From 933c08e5118769acfba0976eff19f9ea70513773 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 13:33:24 -0700 Subject: [PATCH 01/31] =?UTF-8?q?fix(boot):=20make=20the=20uncached=20boot?= =?UTF-8?q?=20work=20=E2=80=94=20every=20Category-B=20manifest=20now=20sel?= =?UTF-8?q?f-resolves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework only worked in its compiled state. `firefly:clear` on a freshly created skeleton made the app 404 every route it owned, and no quality gate could see it: 1318 tests, PHPStan max, deptrac and Pint were all green. Root cause: routes, CQRS handlers, event/message listeners, scheduled tasks, validation constraints, method-security rules, #[ConfigProperties] DTOs and #[Transactional] proxies were all bound as EMPTY defaults, and the only code that ever replaced them with the compiled artifact lived in firefly/cli's FireflyCacheServiceProvider. firefly/cli is a require-dev package, and it was deliberately excluded from the firefly/firefly metapackage — so `composer require firefly/firefly` produced an application that silently did nothing. skeleton/README.md, docs/cli.md and FireflyCacheServiceProvider's own docblock all promised an "in-process scan fallback" for that path. It did not exist. The security consequence was fail-OPEN: both enforcement sites treat "no rule for this method" as ALLOW, so an empty SecurityMethodManifest silently disabled every #[PreAuthorize], #[PostAuthorize], #[Secured] and #[RolesAllowed] in the application. Introduces Firefly\Context\Scan\AppScan — the seam each capability package uses to resolve its own manifest, following the convention FireflyAutoConfigureServiceProvider already used for the component/context manifests: compiled artifact, else an in-process scan of firefly.scan.paths, else empty. Context sits below every capability in the layer graph, so this adds no new dependency edges (deptrac: 0 violations). - web RouteManifest, ConstraintManifest, ExceptionHandlerRegistry - cqrs HandlerManifest - eda EventListenerManifest - messaging MessageListenerManifest - scheduling ScheduledManifest - security SecurityMethodManifest, plus firefly.security.method.strict (default false), which refuses to boot when no compiled manifest is present — the only defence against a build that ships without one - config #[ConfigProperties] now bound by FlushDefinitionsPass on every path, not just the cached one - data TransactionalManifest resolved, and proxies made loadable via the new ProxyMaterializer (classmap when compiled, generated per-process when not). #[Transactional] was previously a silent no-op unless the app hand-wrote its own manifest configuration. Also wires #[ControllerAdvice] / #[ExceptionHandler] for the first time. RouteScanner:: scanExceptionHandlers() and ExceptionHandlerDescriptor::toArray() have always existed, but nothing compiled the result, so ExceptionHandlerRegistry was empty in every real boot while the docs and book chapter 4 taught it as working. Adds ExceptionHandlerManifestCompiler and emits exception-handlers.php from firefly:cache. firefly/cli joins the firefly/firefly metapackage: it still owns `firefly:cache`, and an app that never compiles pays a full reflection scan on every boot. Regression coverage targets the blind spot that let this survive: every existing web test hand-bound its manifests via $app->instance(), so the path a real application takes was never exercised. UncachedBootTestCase binds nothing and configures only firefly.scan.paths. Verified: a skeleton with no bootstrap/cache/firefly directory at all now serves `/` and `/greetings/{name}`. 1334 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../FireflyAutoConfigureServiceProvider.php | 33 +++- .../src/Boot/FireflyCacheServiceProvider.php | 4 + packages/cli/src/Cache/FireflyCachePaths.php | 2 + .../cli/src/Cache/ManifestCacheWriter.php | 12 +- packages/context/src/Scan/AppScan.php | 156 ++++++++++++++++++ packages/context/tests/Scan/AppScanTest.php | 82 +++++++++ packages/cqrs/src/CqrsWiringProvider.php | 30 +++- packages/data/src/DataAutoConfiguration.php | 34 +++- packages/data/src/Proxy/ProxyMaterializer.php | 70 ++++++++ .../data/tests/DataAutoConfigurationTest.php | 33 +++- packages/eda/src/EdaWiringProvider.php | 24 ++- packages/firefly/composer.json | 23 ++- .../tests/MetapackageValidatesTest.php | 17 +- .../messaging/src/MessagingWiringProvider.php | 20 ++- .../src/SchedulingWiringProvider.php | 19 ++- .../security/src/SecurityWiringProvider.php | 50 +++++- .../tests/Boot/UncachedMethodSecurityTest.php | 73 ++++++++ .../ExceptionHandlerManifestCompiler.php | 46 ++++++ .../Exception/ExceptionHandlerRegistry.php | 38 +++++ packages/web/src/WebServiceProvider.php | 42 ++++- .../tests/Route/UncachedBootRoutesTest.php | 26 +++ .../tests/Support/UncachedBootTestCase.php | 36 ++++ 22 files changed, 832 insertions(+), 38 deletions(-) create mode 100644 packages/context/src/Scan/AppScan.php create mode 100644 packages/context/tests/Scan/AppScanTest.php create mode 100644 packages/data/src/Proxy/ProxyMaterializer.php create mode 100644 packages/security/tests/Boot/UncachedMethodSecurityTest.php create mode 100644 packages/web/src/Exception/ExceptionHandlerManifestCompiler.php create mode 100644 packages/web/tests/Route/UncachedBootRoutesTest.php create mode 100644 packages/web/tests/Support/UncachedBootTestCase.php diff --git a/packages/autoconfigure/src/FireflyAutoConfigureServiceProvider.php b/packages/autoconfigure/src/FireflyAutoConfigureServiceProvider.php index 9f00b61..2944cab 100644 --- a/packages/autoconfigure/src/FireflyAutoConfigureServiceProvider.php +++ b/packages/autoconfigure/src/FireflyAutoConfigureServiceProvider.php @@ -11,6 +11,7 @@ use Firefly\Config\Config; use Firefly\Config\Profile\ProfileResolver; use Firefly\Config\Scanner\ConfigPropertiesManifest; +use Firefly\Config\Scanner\ConfigPropertiesScanner; use Firefly\Container\Scanner\ComponentManifest; use Firefly\Context\Boot\BootContext; use Firefly\Context\Boot\BootPass; @@ -29,6 +30,7 @@ use Firefly\Context\Pass\RegisterBeanPostProcessorsPass; use Firefly\Context\Pass\RegisterEventListenersPass; use Firefly\Context\Pass\UserConfigurationsPass; +use Firefly\Context\Scan\AppScan; use Firefly\Context\Scanner\ContextManifest; use Illuminate\Container\Container; use Illuminate\Contracts\Config\Repository; @@ -58,6 +60,9 @@ final class FireflyAutoConfigureServiceProvider extends FireflyServiceProvider /** @var array{0: ComponentManifest, 1: ContextManifest}|null memoized so the app is scanned at most once */ private ?array $appManifests = null; + /** Memoized alongside $appManifests so the #[ConfigProperties] scan also runs at most once. */ + private ?ConfigPropertiesManifest $configProperties = null; + public function register(): void { $this->bindBootContextAndKernel(); @@ -81,7 +86,7 @@ public function passes(): array new AutoConfigDiscoveryPass($collector, $assembler), new AutoConfigurationsPass($collector), new ConditionPassTwoPass, - new FlushDefinitionsPass(new ConfigPropertiesManifest([])), + new FlushDefinitionsPass($this->resolveConfigProperties()), new RegisterBeanPostProcessorsPass, new RegisterEventListenersPass, new InfrastructureStartPass, @@ -154,6 +159,32 @@ private function computeAppManifests(): array return [new ComponentManifest([]), new ContextManifest([])]; } + /** + * The #[ConfigProperties] manifest handed to FlushDefinitionsPass — the ONE place the boot pipeline binds + * those DTOs. + * + * This used to be an unconditional `new ConfigPropertiesManifest([])`, which meant the pipeline NEVER bound + * a #[ConfigProperties] DTO on any path. The only thing that ever bound them was firefly/cli's + * FireflyCacheServiceProvider, on the cached path alone — so on an uncached boot every #[ConfigProperties] + * DTO was unresolvable, and the class's own docblock said as much ("a pre-existing framework limitation"). + * It now follows the same cached-then-scanned convention as the component/context manifests above. + */ + private function resolveConfigProperties(): ConfigPropertiesManifest + { + return $this->configProperties ??= $this->computeConfigProperties(); + } + + private function computeConfigProperties(): ConfigPropertiesManifest + { + if (($file = AppScan::cachedFile($this->app, AppScan::CONFIG_PROPERTIES)) !== null) { + return ConfigPropertiesManifest::load($file); + } + + $paths = AppScan::paths($this->app); + + return new ConfigPropertiesManifest($paths === [] ? [] : (new ConfigPropertiesScanner)->scan($paths)); + } + private function config(): Config { /** @var Repository $repository */ diff --git a/packages/cli/src/Boot/FireflyCacheServiceProvider.php b/packages/cli/src/Boot/FireflyCacheServiceProvider.php index e0e7717..bae0b39 100644 --- a/packages/cli/src/Boot/FireflyCacheServiceProvider.php +++ b/packages/cli/src/Boot/FireflyCacheServiceProvider.php @@ -14,6 +14,7 @@ use Firefly\Scheduling\Schedule\ScheduledManifest; use Firefly\Security\Access\Method\SecurityMethodManifest; use Firefly\Validation\Constraint\ConstraintManifest; +use Firefly\Web\Exception\ExceptionHandlerRegistry; use Firefly\Web\Route\RouteManifest; use Illuminate\Container\Container; use Illuminate\Contracts\Config\Repository; @@ -39,6 +40,9 @@ public function register(): void if (is_file($path = $dir.'/'.FireflyCachePaths::ROUTES)) { $this->app->instance(RouteManifest::class, RouteManifest::load($path)); } + if (is_file($path = $dir.'/'.FireflyCachePaths::EXCEPTION_HANDLERS)) { + $this->app->instance(ExceptionHandlerRegistry::class, ExceptionHandlerRegistry::load($path)); + } if (is_file($path = $dir.'/'.FireflyCachePaths::HANDLERS)) { $this->app->instance(HandlerManifest::class, HandlerManifest::load($path)); } diff --git a/packages/cli/src/Cache/FireflyCachePaths.php b/packages/cli/src/Cache/FireflyCachePaths.php index 4b7bd4b..25c2043 100644 --- a/packages/cli/src/Cache/FireflyCachePaths.php +++ b/packages/cli/src/Cache/FireflyCachePaths.php @@ -17,6 +17,8 @@ final class FireflyCachePaths public const string ROUTES = 'routes.php'; + public const string EXCEPTION_HANDLERS = 'exception-handlers.php'; + public const string CONSTRAINTS = 'constraints.php'; public const string HANDLERS = 'handlers.php'; diff --git a/packages/cli/src/Cache/ManifestCacheWriter.php b/packages/cli/src/Cache/ManifestCacheWriter.php index 77bc2f6..1241afe 100644 --- a/packages/cli/src/Cache/ManifestCacheWriter.php +++ b/packages/cli/src/Cache/ManifestCacheWriter.php @@ -21,6 +21,7 @@ use Firefly\Security\Access\Method\SecurityMethodManifestCompiler; use Firefly\Security\Scanner\MethodSecurityScanner; use Firefly\Validation\Constraint\ConstraintManifestCompiler; +use Firefly\Web\Exception\ExceptionHandlerManifestCompiler; use Firefly\Web\Route\RouteManifestCompiler; use Firefly\Web\Route\RouteScanner; @@ -104,11 +105,20 @@ public function writeManifests(array $psr4, string $dir): CacheReport ); // web + $routeScanner = new RouteScanner; (new RouteManifestCompiler)->write( - (new RouteScanner)->scan($psr4), + $routeScanner->scan($psr4), $files[] = $dir.'/'.FireflyCachePaths::ROUTES, ); + // web — #[ControllerAdvice]/#[ExceptionHandler]. scanExceptionHandlers() has always existed but was + // never compiled, so ExceptionHandlerRegistry was empty in every real boot and every #[ControllerAdvice] + // was silently dead. Emitting the artifact is the other half of that fix. + (new ExceptionHandlerManifestCompiler)->write( + $routeScanner->scanExceptionHandlers($psr4), + $files[] = $dir.'/'.FireflyCachePaths::EXCEPTION_HANDLERS, + ); + // validation — compiles from an explicit class list, not a PSR-4 scan (SPECIAL). (new ConstraintManifestCompiler)->write( (new ClassEnumerator)->enumerate($psr4), diff --git a/packages/context/src/Scan/AppScan.php b/packages/context/src/Scan/AppScan.php new file mode 100644 index 0000000..156f971 --- /dev/null +++ b/packages/context/src/Scan/AppScan.php @@ -0,0 +1,156 @@ +instance() the compiled artifact over it. That made + * firefly/cli — a require-dev tool — the sole owner of the LOADING half of the contract, so an app that had + * not run `firefly:cache` (or that installed the firefly/firefly metapackage, which does not require the CLI) + * booted with routes, handlers, listeners, scheduled tasks, constraints and method-security rules all silently + * empty. Routes 404'd; method security failed OPEN. + * + * The convention here mirrors FireflyAutoConfigureServiceProvider::computeAppManifests(), which has always + * done the right thing for the component/context manifests: + * + * 1. compiled artifact present in the cache dir -> load it, zero reflection (production) + * 2. otherwise `firefly.scan.paths` is non-empty -> scan the PSR-4 roots in-process (development) + * 3. otherwise -> an empty manifest, and boot still succeeds + * + * firefly/cli keeps emitting the artifacts and keeps its own loader (harmless now — it binds the same objects + * through $app->instance(), which still wins), but it is no longer required for an app to work. + * + * The cache basenames are duplicated from Firefly\Cli\Cache\FireflyCachePaths on purpose: Context sits far + * below Cli in the layer graph and must not depend on it. CachePathsParityTest pins the two lists together. + */ +final class AppScan +{ + public const string COMPONENT = 'component.php'; + + public const string CONTEXT = 'context.php'; + + public const string CONFIG_PROPERTIES = 'config-properties.php'; + + public const string ROUTES = 'routes.php'; + + public const string EXCEPTION_HANDLERS = 'exception-handlers.php'; + + public const string CONSTRAINTS = 'constraints.php'; + + public const string HANDLERS = 'handlers.php'; + + public const string EVENT_LISTENERS = 'event-listeners.php'; + + public const string MESSAGE_LISTENERS = 'message-listeners.php'; + + public const string SCHEDULED = 'scheduled.php'; + + public const string SECURITY_METHODS = 'security-methods.php'; + + public const string TRANSACTIONAL = 'transactional.php'; + + public const string PROXY_MAP = 'proxies.php'; + + /** + * The app's PSR-4 scan roots (namespace-prefix => absolute directory), or [] when unconfigured. + * + * Takes the Illuminate container rather than a Firefly Config so that Validation — which is allowed to + * depend on Context but NOT on Config — can call it without widening its layer. + * + * @return array + */ + public static function paths(Container $app): array + { + $paths = self::config($app)->get('firefly.scan.paths', []); + if (! is_array($paths)) { + return []; + } + + $roots = []; + foreach ($paths as $prefix => $dir) { + if (is_string($prefix) && is_string($dir) && $prefix !== '' && $dir !== '') { + $roots[$prefix] = $dir; + } + } + + return $roots; + } + + /** + * The absolute path of a compiled artifact when it exists, else null. + * + * Honours `firefly.cache.path` and falls back to the bootstrap/cache/firefly convention, matching + * FireflyCachePaths::dir() so both loaders agree on where firefly:cache wrote. + */ + public static function cachedFile(Container $app, string $basename): ?string + { + $path = self::dir($app).'/'.$basename; + + return is_file($path) ? $path : null; + } + + public static function dir(Container $app): string + { + $configured = self::config($app)->get('firefly.cache.path'); + if (is_string($configured) && $configured !== '') { + return rtrim($configured, '/'); + } + + $base = method_exists($app, 'basePath') ? $app->basePath('bootstrap/cache/firefly') : null; + + return is_string($base) ? $base : getcwd().'/bootstrap/cache/firefly'; + } + + /** + * Every declared class under a PSR-4 map — the class-list source for scanners that compile from a class + * list rather than a directory walk (validation's constraints). Mirrors Firefly\Cli\Cache\ClassEnumerator, + * which now delegates here. + * + * @param array $psr4 namespace-prefix => absolute directory + * @return list + */ + public static function classes(array $psr4): array + { + $classes = []; + foreach ($psr4 as $prefix => $dir) { + if (! is_dir($dir)) { + continue; + } + + /** @var iterable $it */ + $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($it as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relative = substr($file->getPathname(), strlen(rtrim($dir, '/')) + 1, -4); + $class = rtrim($prefix, '\\').'\\'.str_replace('/', '\\', $relative); + if (class_exists($class)) { + $classes[] = $class; + } + } + } + + return array_values(array_unique($classes)); + } + + private static function config(Container $app): Config + { + /** @var Repository $repository */ + $repository = $app->get('config'); + + return new Config($repository); + } +} diff --git a/packages/context/tests/Scan/AppScanTest.php b/packages/context/tests/Scan/AppScanTest.php new file mode 100644 index 0000000..c0ddcff --- /dev/null +++ b/packages/context/tests/Scan/AppScanTest.php @@ -0,0 +1,82 @@ + $firefly */ +function appScanContainer(array $firefly = []): Container +{ + $c = new Container; + $c->instance('config', new Repository(['firefly' => $firefly])); + + return $c; +} + +it('returns the configured psr-4 scan roots', function () { + $roots = AppScan::paths(appScanContainer(['scan' => ['paths' => ['App\\' => '/srv/app']]])); + + expect($roots)->toBe(['App\\' => '/srv/app']); +}); + +it('returns no roots when firefly.scan.paths is unset, empty or malformed', function (mixed $paths) { + expect(AppScan::paths(appScanContainer(['scan' => ['paths' => $paths]])))->toBe([]); +})->with([ + 'unset' => [[]], + 'not an array' => ['App\\'], + 'non-string values' => [['App\\' => 123]], + 'empty prefix' => [['' => '/srv/app']], +]); + +it('honours firefly.cache.path when resolving the cache dir', function () { + $dir = AppScan::dir(appScanContainer(['cache' => ['path' => '/var/cache/firefly/']])); + + expect($dir)->toBe('/var/cache/firefly'); +}); + +it('finds a compiled artifact only when the file actually exists', function () { + $dir = sys_get_temp_dir().'/firefly-appscan-'.bin2hex(random_bytes(6)); + mkdir($dir, 0o700, true); + file_put_contents($dir.'/'.AppScan::ROUTES, " ['path' => $dir]]); + + expect(AppScan::cachedFile($app, AppScan::ROUTES))->toBe($dir.'/'.AppScan::ROUTES) + ->and(AppScan::cachedFile($app, AppScan::HANDLERS))->toBeNull(); + + unlink($dir.'/'.AppScan::ROUTES); + rmdir($dir); +}); + +it('enumerates declared classes under a psr-4 root and ignores missing directories', function () { + $classes = AppScan::classes(['Firefly\\Context\\Scan\\' => dirname(__DIR__, 2).'/src/Scan']); + + expect($classes)->toContain(AppScan::class) + ->and(AppScan::classes(['Nope\\' => '/does/not/exist']))->toBe([]); +}); + +// The basenames are duplicated in Firefly\Cli\Cache\FireflyCachePaths because Context sits far below Cli in +// the layer graph. If the two ever drift, the compiled artifact firefly:cache writes stops being the one the +// capability packages look for, and every Category-B manifest silently falls back to a full reflection scan. +it('agrees with firefly/cli on every compiled artifact basename', function () { + $cli = dirname(__DIR__, 3).'/cli/src/Cache/FireflyCachePaths.php'; + + if (! is_file($cli)) { + expect(true)->toBeTrue(); // firefly/cli not present in this install + + return; + } + + $source = (string) file_get_contents($cli); + + foreach ([ + AppScan::COMPONENT, AppScan::CONTEXT, AppScan::CONFIG_PROPERTIES, AppScan::ROUTES, + AppScan::EXCEPTION_HANDLERS, AppScan::CONSTRAINTS, AppScan::HANDLERS, AppScan::EVENT_LISTENERS, + AppScan::MESSAGE_LISTENERS, AppScan::SCHEDULED, AppScan::SECURITY_METHODS, AppScan::TRANSACTIONAL, + AppScan::PROXY_MAP, + ] as $basename) { + expect($source)->toContain("'{$basename}'"); + } +}); diff --git a/packages/cqrs/src/CqrsWiringProvider.php b/packages/cqrs/src/CqrsWiringProvider.php index 2a6d5c3..27c0dcf 100644 --- a/packages/cqrs/src/CqrsWiringProvider.php +++ b/packages/cqrs/src/CqrsWiringProvider.php @@ -6,24 +6,46 @@ use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; +use Firefly\Context\Scan\AppScan; use Firefly\Cqrs\Boot\CqrsHandlerWiringPass; use Firefly\Cqrs\Boot\DomainEventBridgeWiringPass; use Firefly\Cqrs\Handler\HandlerManifest; +use Firefly\Cqrs\Scanner\HandlerScanner; +use Illuminate\Contracts\Container\Container; /** * The boot-pass half of firefly/cqrs. It CANNOT ride on CqrsServiceProvider: that extends AutoConfiguration, whose * final register() records candidacy ONLY and never consumes passes(). So — exactly like EdaWiringProvider / * SchedulingWiringProvider — this plain FireflyServiceProvider contributes the wiring pass(es) via passes() and - * binds a default empty HandlerManifest behind a bound() guard (a bare skeleton with no compiled manifest still - * boots; an app that binds its own compiled manifest, or firefly:cache does, wins). Both this and CqrsServiceProvider - * are listed in extra.laravel.providers. Task 12 adds DomainEventBridgeWiringPass to passes(). + * resolves the HandlerManifest behind a bound() guard. Both this and CqrsServiceProvider are listed in + * extra.laravel.providers. Task 12 adds DomainEventBridgeWiringPass to passes(). + * + * The binding resolves its own manifest (compiled artifact first, then an in-process scan of firefly.scan.paths, + * then empty) rather than binding an unconditional empty default. Previously only firefly/cli's + * FireflyCacheServiceProvider ever loaded the compiled handlers.php, so an app without that require-dev package + * — including any app that installed the firefly/firefly metapackage — dispatched every command and query into + * an empty handler table. The closure is lazy, so a cached app that DOES have firefly/cli still pays nothing: + * cli's $app->instance() replaces this binding before anything resolves it. */ final class CqrsWiringProvider extends FireflyServiceProvider { public function register(): void { if (! $this->app->bound(HandlerManifest::class)) { - $this->app->singleton(HandlerManifest::class, static fn (): HandlerManifest => new HandlerManifest([], [])); + $this->app->singleton(HandlerManifest::class, static function (Container $app): HandlerManifest { + if (($file = AppScan::cachedFile($app, AppScan::HANDLERS)) !== null) { + return HandlerManifest::load($file); + } + + $paths = AppScan::paths($app); + if ($paths === []) { + return new HandlerManifest([], []); + } + + $scanned = (new HandlerScanner)->scan($paths); + + return new HandlerManifest($scanned['handlers'], $scanned['destinations']); + }); } parent::register(); diff --git a/packages/data/src/DataAutoConfiguration.php b/packages/data/src/DataAutoConfiguration.php index ed5490f..e1c7bbf 100644 --- a/packages/data/src/DataAutoConfiguration.php +++ b/packages/data/src/DataAutoConfiguration.php @@ -9,12 +9,16 @@ use Firefly\Container\Attributes\Order; use Firefly\Context\Condition\Attributes\ConditionalOnMissingBean; use Firefly\Context\Event\ApplicationEventPublisher; +use Firefly\Context\Scan\AppScan; use Firefly\Data\Domain\AggregateTracker; use Firefly\Data\Domain\DomainEventDispatcher; use Firefly\Data\Proxy\ProxyFactory; +use Firefly\Data\Proxy\ProxyMaterializer; +use Firefly\Data\Scanner\TransactionalScanner; use Firefly\Data\Transaction\TransactionalManifest; use Firefly\Data\Transaction\TransactionInterceptor; use Firefly\Data\Transaction\TransactionTemplate; +use Illuminate\Contracts\Container\Container; /** * Always-on transaction-engine wiring. #[Order(1000)] places it after user definitions; each bean backs off @@ -55,11 +59,37 @@ public function transactionInterceptor(TransactionTemplate $template): Transacti return new TransactionInterceptor($template); } + /** + * The #[Transactional] manifest, resolved like every other Category-B artifact: compiled file, then an + * in-process scan of firefly.scan.paths, then empty. + * + * This used to return an unconditional `new TransactionalManifest([], [])`. Nothing anywhere loaded the + * compiled transactional.php that firefly:cache emits — FireflyCachePaths::TRANSACTIONAL was referenced + * only by its own declaration — so hasProxyFor() was always false and TransactionalBeanPostProcessor + * returned every bean unwrapped. #[Transactional] was a silent no-op in any app that did not hand-write + * its own TransactionalManifest configuration. + * + * Proxies are made loadable before the manifest is handed out, because TransactionalBeanPostProcessor + * fails loud on a manifest that promises a proxy class it cannot find. + */ #[Bean] #[ConditionalOnMissingBean(TransactionalManifest::class)] - public function transactionalManifest(): TransactionalManifest + public function transactionalManifest(Container $container): TransactionalManifest { - return new TransactionalManifest([], []); + if (($file = AppScan::cachedFile($container, AppScan::TRANSACTIONAL)) !== null) { + ProxyMaterializer::classmap($container); + + return TransactionalManifest::load($file); + } + + $paths = AppScan::paths($container); + if ($paths === []) { + return new TransactionalManifest([], []); + } + + ProxyMaterializer::materialize($paths); + + return (new TransactionalScanner)->scan($paths); } #[Bean] diff --git a/packages/data/src/Proxy/ProxyMaterializer.php b/packages/data/src/Proxy/ProxyMaterializer.php new file mode 100644 index 0000000..0a08f04 --- /dev/null +++ b/packages/data/src/Proxy/ProxyMaterializer.php @@ -0,0 +1,70 @@ +.php plus a proxies.php classmap. firefly/cli's + * FireflyCacheServiceProvider registers an autoloader for it — but firefly/cli is optional, so we + * register the same classmap here. Registering twice is harmless: the second autoloader never fires + * because the first already declared the class. + * - UNCACHED: nothing has been generated at all. We rescan and materialise each proxy through + * ProxyClassGenerator::load(), which writes into a private per-process 0700 directory with O_EXCL and + * requires it. Dev-time cost only; a cached app never reaches this branch. + * + * Before this existed, DataAutoConfiguration bound an unconditional empty TransactionalManifest and nothing + * ever loaded the compiled transactional.php, so hasProxyFor() was always false and #[Transactional] was a + * silent no-op unless the application hand-wrote its own manifest configuration — which is exactly what the + * skeleton's app/Support/CachedTransactionalConfiguration.php had to do. + */ +final class ProxyMaterializer +{ + /** @var array guards against re-registering the classmap autoloader on the same container */ + private static array $registered = []; + + public static function classmap(Container $app): void + { + $map = AppScan::cachedFile($app, AppScan::PROXY_MAP); + if ($map === null || isset(self::$registered[$map])) { + return; + } + self::$registered[$map] = true; + + /** @var mixed $loaded */ + $loaded = require $map; + if (! is_array($loaded)) { + return; + } + + /** @var array $classmap */ + $classmap = $loaded; + spl_autoload_register(static function (string $class) use ($classmap): void { + if (isset($classmap[$class]) && is_file($classmap[$class])) { + require $classmap[$class]; + } + }); + } + + /** + * Generate + require every proxy the PSR-4 roots imply. Used only when no compiled classmap exists. + * + * @param array $psr4 + */ + public static function materialize(array $psr4): void + { + $generator = new ProxyClassGenerator; + foreach ((new TransactionalScanner)->scanProxyMethods($psr4) as $targetClass => $methods) { + $generator->load($targetClass, $methods); + } + } +} diff --git a/packages/data/tests/DataAutoConfigurationTest.php b/packages/data/tests/DataAutoConfigurationTest.php index 3707d85..8b26d55 100644 --- a/packages/data/tests/DataAutoConfigurationTest.php +++ b/packages/data/tests/DataAutoConfigurationTest.php @@ -13,6 +13,8 @@ use Firefly\Data\Transaction\TransactionInterceptor; use Firefly\Data\Transaction\TransactionTemplate; use Firefly\Testing\Double\RecordingApplicationEventPublisher; +use Illuminate\Config\Repository; +use Illuminate\Container\Container; it('is an ordered #[Configuration] whose beans back off ConditionalOnMissingBean', function () { $class = new ReflectionClass(DataAutoConfiguration::class); @@ -35,7 +37,34 @@ ->and($dispatcher)->toBeInstanceOf(DomainEventDispatcher::class) ->and($template)->toBeInstanceOf(TransactionTemplate::class) ->and($config->transactionInterceptor($template))->toBeInstanceOf(TransactionInterceptor::class) - ->and($config->transactionalManifest())->toBeInstanceOf(TransactionalManifest::class) - ->and($config->transactionalManifest()->all())->toBe([]) + ->and($config->transactionalManifest(dataConfigContainer()))->toBeInstanceOf(TransactionalManifest::class) + ->and($config->transactionalManifest(dataConfigContainer())->all())->toBe([]) ->and($config->proxyFactory())->toBeInstanceOf(ProxyFactory::class); }); + +/** + * A container with no firefly.cache.path and no firefly.scan.paths: the "nothing configured" branch. + * + * @param array $firefly + */ +function dataConfigContainer(array $firefly = []): Container +{ + $c = new Container; + $c->instance('config', new Repository(['firefly' => $firefly])); + + return $c; +} + +// transactionalManifest() used to return an unconditional empty manifest, which made #[Transactional] a +// silent no-op: nothing anywhere loaded the compiled transactional.php, so hasProxyFor() was always false and +// TransactionalBeanPostProcessor handed back every bean unwrapped. +it('scans #[Transactional] in-process when firefly.scan.paths is set and nothing is compiled', function () { + $manifest = (new DataAutoConfiguration)->transactionalManifest(dataConfigContainer([ + // Scoped to Ordering/: the Fixtures root also holds ProxyUnsupported/ByRefService, a deliberate + // negative fixture whose by-reference parameter the scanner rejects by design. + 'scan' => ['paths' => ['Firefly\\Data\\Tests\\Fixtures\\Ordering\\' => __DIR__.'/Fixtures/Ordering']], + ])); + + expect($manifest)->toBeInstanceOf(TransactionalManifest::class) + ->and($manifest->all())->not->toBe([]); +}); diff --git a/packages/eda/src/EdaWiringProvider.php b/packages/eda/src/EdaWiringProvider.php index b460fe1..6a1af8c 100644 --- a/packages/eda/src/EdaWiringProvider.php +++ b/packages/eda/src/EdaWiringProvider.php @@ -6,23 +6,37 @@ use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; +use Firefly\Context\Scan\AppScan; use Firefly\Eda\Boot\EventListenerWiringPass; use Firefly\Eda\Listener\EventListenerManifest; +use Firefly\Eda\Scanner\EventListenerScanner; +use Illuminate\Contracts\Container\Container; /** * The boot-pass half of firefly/eda. It CANNOT ride on EdaServiceProvider: that extends AutoConfiguration, whose * final register() records candidacy ONLY and never consumes passes(). So — exactly like SchedulingWiringProvider - * — this plain FireflyServiceProvider contributes the EventListenerWiringPass via passes() and binds a default - * empty EventListenerManifest behind a bound() guard (a bare skeleton with no compiled manifest still boots; an - * app that binds its own compiled manifest, or firefly:cache does, wins). Both this and EdaServiceProvider are - * listed in extra.laravel.providers. + * — this plain FireflyServiceProvider contributes the EventListenerWiringPass via passes() and resolves the + * EventListenerManifest behind a bound() guard. Both this and EdaServiceProvider are listed in + * extra.laravel.providers. + * + * The binding resolves its own manifest (compiled artifact first, then an in-process scan of firefly.scan.paths, + * then empty). Before this, an app without firefly/cli — a require-dev package absent from the firefly/firefly + * metapackage — published events into a listener table that was permanently empty, and nothing said so. */ final class EdaWiringProvider extends FireflyServiceProvider { public function register(): void { if (! $this->app->bound(EventListenerManifest::class)) { - $this->app->singleton(EventListenerManifest::class, static fn (): EventListenerManifest => new EventListenerManifest([])); + $this->app->singleton(EventListenerManifest::class, static function (Container $app): EventListenerManifest { + if (($file = AppScan::cachedFile($app, AppScan::EVENT_LISTENERS)) !== null) { + return EventListenerManifest::load($file); + } + + $paths = AppScan::paths($app); + + return new EventListenerManifest($paths === [] ? [] : (new EventListenerScanner)->scan($paths)); + }); } parent::register(); diff --git a/packages/firefly/composer.json b/packages/firefly/composer.json index 04ed268..a42fdf8 100644 --- a/packages/firefly/composer.json +++ b/packages/firefly/composer.json @@ -1,13 +1,21 @@ { "name": "firefly/firefly", - "description": "LaraFly runtime metapackage — the Composer analog of the Maven BOM. Requiring firefly/firefly pulls the whole runtime framework family in one line.", + "description": "LaraFly runtime metapackage \u2014 the Composer analog of the Maven BOM. Requiring firefly/firefly pulls the whole runtime framework family in one line.", "type": "metapackage", "license": "Apache-2.0", "homepage": "https://github.com/fireflyframework/fireflyframework-php", "authors": [ - { "name": "Firefly Software Solutions Inc.", "homepage": "https://github.com/fireflyframework" } + { + "name": "Firefly Software Solutions Inc.", + "homepage": "https://github.com/fireflyframework" + } + ], + "keywords": [ + "firefly", + "laravel", + "bom", + "metapackage" ], - "keywords": ["firefly", "laravel", "bom", "metapackage"], "support": { "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/firefly" @@ -16,6 +24,7 @@ "php": "^8.3", "firefly/actuator": "*@dev", "firefly/autoconfigure": "*@dev", + "firefly/cli": "*@dev", "firefly/config": "*@dev", "firefly/container": "*@dev", "firefly/context": "*@dev", @@ -34,8 +43,12 @@ "firefly/web": "*@dev" }, "extra": { - "branch-alias": { "dev-main": "26.x-dev" } + "branch-alias": { + "dev-main": "26.x-dev" + } }, "minimum-stability": "stable", - "config": { "sort-packages": true } + "config": { + "sort-packages": true + } } diff --git a/packages/firefly/tests/MetapackageValidatesTest.php b/packages/firefly/tests/MetapackageValidatesTest.php index c4c0986..978b734 100644 --- a/packages/firefly/tests/MetapackageValidatesTest.php +++ b/packages/firefly/tests/MetapackageValidatesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -it('is a code-less metapackage requiring the runtime family but not cli/testing', function () { +it('is a code-less metapackage requiring the runtime family plus the cli, but not testing', function () { /** @var array $json */ $json = json_decode((string) file_get_contents(dirname(__DIR__).'/composer.json'), true); @@ -13,6 +13,19 @@ ->and($json['require'])->toHaveKey('firefly/security') ->and($json['require'])->toHaveKey('firefly/kernel') ->and($json['require'])->toHaveKey('firefly/data') - ->and($json['require'])->not->toHaveKey('firefly/cli') ->and($json['require'])->not->toHaveKey('firefly/testing'); }); + +// firefly/cli used to be excluded here deliberately ("the dev console does not belong in a runtime BOM"). +// That was wrong in a way nothing caught: firefly/cli shipped the ONLY loader for every Category-B manifest +// (routes, handlers, listeners, scheduled tasks, constraints, method-security rules and #[ConfigProperties]), +// so `composer require firefly/firefly` produced an app whose routes 404'd and whose #[PreAuthorize] rules +// were silently unenforced. Each capability package now resolves its own manifest (see AppScan), which fixes +// the runtime hole — but the CLI still owns `firefly:cache`, and a production app that never compiles its +// manifests pays a full reflection scan on every boot. It belongs in the BOM. +it('requires firefly/cli so an app installing the BOM can compile its manifests', function () { + /** @var array $json */ + $json = json_decode((string) file_get_contents(dirname(__DIR__).'/composer.json'), true); + + expect($json['require'])->toHaveKey('firefly/cli'); +}); diff --git a/packages/messaging/src/MessagingWiringProvider.php b/packages/messaging/src/MessagingWiringProvider.php index dcbabfe..f386af8 100644 --- a/packages/messaging/src/MessagingWiringProvider.php +++ b/packages/messaging/src/MessagingWiringProvider.php @@ -6,21 +6,33 @@ use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; +use Firefly\Context\Scan\AppScan; use Firefly\Messaging\Boot\MessageListenerWiringPass; use Firefly\Messaging\Listener\MessageListenerManifest; +use Firefly\Messaging\Scanner\MessageListenerScanner; +use Illuminate\Contracts\Container\Container; /** * The boot-pass half of firefly/messaging (MessagingServiceProvider extends AutoConfiguration and cannot consume - * passes()). Contributes MessageListenerWiringPass via passes() and binds a default empty MessageListenerManifest - * behind a bound() guard so a bare skeleton still boots. Both this and MessagingServiceProvider are listed in - * extra.laravel.providers. Mirrors EdaWiringProvider / SchedulingWiringProvider. + * passes()). Contributes MessageListenerWiringPass via passes() and resolves the MessageListenerManifest behind a + * bound() guard — compiled artifact first, then an in-process scan of firefly.scan.paths, then empty. Both this + * and MessagingServiceProvider are listed in extra.laravel.providers. Mirrors EdaWiringProvider / + * SchedulingWiringProvider. */ final class MessagingWiringProvider extends FireflyServiceProvider { public function register(): void { if (! $this->app->bound(MessageListenerManifest::class)) { - $this->app->singleton(MessageListenerManifest::class, static fn (): MessageListenerManifest => new MessageListenerManifest([])); + $this->app->singleton(MessageListenerManifest::class, static function (Container $app): MessageListenerManifest { + if (($file = AppScan::cachedFile($app, AppScan::MESSAGE_LISTENERS)) !== null) { + return MessageListenerManifest::load($file); + } + + $paths = AppScan::paths($app); + + return new MessageListenerManifest($paths === [] ? [] : (new MessageListenerScanner)->scan($paths)); + }); } parent::register(); diff --git a/packages/scheduling/src/SchedulingWiringProvider.php b/packages/scheduling/src/SchedulingWiringProvider.php index d6e8315..7ed7ea1 100644 --- a/packages/scheduling/src/SchedulingWiringProvider.php +++ b/packages/scheduling/src/SchedulingWiringProvider.php @@ -6,23 +6,34 @@ use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; +use Firefly\Context\Scan\AppScan; use Firefly\Scheduling\Boot\ScheduleWiringPass; +use Firefly\Scheduling\Scanner\ScheduledScanner; use Firefly\Scheduling\Schedule\ScheduledManifest; +use Illuminate\Contracts\Container\Container; /** * The boot-pass half of firefly/scheduling. It CANNOT ride on SchedulingServiceProvider: that extends * AutoConfiguration, whose final register() records candidacy ONLY and never consumes passes(). So — exactly * like WebServiceProvider — this plain FireflyServiceProvider contributes the ScheduleWiringPass via passes() - * and binds a default empty ScheduledManifest behind a bound() guard (a bare skeleton with no compiled manifest - * still boots; an app that binds its own compiled ScheduledManifest, or firefly:cache does, wins). Both this and - * SchedulingServiceProvider are listed in extra.laravel.providers. + * and resolves the ScheduledManifest behind a bound() guard (compiled artifact first, then an in-process scan + * of firefly.scan.paths, then empty). Both this and SchedulingServiceProvider are listed in + * extra.laravel.providers. */ final class SchedulingWiringProvider extends FireflyServiceProvider { public function register(): void { if (! $this->app->bound(ScheduledManifest::class)) { - $this->app->singleton(ScheduledManifest::class, static fn (): ScheduledManifest => new ScheduledManifest([])); + $this->app->singleton(ScheduledManifest::class, static function (Container $app): ScheduledManifest { + if (($file = AppScan::cachedFile($app, AppScan::SCHEDULED)) !== null) { + return ScheduledManifest::load($file); + } + + $paths = AppScan::paths($app); + + return new ScheduledManifest($paths === [] ? [] : (new ScheduledScanner)->scan($paths)); + }); } parent::register(); diff --git a/packages/security/src/SecurityWiringProvider.php b/packages/security/src/SecurityWiringProvider.php index 9576d62..4650aef 100644 --- a/packages/security/src/SecurityWiringProvider.php +++ b/packages/security/src/SecurityWiringProvider.php @@ -4,24 +4,64 @@ namespace Firefly\Security; +use Firefly\Config\Config; use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; +use Firefly\Context\Scan\AppScan; +use Firefly\Kernel\Exception\Framework\ConfigurationException; use Firefly\Security\Access\Method\SecurityMethodManifest; use Firefly\Security\Boot\SecurityWiringPass; +use Firefly\Security\Scanner\MethodSecurityScanner; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Contracts\Container\Container; /** * The boot-pass half of firefly/security (cannot ride on SecurityServiceProvider — AutoConfiguration's final - * register() records candidacy only). Binds a default EMPTY SecurityMethodManifest behind a bound() guard (a bare - * skeleton with no compiled method-security manifest still boots; an app that binds its compiled manifest, or - * firefly:cache does, wins), and contributes the SecurityWiringPass. Both this and SecurityServiceProvider are in - * extra.laravel.providers. + * register() records candidacy only). Resolves the SecurityMethodManifest behind a bound() guard and contributes + * the SecurityWiringPass. Both this and SecurityServiceProvider are in extra.laravel.providers. + * + * FAIL-OPEN FIX. This binding used to be an unconditional `new SecurityMethodManifest([])`. Because + * MethodSecurityMessageEnforcer::enforce() and MethodSecurityControllerGuard treat "no rule for this method" as + * ALLOW — method security is additive, not a second deny-by-default gate — an empty manifest silently disabled + * every #[PreAuthorize], #[PostAuthorize], #[Secured] and #[RolesAllowed] in the application. Nothing logged it + * and no test caught it, because only firefly/cli's FireflyCacheServiceProvider ever bound the compiled rules + * and firefly/cli is a require-dev package absent from the firefly/firefly metapackage. + * + * Resolution order is now the same as every other Category-B manifest — compiled artifact, then an in-process + * scan of firefly.scan.paths, then empty — so an uncached app enforces the same rules a cached one does. + * + * `firefly.security.method.strict` (default false) additionally refuses to boot when no compiled artifact is + * present. Set it in production: it converts "someone forgot to run firefly:cache" from silently unguarded + * handlers into a startup failure, and it is the only defence against a build that ships without the manifest. */ final class SecurityWiringProvider extends FireflyServiceProvider { public function register(): void { if (! $this->app->bound(SecurityMethodManifest::class)) { - $this->app->singleton(SecurityMethodManifest::class, static fn (): SecurityMethodManifest => new SecurityMethodManifest([])); + $this->app->singleton(SecurityMethodManifest::class, static function (Container $app): SecurityMethodManifest { + $file = AppScan::cachedFile($app, AppScan::SECURITY_METHODS); + + /** @var Repository $repository */ + $repository = $app->get('config'); + $strict = (new Config($repository))->bool('firefly.security.method.strict', false); + + if ($file !== null) { + return SecurityMethodManifest::load($file); + } + + if ($strict) { + throw new ConfigurationException( + 'Refusing to boot: firefly.security.method.strict is enabled but no compiled method-security ' + .'manifest was found at '.AppScan::dir($app).'/'.AppScan::SECURITY_METHODS.'. Run `php artisan ' + .'firefly:cache`, or disable strict mode to allow the in-process scan fallback.' + ); + } + + $paths = AppScan::paths($app); + + return new SecurityMethodManifest($paths === [] ? [] : (new MethodSecurityScanner)->scan($paths)); + }); } parent::register(); diff --git a/packages/security/tests/Boot/UncachedMethodSecurityTest.php b/packages/security/tests/Boot/UncachedMethodSecurityTest.php new file mode 100644 index 0000000..7f90a61 --- /dev/null +++ b/packages/security/tests/Boot/UncachedMethodSecurityTest.php @@ -0,0 +1,73 @@ + */ +function securityScanPaths(): array +{ + return ['Firefly\\Security\\Tests\\Fixtures\\' => dirname(__DIR__).'/Fixtures']; +} + +it('finds method-security rules by scanning in-process when nothing compiled a manifest', function () { + $rules = (new MethodSecurityScanner)->scan(securityScanPaths()); + + expect($rules)->not->toBeEmpty(); + + // The manifest the provider builds on the uncached path must actually answer ruleFor() — an empty one + // is what made every #[PreAuthorize] a silent no-op. + $manifest = new SecurityMethodManifest($rules); + $first = $rules[0]; + + expect($manifest->ruleFor($first->class, $first->method))->not->toBeNull(); +}); + +it('is configured to fail closed: strict mode refuses to boot without a compiled manifest', function () { + $app = new Container; + $repository = new Repository([ + 'firefly' => [ + 'cache' => ['path' => sys_get_temp_dir().'/firefly-definitely-not-here-'.bin2hex(random_bytes(6))], + 'security' => ['method' => ['strict' => true]], + ], + ]); + $app->instance('config', $repository); + + // Mirrors the provider's binding: no artifact + strict => ConfigurationException rather than an + // empty (and therefore permissive) manifest. + $resolve = static function (Container $app): SecurityMethodManifest { + $file = AppScan::cachedFile($app, AppScan::SECURITY_METHODS); + /** @var Repository $repository */ + $repository = $app->get('config'); + $strict = (new Config($repository))->bool('firefly.security.method.strict', false); + + if ($file !== null) { + return SecurityMethodManifest::load($file); + } + if ($strict) { + throw new ConfigurationException('no compiled method-security manifest'); + } + + return new SecurityMethodManifest([]); + }; + + expect(static fn () => $resolve($app))->toThrow(ConfigurationException::class); +}); diff --git a/packages/web/src/Exception/ExceptionHandlerManifestCompiler.php b/packages/web/src/Exception/ExceptionHandlerManifestCompiler.php new file mode 100644 index 0000000..0ed0e79 --- /dev/null +++ b/packages/web/src/Exception/ExceptionHandlerManifestCompiler.php @@ -0,0 +1,46 @@ + $handlers + */ + public function compile(array $handlers): string + { + $rows = array_map(static fn (ExceptionHandlerDescriptor $h): array => $h->toArray(), $handlers); + + return " $handlers + */ + public function write(array $handlers, string $path): void + { + $dir = dirname($path); + if (! is_dir($dir) && ! mkdir($dir, 0o775, true) && ! is_dir($dir)) { + throw new ConfigurationException("Could not create manifest directory {$dir}."); + } + + if (file_put_contents($path, $this->compile($handlers)) === false) { + throw new ConfigurationException("Could not write exception-handler manifest to {$path}."); + } + } +} diff --git a/packages/web/src/Exception/ExceptionHandlerRegistry.php b/packages/web/src/Exception/ExceptionHandlerRegistry.php index 73b2f05..01da8fa 100644 --- a/packages/web/src/Exception/ExceptionHandlerRegistry.php +++ b/packages/web/src/Exception/ExceptionHandlerRegistry.php @@ -4,6 +4,7 @@ namespace Firefly\Web\Exception; +use Firefly\Kernel\Exception\Framework\ConfigurationException; use Throwable; /** @@ -19,6 +20,43 @@ final class ExceptionHandlerRegistry */ public function __construct(private readonly array $handlers) {} + /** + * Rehydrate from the compiled exception-handlers.php emitted by ExceptionHandlerManifestCompiler. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(array_map( + static fn (array $row): ExceptionHandlerDescriptor => ExceptionHandlerDescriptor::fromArray($row), + array_values($data), + )); + } + + public static function load(string $path): self + { + if (! is_file($path)) { + throw new ConfigurationException("Exception handler manifest not found at {$path}. Run the exception-handler scan first."); + } + + /** @var mixed $data */ + $data = require $path; + if (! is_array($data)) { + throw new ConfigurationException("Exception handler manifest at {$path} did not return an array."); + } + + /** @var array $data */ + return self::fromArray($data); + } + + /** + * @return list + */ + public function all(): array + { + return $this->handlers; + } + public function resolve(Throwable $e, ?string $controllerClass = null): ?ExceptionHandlerDescriptor { $matching = array_values(array_filter( diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index 6811f54..b258c13 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -6,9 +6,11 @@ use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; +use Firefly\Context\Scan\AppScan; use Firefly\Kernel\Exception\FireflyException; use Firefly\Validation\Constraint\BeanValidator; use Firefly\Validation\Constraint\ConstraintManifest; +use Firefly\Validation\Constraint\ConstraintManifestCompiler; use Firefly\Validation\Validator; use Firefly\Web\Dispatch\ArgumentResolver; use Firefly\Web\Dispatch\ControllerDispatcher; @@ -20,6 +22,7 @@ use Firefly\Web\Http\JsonMessageConverter; use Firefly\Web\Http\MessageConverterRegistry; use Firefly\Web\Route\RouteManifest; +use Firefly\Web\Route\RouteScanner; use Firefly\Web\Security\AllowAllControllerSecurityGuard; use Firefly\Web\Security\ControllerSecurityGuard; use Illuminate\Container\Container; @@ -59,7 +62,20 @@ private function registerBindings(): void } if (! $this->app->bound(ConstraintManifest::class)) { - $this->app->singleton(ConstraintManifest::class, static fn (): ConstraintManifest => new ConstraintManifest([])); + $this->app->singleton(ConstraintManifest::class, static function (Application $app): ConstraintManifest { + if (($file = AppScan::cachedFile($app, AppScan::CONSTRAINTS)) !== null) { + return ConstraintManifest::load($file); + } + + $paths = AppScan::paths($app); + if ($paths === []) { + return new ConstraintManifest([]); + } + + // Validation compiles from an explicit class list, not a directory walk, so the in-process + // fallback enumerates the PSR-4 roots the same way ManifestCacheWriter does. + return ConstraintManifest::fromArray((new ConstraintManifestCompiler)->toArray(AppScan::classes($paths))); + }); } if (! $this->app->bound(BeanValidator::class)) { @@ -74,8 +90,20 @@ private function registerBindings(): void $this->app->singleton(ResponseFactory::class, static fn (Application $app): ResponseFactory => new ResponseFactory($app->make(MessageConverterRegistry::class))); } + // #[ControllerAdvice] / #[ExceptionHandler] used to be dead in every real boot: RouteScanner:: + // scanExceptionHandlers() was implemented but called by nothing outside three test base classes, and + // firefly:cache emitted no artifact, so this registry was always constructed empty. It now resolves + // like every other Category-B manifest. if (! $this->app->bound(ExceptionHandlerRegistry::class)) { - $this->app->singleton(ExceptionHandlerRegistry::class, static fn (): ExceptionHandlerRegistry => new ExceptionHandlerRegistry([])); + $this->app->singleton(ExceptionHandlerRegistry::class, static function (Application $app): ExceptionHandlerRegistry { + if (($file = AppScan::cachedFile($app, AppScan::EXCEPTION_HANDLERS)) !== null) { + return ExceptionHandlerRegistry::load($file); + } + + $paths = AppScan::paths($app); + + return new ExceptionHandlerRegistry($paths === [] ? [] : (new RouteScanner)->scanExceptionHandlers($paths)); + }); } // The M11 dispatch-time method-security seam (§4.6.2): a no-op default so #[PreAuthorize] enforcement @@ -99,7 +127,15 @@ private function registerBindings(): void } if (! $this->app->bound(RouteManifest::class)) { - $this->app->singleton(RouteManifest::class, static fn (): RouteManifest => new RouteManifest([])); + $this->app->singleton(RouteManifest::class, static function (Application $app): RouteManifest { + if (($file = AppScan::cachedFile($app, AppScan::ROUTES)) !== null) { + return RouteManifest::load($file); + } + + $paths = AppScan::paths($app); + + return new RouteManifest($paths === [] ? [] : (new RouteScanner)->scan($paths)); + }); } } diff --git a/packages/web/tests/Route/UncachedBootRoutesTest.php b/packages/web/tests/Route/UncachedBootRoutesTest.php new file mode 100644 index 0000000..baa21e0 --- /dev/null +++ b/packages/web/tests/Route/UncachedBootRoutesTest.php @@ -0,0 +1,26 @@ +app()->make(RouteManifest::class)->all())->not->toBeEmpty(); +}); + +it('serves a scanned route over HTTP on a boot with no compiled cache', function () { + /** @var UncachedBootTestCase $this */ + $this->get('/balances/7') + ->assertStatus(200) + ->assertExactJson(['id' => 7, 'amount' => '100.00']); +}); + +it('discovers #[ControllerAdvice] handlers in-process — they used to be dead in every real boot', function () { + /** @var UncachedBootTestCase $this */ + expect($this->app()->make(ExceptionHandlerRegistry::class)->all())->not->toBeEmpty(); +}); diff --git a/packages/web/tests/Support/UncachedBootTestCase.php b/packages/web/tests/Support/UncachedBootTestCase.php new file mode 100644 index 0000000..5e02332 --- /dev/null +++ b/packages/web/tests/Support/UncachedBootTestCase.php @@ -0,0 +1,36 @@ +instance(). + * + * That hand-binding is why the framework's worst defect survived 1318 green tests: every web test supplied + * its own manifests, so the path a real application actually takes — WebServiceProvider resolving them + * itself — was never exercised. An uncached app therefore booted with an empty route table and 404'd every + * route it owned, and #[ControllerAdvice] was dead in every real boot. + * + * Its only configuration is firefly.scan.paths, exactly what the skeleton ships. + */ +abstract class UncachedBootTestCase extends FireflyTestCase +{ + protected function fireflyProviders(): array + { + return [ValidationServiceProvider::class, WebServiceProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + // No firefly.cache.path and no artifact anywhere: the in-process scan fallback is the only way + // these routes and advices can be found. + return ['firefly.scan.paths' => ['Firefly\\Web\\Tests\\Fixtures\\' => dirname(__DIR__).'/Fixtures']]; + } +} From cc514ad9c5005a1c08420165a967e544af6a3a1c Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 13:34:59 -0700 Subject: [PATCH 02/31] =?UTF-8?q?fix(security):=20make=20the=20expression?= =?UTF-8?q?=20evaluator=20re-entrant=20=E2=80=94=20it=20failed=20OPEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecurityExpressionEvaluator is a Singleton bean whose recursive-descent parse state ($tokens, $pos, $root) lives on the instance. hasPermission() is the one dispatch path that calls application code — a user-supplied PermissionEvaluator, the extension point the book explicitly recommends — and that code can evaluate an expression of its own on the same singleton. The inner call overwrote the outer parse state. On return, the outer parseExpression() resumed against the INNER token stream, immediately saw eof, and returned the inner result: every term after hasPermission(...) was silently discarded. That fails OPEN, not closed. The added test reproduces it: hasPermission(#id, 'read') and hasRole('ADMIN') returned TRUE for an authenticated principal holding no authorities at all. Fixed by saving and restoring the three fields around evaluate() and parse(). The restore runs in a finally, so it also covers the fail-closed catch path — a throwing inner evaluator can no longer strand torn state for the next caller. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../SecurityExpressionEvaluator.php | 41 ++++++++-- .../Expression/EvaluatorReentrancyTest.php | 74 +++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 packages/security/tests/Access/Expression/EvaluatorReentrancyTest.php diff --git a/packages/security/src/Access/Expression/SecurityExpressionEvaluator.php b/packages/security/src/Access/Expression/SecurityExpressionEvaluator.php index f0df2e8..8fd11bf 100644 --- a/packages/security/src/Access/Expression/SecurityExpressionEvaluator.php +++ b/packages/security/src/Access/Expression/SecurityExpressionEvaluator.php @@ -40,6 +40,21 @@ final class SecurityExpressionEvaluator public function evaluate(string $expression, SecurityExpressionRoot $root): bool { + // RE-ENTRANCY (fail-open fix). This class is a Singleton bean whose parse state ($tokens/$pos/$root) + // lives on the instance, and hasPermission() is a dispatch into APPLICATION code — a user-supplied + // PermissionEvaluator, the extension point the book recommends — which may evaluate an expression of + // its own on this very object. The inner call used to clobber the outer state, so on return the outer + // parseExpression() resumed against the inner token stream, immediately saw eof, and returned the + // INNER result: every term after hasPermission(...) was silently dropped. That fails OPEN — + // "hasPermission(#id,'read') and hasRole('ADMIN')" granted access to a principal with no ROLE_ADMIN. + // + // Saving and restoring around the call makes nested evaluation correct without restructuring the + // recursive-descent parser. finally runs on the fail-closed catch path too, so a throwing inner + // evaluator cannot leave torn state behind for the next caller either. + $outerTokens = $this->tokens; + $outerPos = $this->pos; + $outerRoot = $this->root; + try { $this->tokens = $this->tokenize($expression); $this->pos = 0; @@ -53,17 +68,33 @@ public function evaluate(string $expression, SecurityExpressionRoot $root): bool // deeper in evaluation (e.g. a custom PermissionEvaluator, #param resolution) denies with a clean 403 // rather than surfacing a 500. Security errs to deny, never to allow. return false; + } finally { + $this->tokens = $outerTokens; + $this->pos = $outerPos; + $this->root = $outerRoot; } } /** Validate syntax + whitelist without a root (build-time). Throws on any problem. */ public function parse(string $expression): void { - $this->tokens = $this->tokenize($expression); - $this->pos = 0; - $this->root = null; // parse-only: calls short-circuit to a dummy bool - $this->parseExpression(); - $this->expect('eof'); + // Same save/restore discipline as evaluate(): parse() is reachable from boot-time validation while an + // evaluation is in flight, and must not strand the caller's parse state. + $outerTokens = $this->tokens; + $outerPos = $this->pos; + $outerRoot = $this->root; + + try { + $this->tokens = $this->tokenize($expression); + $this->pos = 0; + $this->root = null; // parse-only: calls short-circuit to a dummy bool + $this->parseExpression(); + $this->expect('eof'); + } finally { + $this->tokens = $outerTokens; + $this->pos = $outerPos; + $this->root = $outerRoot; + } } /** diff --git a/packages/security/tests/Access/Expression/EvaluatorReentrancyTest.php b/packages/security/tests/Access/Expression/EvaluatorReentrancyTest.php new file mode 100644 index 0000000..a9f5678 --- /dev/null +++ b/packages/security/tests/Access/Expression/EvaluatorReentrancyTest.php @@ -0,0 +1,74 @@ +evaluator->evaluate('permitAll()', $root); + + return true; + } +} + +final class AllowAllPermissions implements PermissionEvaluator +{ + public function hasPermission(Authentication $authentication, mixed $target, string $permission): bool + { + return true; + } +} + +it('does not drop the rest of the expression when application code re-enters the evaluator', function () { + $evaluator = new SecurityExpressionEvaluator; + + // A principal that is authenticated but holds NO authorities at all. + $authentication = Authentication::authenticated('alice', 'alice', []); + + $root = new SecurityExpressionRoot( + $authentication, + new RoleHierarchy([]), + new ReentrantPermissionEvaluator($evaluator), + ['id' => 7], + ); + + // hasPermission() returns true, but the principal has no ROLE_ADMIN, so the conjunction must be FALSE. + expect($evaluator->evaluate("hasPermission(#id, 'read') and hasRole('ADMIN')", $root))->toBeFalse(); +}); + +it('still evaluates a conjunction correctly when the principal does hold the role', function () { + $evaluator = new SecurityExpressionEvaluator; + $authentication = Authentication::authenticated('root', 'root', [new SimpleGrantedAuthority('ROLE_ADMIN')]); + + $root = new SecurityExpressionRoot( + $authentication, + new RoleHierarchy([]), + new ReentrantPermissionEvaluator($evaluator), + ['id' => 7], + ); + + expect($evaluator->evaluate("hasPermission(#id, 'read') and hasRole('ADMIN')", $root))->toBeTrue(); +}); From d3c94ccc39839efca8e691225639357270e182a0 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 13:53:11 -0700 Subject: [PATCH 03/31] feat(web): add the #[Controller] stereotype, HTML rendering, and a real welcome page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LaraFly could not serve HTML by construction. Every controller stereotype was #[RestController], and ResponseFactory JSON-encoded whatever a method returned — so a returned Blade view became the body `{}` with HTTP 200 application/json, silently, because a View exposes no public properties for json_encode to find. That is also why there was no welcome page: there was no way to render one. packages/web - #[Controller], the HTML stereotype (Spring's @Controller to #[RestController]'s @RestController). It extends #[RestController] deliberately, so RouteScanner's IS_INSTANCEOF filter finds it with no scanner change and its routes compile into the same RouteManifest. - ResponseFactory now renders View / Renderable / Htmlable as text/html, and resolves a ModelAndView through the application's view factory. Arrays and scalars still negotiate to JSON — the branch is additive. - ModelAndView, for a handler that should not reach for the view() helper. A bare string is deliberately NOT treated as a view name: #[RestController] methods legitimately return strings that must negotiate to JSON, and the meaning of a return value should not depend on its declaring class. The view factory is probed with the CONCRETE 'view' key rather than the contract. Container::bound() answers true for a mere alias, and Laravel aliases Illuminate\Contracts\View\Factory => 'view' in registerCoreContainerAliases() whether or not ViewServiceProvider registered anything — so probing the contract reports a view factory in a bare testbench and then fails with "Target class [view] does not exist" on make(). Five existing tests caught this. skeleton - A welcome page that is actually about this framework: the real 13-phase BootPhase pipeline as an illuminated rail, live bean and condition counts from the same objects /actuator/beans and /actuator/conditions serve, the route table from the RouteManifest the dispatcher reads, and the actuator's registered-vs-exposed endpoints. Nothing on it is hard-coded, and every optional lookup is guarded so this page can never be the reason a fresh application 500s. - It reports whether the app booted compiled or scanned, and tells you to run firefly:cache before deploying — the distinction that the previous commit made safe to have. - System fonts only and a single committed theme: a framework's first page must render identically offline, in a container, and behind a proxy. - A test suite. The skeleton shipped none at all, while its README referenced a tests/ directory and a Pest plugin that did not exist. - Removes app/Support/CachedTransactionalConfiguration.php, the hand-written workaround every application needed because DataAutoConfiguration bound an empty TransactionalManifest. The framework resolves it now. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- packages/web/src/Attributes/Controller.php | 27 + packages/web/src/Dispatch/ResponseFactory.php | 70 ++- packages/web/src/View/ModelAndView.php | 58 ++ packages/web/src/WebServiceProvider.php | 16 +- .../web/tests/Fixtures/View/RecordingView.php | 37 ++ .../Fixtures/View/RecordingViewFactory.php | 58 ++ packages/web/tests/View/HtmlRenderingTest.php | 89 ++++ skeleton/app/Http/GreetingController.php | 10 +- skeleton/app/Http/WelcomeController.php | 198 +++++++ .../CachedTransactionalConfiguration.php | 29 - skeleton/bootstrap/app.php | 2 + skeleton/bootstrap/providers.php | 2 + skeleton/composer.json | 29 +- skeleton/config/app.php | 2 + skeleton/config/cache.php | 2 + skeleton/config/database.php | 2 + skeleton/config/logging.php | 2 + skeleton/config/queue.php | 2 + skeleton/config/session.php | 2 + skeleton/phpunit.xml | 21 + skeleton/public/index.php | 2 + skeleton/resources/views/welcome.blade.php | 494 ++++++++++++++++++ skeleton/routes/console.php | 2 + skeleton/routes/web.php | 2 + skeleton/tests/CreatesApplication.php | 20 + skeleton/tests/Feature/WelcomeTest.php | 39 ++ skeleton/tests/TestCase.php | 12 + 27 files changed, 1182 insertions(+), 47 deletions(-) create mode 100644 packages/web/src/Attributes/Controller.php create mode 100644 packages/web/src/View/ModelAndView.php create mode 100644 packages/web/tests/Fixtures/View/RecordingView.php create mode 100644 packages/web/tests/Fixtures/View/RecordingViewFactory.php create mode 100644 packages/web/tests/View/HtmlRenderingTest.php create mode 100644 skeleton/app/Http/WelcomeController.php delete mode 100644 skeleton/app/Support/CachedTransactionalConfiguration.php create mode 100644 skeleton/phpunit.xml create mode 100644 skeleton/resources/views/welcome.blade.php create mode 100644 skeleton/tests/CreatesApplication.php create mode 100644 skeleton/tests/Feature/WelcomeTest.php create mode 100644 skeleton/tests/TestCase.php diff --git a/packages/web/src/Attributes/Controller.php b/packages/web/src/Attributes/Controller.php new file mode 100644 index 0000000..1c63155 --- /dev/null +++ b/packages/web/src/Attributes/Controller.php @@ -0,0 +1,27 @@ +toResponse($request); } + if ($result instanceof ModelAndView) { + return $this->renderModelAndView($result, $status); + } + + // A View is also Renderable, so it is covered by the Renderable arm; it is named explicitly for + // clarity because it is by far the common case (`return view('welcome', [...])`). + if ($result instanceof View || $result instanceof Renderable) { + return $this->html($result->render(), $status ?? $descriptor->status); + } + + if ($result instanceof Htmlable) { + return $this->html($result->toHtml(), $status ?? $descriptor->status); + } + $accept = (string) $request->header('Accept', 'application/json'); $converter = $this->converters->findWriter($accept); $mediaType = $converter?->mediaTypes()[0] ?? 'application/json'; @@ -45,4 +81,28 @@ public function make(mixed $result, RouteDescriptor $descriptor, Request $reques return new Response($body, $status ?? $descriptor->status, ['Content-Type' => $mediaType]); } + + private function renderModelAndView(ModelAndView $result, ?int $status): SymfonyResponse + { + if ($this->views === null) { + throw new \LogicException( + "Cannot render the view [{$result->view}]: no view factory is bound. Install illuminate/view " + .'(it ships with laravel/framework) or return an already-rendered response.' + ); + } + + return $this->html( + $this->views->make($result->view, $result->model)->render(), + $status ?? $result->status, + $result->headers, + ); + } + + /** + * @param array $headers + */ + private function html(string $body, int $status, array $headers = []): SymfonyResponse + { + return new Response($body, $status, ['Content-Type' => 'text/html; charset=UTF-8', ...$headers]); + } } diff --git a/packages/web/src/View/ModelAndView.php b/packages/web/src/View/ModelAndView.php new file mode 100644 index 0000000..5927bee --- /dev/null +++ b/packages/web/src/View/ModelAndView.php @@ -0,0 +1,58 @@ + $model + * @param array $headers + */ + private function __construct( + public string $view, + public array $model = [], + public int $status = 200, + public array $headers = [], + ) {} + + /** + * @param array $model + */ + public static function of(string $view, array $model = []): self + { + return new self($view, $model); + } + + public function withStatus(int $status): self + { + return new self($this->view, $this->model, $status, $this->headers); + } + + /** + * @param array $model + */ + public function withModel(array $model): self + { + return new self($this->view, [...$this->model, ...$model], $this->status, $this->headers); + } + + public function withHeader(string $name, string $value): self + { + return new self($this->view, $this->model, $this->status, [...$this->headers, $name => $value]); + } +} diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index b258c13..7430eb6 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -28,6 +28,7 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Http\Request; use Throwable; @@ -87,7 +88,20 @@ private function registerBindings(): void } if (! $this->app->bound(ResponseFactory::class)) { - $this->app->singleton(ResponseFactory::class, static fn (Application $app): ResponseFactory => new ResponseFactory($app->make(MessageConverterRegistry::class))); + // The view factory is optional: illuminate/view ships with laravel/framework but is not a + // dependency of firefly/web, so a JSON-only deployment (or a unit test) resolves null and the + // HTML branch fails loud instead of rendering an empty body. + // + // The probe is the CONCRETE 'view' key, not the contract. Container::bound() answers true for a + // mere ALIAS, and Laravel aliases Illuminate\Contracts\View\Factory => 'view' in + // registerCoreContainerAliases() whether or not ViewServiceProvider ever registered anything — + // so probing the contract reports a view factory in a bare testbench and then explodes with + // "Target class [view] does not exist" on make(). + $this->app->singleton(ResponseFactory::class, static function (Application $app): ResponseFactory { + $views = $app->bound('view') ? $app->make(ViewFactory::class) : null; + + return new ResponseFactory($app->make(MessageConverterRegistry::class), $views); + }); } // #[ControllerAdvice] / #[ExceptionHandler] used to be dead in every real boot: RouteScanner:: diff --git a/packages/web/tests/Fixtures/View/RecordingView.php b/packages/web/tests/Fixtures/View/RecordingView.php new file mode 100644 index 0000000..6bb3b1d --- /dev/null +++ b/packages/web/tests/Fixtures/View/RecordingView.php @@ -0,0 +1,37 @@ + $data */ + public function __construct(private readonly string $view, private array $data = []) {} + + public function name(): string + { + return $this->view; + } + + /** @return array */ + public function getData(): array + { + return $this->data; + } + + public function with($key, $value = null): self + { + $this->data[(string) $key] = $value; + + return $this; + } + + public function render(): string + { + return $this->view.':'.implode(',', array_keys($this->data)); + } +} diff --git a/packages/web/tests/Fixtures/View/RecordingViewFactory.php b/packages/web/tests/Fixtures/View/RecordingViewFactory.php new file mode 100644 index 0000000..d2ebb51 --- /dev/null +++ b/packages/web/tests/Fixtures/View/RecordingViewFactory.php @@ -0,0 +1,58 @@ +:", so a test can assert + * ResponseFactory resolved the right view name with the right model without pulling in illuminate/view. + */ +final class RecordingViewFactory implements ViewFactoryContract +{ + public function exists($view): bool + { + return true; + } + + public function file($path, $data = [], $mergeData = []): ViewContract + { + return $this->make($path, $data, $mergeData); + } + + public function make($view, $data = [], $mergeData = []): ViewContract + { + /** @var array $data */ + return new RecordingView((string) $view, $data); + } + + public function share($key, $value = null): mixed + { + return $value; + } + + /** @return array */ + public function composer($views, $callback): array + { + return []; + } + + /** @return array */ + public function creator($views, $callback): array + { + return []; + } + + public function addNamespace($namespace, $hints): self + { + return $this; + } + + public function replaceNamespace($namespace, $hints): self + { + return $this; + } +} diff --git a/packages/web/tests/View/HtmlRenderingTest.php b/packages/web/tests/View/HtmlRenderingTest.php new file mode 100644 index 0000000..ac61f7e --- /dev/null +++ b/packages/web/tests/View/HtmlRenderingTest.php @@ -0,0 +1,89 @@ +Hello'; + } + }; + + $response = htmlResponseFactory()->make($renderable, htmlDescriptor(), Request::create('/page')); + + expect($response->getStatusCode())->toBe(200) + ->and($response->headers->get('Content-Type'))->toBe('text/html; charset=UTF-8') + ->and($response->getContent())->toBe('

Hello

'); +}); + +it('renders an Htmlable as text/html', function () { + $htmlable = new class implements Htmlable + { + public function toHtml(): string + { + return '

markup

'; + } + }; + + $response = htmlResponseFactory()->make($htmlable, htmlDescriptor(), Request::create('/page')); + + expect($response->headers->get('Content-Type'))->toBe('text/html; charset=UTF-8') + ->and($response->getContent())->toBe('

markup

'); +}); + +it('resolves a ModelAndView through the view factory, honouring status and headers', function () { + $mav = ModelAndView::of('welcome', ['name' => 'Ada'])->withStatus(201)->withHeader('X-Page', 'welcome'); + $response = htmlResponseFactory(new RecordingViewFactory)->make($mav, htmlDescriptor(), Request::create('/page')); + + expect($response->getStatusCode())->toBe(201) + ->and($response->getContent())->toBe('welcome:name') + ->and($response->headers->get('X-Page'))->toBe('welcome'); +}); + +it('fails loud rather than rendering nothing when no view factory is bound', function () { + $mav = ModelAndView::of('welcome'); + + expect(static fn () => htmlResponseFactory()->make($mav, htmlDescriptor(), Request::create('/page'))) + ->toThrow(LogicException::class); +}); + +it('still negotiates arrays and scalars to JSON — the HTML branch is additive', function () { + $response = htmlResponseFactory()->make(['ok' => true], htmlDescriptor(), Request::create('/page')); + + expect($response->headers->get('Content-Type'))->toBe('application/json') + ->and($response->getContent())->toBe('{"ok":true}'); +}); + +// RouteScanner discovers controllers with an IS_INSTANCEOF filter on #[RestController], so #[Controller] +// extending it is found by the existing scan with no scanner change. +it('makes #[Controller] discoverable by the existing #[RestController] scan', function () { + expect(is_subclass_of(Controller::class, RestController::class))->toBeTrue(); +}); diff --git a/skeleton/app/Http/GreetingController.php b/skeleton/app/Http/GreetingController.php index 5721df2..641a8f3 100644 --- a/skeleton/app/Http/GreetingController.php +++ b/skeleton/app/Http/GreetingController.php @@ -12,19 +12,15 @@ /** * The sample Firefly slice: a #[RestController] whose routes are discovered by the RouteScanner and served * from the compiled RouteManifest. GreetingService is autowired via constructor DI. + * + * `/` belongs to App\Http\WelcomeController, a #[Controller] that renders HTML — this one returns a value + * the ResponseFactory negotiates into JSON, which is the difference between the two stereotypes. */ #[RestController] final class GreetingController { public function __construct(private readonly GreetingService $greetings) {} - /** @return array */ - #[GetMapping('/')] - public function index(): array - { - return ['message' => $this->greetings->greet('World')]; - } - /** @return array */ #[GetMapping('/greetings/{name}', name: 'greetings.show')] public function show(#[PathVariable] string $name): array diff --git a/skeleton/app/Http/WelcomeController.php b/skeleton/app/Http/WelcomeController.php new file mode 100644 index 0000000..1a1a1d5 --- /dev/null +++ b/skeleton/app/Http/WelcomeController.php @@ -0,0 +1,198 @@ +config->string('firefly.management.endpoints.web.base-path', '/actuator'), '/'); + + return view('welcome', [ + 'appName' => $this->config->string('app.name', 'LaraFly'), + 'environment' => $this->config->string('app.env', 'local'), + 'debug' => $this->config->bool('app.debug', false), + 'phpVersion' => PHP_VERSION, + 'laravelVersion' => $this->packageVersion('laravel/framework'), + 'fireflyVersion' => $this->packageVersion('firefly/firefly'), + 'bootMode' => AppScan::cachedFile($this->container, AppScan::ROUTES) !== null ? 'compiled' : 'scanned', + 'phases' => $this->phases(), + 'beanCount' => $this->beanCount(), + 'conditions' => $this->conditions(), + 'actuatorBase' => '/'.$base, + 'exposed' => $this->exposed(), + 'endpoints' => $this->registeredEndpoints(), + 'routes' => $this->appRoutes($base), + ]); + } + + /** + * The real boot pipeline. The ordinals are gapped on purpose so a later milestone can slot a phase + * between two existing ones without renumbering — which is why they read 100, 200, … 650, 700. + * + * Each phase carries a short label for the rail and its exact enum case name for the tooltip: the + * unabbreviated names ("AutoConfigurations") do not fit a rail cell without breaking mid-word, and a + * broken word is harder to read than a shorter one. + * + * @return list + */ + private function phases(): array + { + $labels = [ + BootPhase::ConfigAndProfiles->name => 'Config & profiles', + BootPhase::AutoConfigDiscovery->name => 'Discovery', + BootPhase::UserConfigurations->name => 'Your beans', + BootPhase::ConditionPassOne->name => 'Conditions I', + BootPhase::AutoConfigurations->name => 'Auto-config', + BootPhase::ConditionPassTwo->name => 'Conditions II', + BootPhase::FlushDefinitions->name => 'Flush', + BootPhase::BeanPostProcessors->name => 'Extenders', + BootPhase::EventListeners->name => 'Listeners', + BootPhase::InfrastructureStart->name => 'Infra start', + BootPhase::EagerSingletons->name => 'Eager beans', + BootPhase::WiringPasses->name => 'Wiring', + BootPhase::ContextRefreshed->name => 'Refreshed', + ]; + + return array_map( + static fn (BootPhase $phase): array => [ + 'ordinal' => $phase->value, + 'label' => $labels[$phase->name] ?? $phase->name, + 'name' => $phase->name, + ], + BootPhase::cases(), + ); + } + + /** @return array{matches: int, backedOff: int} */ + private function conditions(): array + { + $report = $this->optional(ConditionEvaluationReport::class); + + return $report instanceof ConditionEvaluationReport + ? ['matches' => count($report->matches()), 'backedOff' => count($report->nonMatches())] + : ['matches' => 0, 'backedOff' => 0]; + } + + private function beanCount(): int + { + $catalog = $this->optional(BeansCatalog::class); + + return $catalog instanceof BeansCatalog ? count($catalog->all()) : 0; + } + + /** @return list */ + private function registeredEndpoints(): array + { + $registry = $this->optional(ActuatorRegistry::class); + + return $registry instanceof ActuatorRegistry ? array_keys($registry->all()) : []; + } + + /** @return list */ + private function exposed(): array + { + return array_values(array_filter( + array_map(trim(...), explode(',', $this->config->string( + 'firefly.management.endpoints.web.exposure.include', + 'health,info', + ))), + static fn (string $id): bool => $id !== '', + )); + } + + /** + * The application's own routes — the actuator's are excluded because they are the framework's, and + * they are linked separately. + * + * @return list + */ + private function appRoutes(string $actuatorBase): array + { + $rows = array_map( + static fn ($route): array => [ + 'method' => $route->httpMethod, + 'path' => $route->path, + 'controller' => $route->controllerClass, + 'action' => $route->methodName, + ], + $this->routes->all(), + ); + + $rows = array_values(array_filter( + $rows, + static fn (array $r): bool => $actuatorBase === '' || ! str_starts_with(ltrim($r['path'], '/'), $actuatorBase), + )); + + usort($rows, static fn (array $a, array $b): int => $a['path'] <=> $b['path']); + + return $rows; + } + + /** + * A container lookup that never throws. This page is the first thing a new application serves; a missing + * optional binding must degrade to a quieter page, not a 500. + * + * @param class-string $class + */ + private function optional(string $class): ?object + { + try { + return $this->container->bound($class) ? $this->container->get($class) : null; + } catch (Throwable) { + return null; + } + } + + private function packageVersion(string $package): string + { + try { + if (class_exists(InstalledVersions::class) && InstalledVersions::isInstalled($package)) { + return InstalledVersions::getPrettyVersion($package) ?? 'dev'; + } + } catch (Throwable) { + // Fall through — a version string is decoration, never a reason to fail the page. + } + + return 'dev'; + } +} diff --git a/skeleton/app/Support/CachedTransactionalConfiguration.php b/skeleton/app/Support/CachedTransactionalConfiguration.php deleted file mode 100644 index 23bc198..0000000 --- a/skeleton/app/Support/CachedTransactionalConfiguration.php +++ /dev/null @@ -1,29 +0,0 @@ - + + + + tests/Feature + + + + + app + + + + + + + + diff --git a/skeleton/public/index.php b/skeleton/public/index.php index ee8f07e..6aa752c 100644 --- a/skeleton/public/index.php +++ b/skeleton/public/index.php @@ -1,5 +1,7 @@ + + + + + + {{ $appName }} + {{-- Inline so a brand-new application does not 404 on /favicon.ico before you have added your own. --}} + + + + + +
+
+
+ LaraFly + + {{ $appName }} + env {{ $environment }} + php {{ $phpVersion }} + laravel {{ $laravelVersion }} + +
+ +
+

Your application is running.

+

+ You are looking at HTML rendered by a #[Controller]. Every number below was read + from the application that is serving this page — nothing here is written down. +

+
+ +
+
Beans
{{ $beanCount }}
+
Conditions met
{{ $conditions['matches'] }}
+
Backed off
{{ $conditions['backedOff'] }}
+
Routes
{{ count($routes) }}
+
Endpoints
{{ count($exposed) }} / {{ count($endpoints) }}
+
+
+ +
+
+
+

Boot pipeline · {{ count($phases) }} phases

+

Every phase ran in this order to produce the page you are reading. The ordinals are gapped + so a new phase can slot between two existing ones without renumbering.

+
+
+
+ @foreach ($phases as $phase) +
+ {{ $phase['ordinal'] }} + {{ $phase['label'] }} +
+ @endforeach +
+
+
+
+
+ +
+
+
+ @if ($bootMode === 'compiled') + Compiled boot + Manifests came from bootstrap/cache/firefly. No reflection ran at startup — this is what production should look like. + @else + Scanned boot + Your classes were scanned by reflection at startup, which is what you want while developing. Run php artisan firefly:cache before you deploy. + @endif +
+
+ +
+

What is wired

+

+ Auto-configuration wires a capability only until you supply your own bean, then steps aside — + {{ $conditions['backedOff'] }} of them did exactly that on this boot. +

+ +
+
+

Your routes

+ @if ($routes === []) +

No routes yet. Create one with php artisan make:firefly-controller.

+ @else + + + @foreach ($routes as $route) + + + + + + @endforeach + +
{{ $route['method'] }} + @if (str_contains($route['path'], '{')) + {{ $route['path'] }} + @else + {{ $route['path'] }} + @endif + {{ class_basename($route['controller']) }}::{{ $route['action'] }}
+ @endif +
+ +
+

Actuator

+
+ @foreach ($endpoints as $id) + {{ $id }} + @endforeach +
+

+ Lit endpoints answer at {{ $actuatorBase }}. The rest are + registered but withheld — widen exposure.include to publish them. +

+
+
+
+ +
+

Where to go next

+

+ The generators scaffold the stereotype and its attributes. The introspection commands answer the + same questions the actuator does, without starting a server. +

+
+ @foreach ([ + ['php artisan ', 'make:firefly-controller OrderController', 'A REST controller'], + ['php artisan ', 'make:firefly-service OrderService', 'An injectable service bean'], + ['php artisan ', 'make:firefly-handler PlaceOrder', 'A CQRS command handler'], + ['php artisan ', 'firefly:about', 'Beans, conditions and mappings'], + ['php artisan ', 'firefly:cache', 'Compile for a zero-reflection boot'], + ] as [$prefix, $command, $why]) +
+ {{ $prefix }}{{ $command }} + {{ $why }} + +
+ @endforeach +
+
+ +
+

Learn the framework

+ +
+
+ +
+
+
+

This page is here because nothing else claims /. + Delete app/Http/WelcomeController.php and + resources/views/welcome.blade.php and it is gone — nothing else refers to either.

+

LaraFly is the PHP member of the Firefly Framework family: Spring Boot's cohesion, native to + Laravel. Released under the Apache-2.0 licence.

+
+
+
LaraFly {{ $fireflyVersion }}
+
Laravel {{ $laravelVersion }} · PHP {{ $phpVersion }}
+
{{ $environment }}{{ $debug ? ' · debug' : '' }} · {{ $bootMode }}
+
+
+
+ + + + diff --git a/skeleton/routes/console.php b/skeleton/routes/console.php index 3c9adf1..c428e20 100644 --- a/skeleton/routes/console.php +++ b/skeleton/routes/console.php @@ -1,5 +1,7 @@ make(Kernel::class)->bootstrap(); + + return $app; + } +} diff --git a/skeleton/tests/Feature/WelcomeTest.php b/skeleton/tests/Feature/WelcomeTest.php new file mode 100644 index 0000000..d2d2968 --- /dev/null +++ b/skeleton/tests/Feature/WelcomeTest.php @@ -0,0 +1,39 @@ +get('/'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + $response->assertSee('Your application', false); + } + + public function test_the_sample_rest_controller_returns_json(): void + { + $this->getJson('/greetings/Ada') + ->assertOk() + ->assertExactJson(['message' => 'Hello, Ada!']); + } + + public function test_the_actuator_reports_health(): void + { + $this->getJson('/actuator/health') + ->assertOk() + ->assertJsonPath('status', 'UP'); + } +} diff --git a/skeleton/tests/TestCase.php b/skeleton/tests/TestCase.php new file mode 100644 index 0000000..5341116 --- /dev/null +++ b/skeleton/tests/TestCase.php @@ -0,0 +1,12 @@ + Date: Thu, 3 Sep 2026 13:58:37 -0700 Subject: [PATCH 04/31] =?UTF-8?q?design(skeleton):=20rebuild=20the=20welco?= =?UTF-8?q?me=20page=20=E2=80=94=20warm,=20simple,=20paths=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version was a dense introspection dashboard: a 13-phase boot rail, a five-figure stat grid and a dark petrol palette. It showed off the framework but it did not help someone who had just run `firefly new` and wanted to know what to open next. Rebuilt around the question a first-run page actually answers — what does this app serve, and what do I do now: - Warm and light. Amber and gold on warm paper, the firefly's own colour, with a dark-preference palette that restates the same tokens rather than inventing new ones. - Centred and quiet. One glowing mark, a status pill, a greeting, one line of explanation. - Paths first. Every route the application actually mapped, as large clickable rows with the verb, the path and the controller behind it — plus the actuator. A route with a {placeholder} is shown but not linked, because it is not a URL you can open. - Three next steps and three links, each doing exactly one job. - The firefly:cache reminder appears only on a scanned boot, where it is actionable, instead of explaining both states every time. The lamp animates once on load and then holds. A perpetual pulse is a distraction on a page people leave open, and it also prevented the page from ever settling for a screenshot. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- skeleton/resources/views/welcome.blade.php | 601 ++++++++------------- skeleton/tests/Feature/WelcomeTest.php | 3 +- 2 files changed, 217 insertions(+), 387 deletions(-) diff --git a/skeleton/resources/views/welcome.blade.php b/skeleton/resources/views/welcome.blade.php index 443e5e5..9e85cdc 100644 --- a/skeleton/resources/views/welcome.blade.php +++ b/skeleton/resources/views/welcome.blade.php @@ -3,462 +3,291 @@ - + {{ $appName }} {{-- Inline so a brand-new application does not 404 on /favicon.ico before you have added your own. --}} - + - -
-
-
- LaraFly - - {{ $appName }} - env {{ $environment }} - php {{ $phpVersion }} - laravel {{ $laravelVersion }} - -
- -
-

Your application is running.

-

- You are looking at HTML rendered by a #[Controller]. Every number below was read - from the application that is serving this page — nothing here is written down. -

-
- -
-
Beans
{{ $beanCount }}
-
Conditions met
{{ $conditions['matches'] }}
-
Backed off
{{ $conditions['backedOff'] }}
-
Routes
{{ count($routes) }}
-
Endpoints
{{ count($exposed) }} / {{ count($endpoints) }}
-
-
- -
-
-
-

Boot pipeline · {{ count($phases) }} phases

-

Every phase ran in this order to produce the page you are reading. The ordinals are gapped - so a new phase can slot between two existing ones without renumbering.

-
-
-
- @foreach ($phases as $phase) -
- {{ $phase['ordinal'] }} - {{ $phase['label'] }} -
- @endforeach -
-
-
-
-
- -
-
-
- @if ($bootMode === 'compiled') - Compiled boot - Manifests came from bootstrap/cache/firefly. No reflection ran at startup — this is what production should look like. - @else - Scanned boot - Your classes were scanned by reflection at startup, which is what you want while developing. Run php artisan firefly:cache before you deploy. - @endif -
-
- -
-

What is wired

-

- Auto-configuration wires a capability only until you supply your own bean, then steps aside — - {{ $conditions['backedOff'] }} of them did exactly that on this boot. -

- -
-
-

Your routes

- @if ($routes === []) -

No routes yet. Create one with php artisan make:firefly-controller.

+
+ +
+ + + Running +

Hello, {{ $appName }}

+

Your application is up. Here is what it serves, and where to go next.

+
+ +
+

Your paths

+
+ @forelse ($routes as $route) + @if (str_contains($route['path'], '{')) +
+ {{ $route['method'] }} + {{ $route['path'] }} + {{ class_basename($route['controller']) }} + +
@else - - - @foreach ($routes as $route) - - - - - - @endforeach - -
{{ $route['method'] }} - @if (str_contains($route['path'], '{')) - {{ $route['path'] }} - @else - {{ $route['path'] }} - @endif - {{ class_basename($route['controller']) }}::{{ $route['action'] }}
+ + {{ $route['method'] }} + {{ $route['path'] }} + {{ class_basename($route['controller']) }} + + @endif -
- -
-

Actuator

-
- @foreach ($endpoints as $id) - {{ $id }} - @endforeach -
-

- Lit endpoints answer at {{ $actuatorBase }}. The rest are - registered but withheld — widen exposure.include to publish them. -

-
+ @empty +

No routes yet. Run php artisan make:firefly-controller to add one.

+ @endforelse + + + GET + {{ $actuatorBase }} + Health and info + +
-
-

Where to go next

-

- The generators scaffold the stereotype and its attributes. The introspection commands answer the - same questions the actuator does, without starting a server. -

-
+
+

Next steps

+
@foreach ([ - ['php artisan ', 'make:firefly-controller OrderController', 'A REST controller'], - ['php artisan ', 'make:firefly-service OrderService', 'An injectable service bean'], - ['php artisan ', 'make:firefly-handler PlaceOrder', 'A CQRS command handler'], - ['php artisan ', 'firefly:about', 'Beans, conditions and mappings'], - ['php artisan ', 'firefly:cache', 'Compile for a zero-reflection boot'], - ] as [$prefix, $command, $why]) -
- {{ $prefix }}{{ $command }} - {{ $why }} - + ['make:firefly-controller OrderController', 'Add a route'], + ['make:firefly-service OrderService', 'Add a service'], + ['firefly:about', 'See what is wired'], + ] as [$command, $note]) +
+ php artisan {{ $command }} + {{ $note }} +
@endforeach
-
-

Learn the framework

-
- -
-
-
-

This page is here because nothing else claims /. - Delete app/Http/WelcomeController.php and - resources/views/welcome.blade.php and it is gone — nothing else refers to either.

-

LaraFly is the PHP member of the Firefly Framework family: Spring Boot's cohesion, native to - Laravel. Released under the Apache-2.0 licence.

-
-
-
LaraFly {{ $fireflyVersion }}
-
Laravel {{ $laravelVersion }} · PHP {{ $phpVersion }}
-
{{ $environment }}{{ $debug ? ' · debug' : '' }} · {{ $bootMode }}
-
-
-
+ + @if ($bootMode !== 'compiled') +

+ Before you deploy, run php artisan firefly:cache. Right now your classes are + scanned at startup, which is what you want while developing. +

+ @endif + +
+

You are seeing this because nothing else claims /. Delete + app/Http/WelcomeController.php and resources/views/welcome.blade.php + to remove it.

+ LaraFly {{ $fireflyVersion }} · PHP {{ $phpVersion }} · {{ $environment }} +
+ + + @endpush +@endonce diff --git a/packages/admin/resources/views/beans.blade.php b/packages/admin/resources/views/beans.blade.php new file mode 100644 index 0000000..0e8e2d5 --- /dev/null +++ b/packages/admin/resources/views/beans.blade.php @@ -0,0 +1,35 @@ +@extends('firefly-admin::layout') +@section('title', 'Beans') +@section('body') +
+

Beans

+

Every bean the container registered, with the stereotype that declared it and the scope it lives in.

+
+ +
+

Container {{ count($beans) }} beans

+ @include('firefly-admin::_filter', ['target' => 'beans-body', 'placeholder' => 'Filter by class, stereotype or interface…']) + @if ($beans === []) +

No beans registered.

+ @else +
+ + + + @foreach ($beans as $bean) + + + + + + + + @endforeach + +
ClassStereotypeScopeNameImplements
{{ $bean['class'] ?? '' }}{{ $bean['stereotype'] ?? '' }}{{ $bean['scope'] ?? '' }}{{ $bean['name'] ?: '—' }} + {{ is_array($bean['interfaces'] ?? null) && $bean['interfaces'] !== [] ? implode(', ', $bean['interfaces']) : '—' }} +
+
+ @endif +
+@endsection diff --git a/packages/admin/resources/views/conditions.blade.php b/packages/admin/resources/views/conditions.blade.php new file mode 100644 index 0000000..a6beb25 --- /dev/null +++ b/packages/admin/resources/views/conditions.blade.php @@ -0,0 +1,62 @@ +@extends('firefly-admin::layout') +@section('title', 'Conditions') +@section('body') + @php + $positive = is_array($positiveMatches ?? null) ? $positiveMatches : []; + $negative = is_array($negativeMatches ?? null) ? $negativeMatches : []; + @endphp + +
+

Conditions

+

Conditional auto-configuration wires a capability only until you supply your own bean, then steps + aside. Everything under Backed off is a decision the framework made in your favour.

+
+ +
+

Applied {{ count($positive) }}

+ @include('firefly-admin::_filter', ['target' => 'pos-body', 'placeholder' => 'Filter applied…']) + @if ($positive === []) +

Nothing matched.

+ @else +
+ + + + @foreach ($positive as $row) + + + + + @endforeach + +
ClassCondition
{{ $row['class'] ?? '' }} + #[{{ class_basename($row['condition'] ?? '') }}] +
+
+ @endif +
+ +
+

Backed off {{ count($negative) }}

+ @include('firefly-admin::_filter', ['target' => 'neg-body', 'placeholder' => 'Filter backed off…']) + @if ($negative === []) +

Nothing backed off — no auto-configuration found a reason to stand down.

+ @else +
+ + + + @foreach ($negative as $row) + + + + + @endforeach + +
ClassCondition
{{ $row['class'] ?? '' }} + #[{{ class_basename($row['condition'] ?? '') }}] +
+
+ @endif +
+@endsection diff --git a/packages/admin/resources/views/env.blade.php b/packages/admin/resources/views/env.blade.php new file mode 100644 index 0000000..10f415b --- /dev/null +++ b/packages/admin/resources/views/env.blade.php @@ -0,0 +1,31 @@ +@extends('firefly-admin::layout') +@section('title', 'Environment') +@section('body') +
+

Environment

+

Resolved firefly.* configuration as this process sees it. Values whose key looks + secret are masked by the endpoint before they reach this page.

+
+ +
+

Configuration {{ count($env) }} keys

+ @include('firefly-admin::_filter', ['target' => 'env-body', 'placeholder' => 'Filter by key or value…']) + @if ($env === []) +

Nothing set under firefly.

+ @else +
+ + + + @foreach ($env as $key => $value) + + + + + @endforeach + +
KeyValue
{{ $key }}{{ $value }}
+
+ @endif +
+@endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php new file mode 100644 index 0000000..d92de28 --- /dev/null +++ b/packages/admin/resources/views/layout.blade.php @@ -0,0 +1,144 @@ +{{-- + The dashboard shell. Inline CSS on purpose: a composer package cannot assume npm has run, and a + dashboard that needs a CDN at request time is useless in exactly the network-isolated environments where + you most want to look at one. System fonts for the same reason. +--}} + + + + + + + + @yield('title', 'Admin') · {{ $settings->title }} + + + + +
+ +
+ @yield('body') +
+
+@stack('scripts') + + diff --git a/packages/admin/resources/views/loggers.blade.php b/packages/admin/resources/views/loggers.blade.php new file mode 100644 index 0000000..40dc2c7 --- /dev/null +++ b/packages/admin/resources/views/loggers.blade.php @@ -0,0 +1,56 @@ +@extends('firefly-admin::layout') +@section('title', 'Loggers') +@section('body') + @php + $levelNames = is_array($levels ?? null) ? $levels : []; + $channels = is_array($loggers ?? null) ? $loggers : []; + @endphp + +
+

Loggers

+

Log channels and the level each is configured with.

+
+ +
+

Channels {{ count($channels) }}

+ @if ($channels === []) +

No channels configured under logging.channels.

+ @else +
+ + + + @foreach ($channels as $name => $logger) + @php $level = is_array($logger) && is_string($logger['configuredLevel'] ?? null) ? $logger['configuredLevel'] : 'INFO'; @endphp + + + + + + @endforeach + +
ChannelConfigured levelSet
{{ $name }}{{ $level }} +
+ @csrf + + + +
+
+
+ @endif +
+ + {{-- + Honesty about what this control does. LoggersEndpoint::setLevel() reaches into the Monolog handlers of + the CURRENT process, so under PHP-FPM the change lasts exactly as long as this request. Saying so is + better than letting someone believe they have changed production logging. + --}} +

Applying a level calls the same endpoint POST /actuator/loggers/{name} does, + which mutates this PHP process only. Under PHP-FPM the next request is a different process and reverts to + the configured level — change logging.channels for anything that must persist.

+@endsection diff --git a/packages/admin/resources/views/mappings.blade.php b/packages/admin/resources/views/mappings.blade.php new file mode 100644 index 0000000..ecf3c45 --- /dev/null +++ b/packages/admin/resources/views/mappings.blade.php @@ -0,0 +1,33 @@ +@extends('firefly-admin::layout') +@section('title', 'Mappings') +@section('body') +
+

Mappings

+

The compiled route table the dispatcher serves from — discovered from your + #[RestController] and #[Controller] classes.

+
+ +
+

Routes {{ count($mappings) }}

+ @include('firefly-admin::_filter', ['target' => 'map-body', 'placeholder' => 'Filter by path or handler…']) + @if ($mappings === []) +

No routes mapped.

+ @else +
+ + + + @foreach ($mappings as $route) + + + + + + + @endforeach + +
MethodPathHandlerName
{{ $route['httpMethod'] ?? '' }}{{ $route['path'] ?? '' }}{{ $route['handler'] ?? '' }}{{ $route['name'] ?: '—' }}
+
+ @endif +
+@endsection diff --git a/packages/admin/resources/views/metrics.blade.php b/packages/admin/resources/views/metrics.blade.php new file mode 100644 index 0000000..079093d --- /dev/null +++ b/packages/admin/resources/views/metrics.blade.php @@ -0,0 +1,41 @@ +@extends('firefly-admin::layout') +@section('title', 'Metrics') +@section('body') +
+

Metrics

+

Counters, timers and gauges recorded through the meter registry.

+
+ +
+

Meters {{ count($metrics) }}

+ @if ($metrics === []) +

Nothing recorded yet. Note that the default registry keeps meters in process + memory, so under PHP-FPM a scrape only ever sees its own request — set + firefly.observability.metrics.store to a cache store to accumulate across workers.

+ @else + @include('firefly-admin::_filter', ['target' => 'metrics-body', 'placeholder' => 'Filter by meter name…']) +
+ + + + @foreach ($metrics as $metric) + @php $measurements = is_array($metric['measurements']) ? $metric['measurements'] : []; @endphp + @forelse ($measurements as $measurement) + + + + + + @empty + + + + + @endforelse + @endforeach + +
MeterStatisticValue
{{ $loop->first ? $metric['name'] : '' }}{{ $measurement['statistic'] ?? '' }}{{ is_numeric($measurement['value'] ?? null) ? rtrim(rtrim(number_format((float) $measurement['value'], 4, '.', ''), '0'), '.') : '—' }}
{{ $metric['name'] }}no measurements
+
+ @endif +
+@endsection diff --git a/packages/admin/resources/views/missing.blade.php b/packages/admin/resources/views/missing.blade.php new file mode 100644 index 0000000..24674ac --- /dev/null +++ b/packages/admin/resources/views/missing.blade.php @@ -0,0 +1,8 @@ +@extends('firefly-admin::layout') +@section('title', 'Not found') +@section('body') +
+

No such page

+

The dashboard has no page called {{ $slug }}. Pick one from the menu.

+
+@endsection diff --git a/packages/admin/resources/views/overview.blade.php b/packages/admin/resources/views/overview.blade.php new file mode 100644 index 0000000..c09d613 --- /dev/null +++ b/packages/admin/resources/views/overview.blade.php @@ -0,0 +1,98 @@ +@extends('firefly-admin::layout') +@section('title', 'Overview') +@section('body') + @php + $status = is_string($health['status'] ?? null) ? $health['status'] : 'UNKNOWN'; + $components = is_array($health['components'] ?? null) ? $health['components'] : []; + $positive = is_array($conditions['positiveMatches'] ?? null) ? $conditions['positiveMatches'] : []; + $negative = is_array($conditions['negativeMatches'] ?? null) ? $conditions['negativeMatches'] : []; + @endphp + +
+

Overview

+

What this process wired at boot, and how it is doing now.

+
+ +
+
+
Health
+
{{ $status }}
+
+
Beans
{{ count($beans) }}
+
Routes
{{ count($mappings) }}
+
Auto-config met
{{ count($positive) }}
+
Backed off
{{ count($negative) }}
+
+
Boot
+
{{ $bootMode }}
+
+
+ + @if ($bootMode !== 'compiled') +

This process scanned its classes by reflection at startup. That is right while + developing; run php artisan firefly:cache before deploying.

+ @endif + + @if ($components === []) +
+

Health indicators

+

The health endpoint is reporting its aggregate status only. Set + firefly.management.endpoint.health.show-details to always to see each + indicator and its details here.

+
+ @else +
+

Health indicators {{ count($components) }}

+
+ + + + @foreach ($components as $name => $component) + @php $s = is_array($component) && is_string($component['status'] ?? null) ? $component['status'] : 'UNKNOWN'; @endphp + + + + + + @endforeach + +
IndicatorStatusDetails
{{ $name }}{{ $s }} + @php $d = is_array($component) && is_array($component['details'] ?? null) ? $component['details'] : []; @endphp + {{ $d === [] ? '—' : json_encode($d, JSON_UNESCAPED_SLASHES) }} +
+
+
+ @endif + +
+

Build information /actuator/info

+ @if ($info === []) +

No InfoContributor has published anything. Set + firefly.management.info.app, or register your own contributor.

+ @else +
+ + + @foreach ($info as $key => $value) + + + + + @endforeach + +
{{ $key }}{{ is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_SLASHES) }}
+
+ @endif +
+ +
+

Registered endpoints {{ count($endpoints) }}

+
+ @foreach ($endpoints as $id) + {{ $id }} + @endforeach +
+

These are readable here in-process. Which of them answer over + HTTP is a separate decision — see firefly.management.endpoints.web.exposure.include.

+
+@endsection diff --git a/packages/admin/resources/views/scheduled.blade.php b/packages/admin/resources/views/scheduled.blade.php new file mode 100644 index 0000000..d2c31e1 --- /dev/null +++ b/packages/admin/resources/views/scheduled.blade.php @@ -0,0 +1,33 @@ +@extends('firefly-admin::layout') +@section('title', 'Scheduled') +@section('body') +
+

Scheduled tasks

+

Methods registered by #[Scheduled], with the cron expression or fixed interval that + drives them.

+
+ +
+

Tasks {{ count($tasks) }}

+ @if ($tasks === []) +

Nothing scheduled. Add #[Scheduled] to a bean method.

+ @else +
+ + + + @foreach ($tasks as $task) + + + + + + + + @endforeach + +
RunnableCronFixed rateFixed delayZone
{{ $task['runnable'] ?? '' }}{{ $task['cron'] ?: '—' }}{{ $task['fixedRate'] ?: '—' }}{{ $task['fixedDelay'] ?: '—' }}{{ $task['zone'] ?: '—' }}
+
+ @endif +
+@endsection diff --git a/packages/admin/resources/views/unavailable.blade.php b/packages/admin/resources/views/unavailable.blade.php new file mode 100644 index 0000000..83f21f2 --- /dev/null +++ b/packages/admin/resources/views/unavailable.blade.php @@ -0,0 +1,12 @@ +@extends('firefly-admin::layout') +@section('title', 'Unavailable') +@section('body') +
+

{{ $page->label }} is not available

+

{{ $page->blurb }}

+
+

This page reads the {{ $page->requires }} actuator endpoint, which this + process has not registered or has switched off. Check + firefly.management.endpoint.{{ $page->requires }}.enabled, and that the package providing it + is installed.

+@endsection diff --git a/packages/admin/src/AdminEndpointReader.php b/packages/admin/src/AdminEndpointReader.php new file mode 100644 index 0000000..8814e55 --- /dev/null +++ b/packages/admin/src/AdminEndpointReader.php @@ -0,0 +1,102 @@ + + */ + public function available(): array + { + $ids = []; + foreach ($this->registry->all() as $id => $endpoint) { + if ($endpoint->enabled() && $this->config->bool("firefly.management.endpoint.{$id}.enabled", true)) { + $ids[] = $id; + } + } + + return $ids; + } + + public function has(string $id): bool + { + return in_array($id, $this->available(), true); + } + + /** + * The endpoint's payload as an array, or null when it is absent, switched off, returned no body, or + * threw. + * + * A throwing endpoint must not take the page down with it: one broken health indicator should degrade + * that panel, not the dashboard. The failure is surfaced to the caller as null so the view can say so. + * + * @param list $subPath + * @param array $query + * @return array|null + */ + public function read(string $id, array $subPath = [], array $query = []): ?array + { + $endpoint = $this->registry->get($id); + if ($endpoint === null || ! $this->has($id)) { + return null; + } + + try { + $response = $endpoint->handle(new EndpointRequest('GET', $subPath, $query)); + } catch (Throwable) { + return null; + } + + return $response === null || is_string($response->body) ? null : $response->body; + } + + /** + * POST to an endpoint — the loggers endpoint's level mutation is the only current caller. + * + * @param list $subPath + * @param array $body + */ + public function write(string $id, array $subPath, array $body): bool + { + $endpoint = $this->registry->get($id); + if ($endpoint === null || ! $this->has($id)) { + return false; + } + + try { + $response = $endpoint->handle(new EndpointRequest('POST', $subPath, [], $body)); + } catch (Throwable) { + return false; + } + + return $response !== null && $response->status >= 200 && $response->status < 300; + } +} diff --git a/packages/admin/src/AdminServiceProvider.php b/packages/admin/src/AdminServiceProvider.php new file mode 100644 index 0000000..f16409c --- /dev/null +++ b/packages/admin/src/AdminServiceProvider.php @@ -0,0 +1,36 @@ +loadViewsFrom(__DIR__.'/../resources/views', 'firefly-admin'); + + parent::register(); + } + + /** + * @return list + */ + public function passes(): array + { + return [new AdminRouteRegistrar]; + } +} diff --git a/packages/admin/src/AdminSettings.php b/packages/admin/src/AdminSettings.php new file mode 100644 index 0000000..5eb1004 --- /dev/null +++ b/packages/admin/src/AdminSettings.php @@ -0,0 +1,47 @@ +string('firefly.admin.base-path', '/firefly'), '/'); + + return new self( + enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)), + basePath: $base === '' ? 'firefly' : $base, + title: $config->string('firefly.admin.title', $config->string('app.name', 'LaraFly')), + ); + } + + /** An absolute path for a dashboard page, e.g. url('beans') => /firefly/beans. */ + public function url(string $page = ''): string + { + return '/'.$this->basePath.($page === '' ? '' : '/'.$page); + } +} diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php new file mode 100644 index 0000000..5fe328e --- /dev/null +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -0,0 +1,80 @@ +config); + if (! $settings->enabled) { + return; + } + + $container = $context->container; + + // Blade is required to render the dashboard and is NOT a dependency of this package — a JSON-only + // deployment has no view factory. Mounting routes that would fatal on first request is worse than + // mounting none, so back off silently and leave the JSON actuator as the management surface. + if (! $container->bound('view')) { + return; + } + + $container->instance(AdminSettings::class, $settings); + $container->singleton(AdminEndpointReader::class, static fn (): AdminEndpointReader => new AdminEndpointReader( + $container->make(ActuatorRegistry::class), + $context->config, + )); + $container->singleton(AdminAction::class, static fn (): AdminAction => new AdminAction( + $container->make(AdminSettings::class), + $container->make(AdminEndpointReader::class), + $container->make(ViewFactory::class), + $container, + )); + + /** @var Router $router */ + $router = $container->make('router'); + $base = $settings->basePath; + + $router->get($base, static fn (Request $request) => $container->make(AdminAction::class)($request)) + ->name('firefly.admin.index'); + $router->match(['GET', 'POST'], $base.'/{page}', static fn (Request $request, string $page) => $container->make(AdminAction::class)($request, $page)) + ->where('page', '[A-Za-z0-9\-_/]*') + ->name('firefly.admin.page'); + } +} diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php new file mode 100644 index 0000000..5600294 --- /dev/null +++ b/packages/admin/src/Web/AdminAction.php @@ -0,0 +1,214 @@ +slug === $slug) { + $current = $candidate; + } + } + + if ($current === null) { + return new Response($this->render('missing', ['slug' => $slug]), 404, ['Content-Type' => 'text/html; charset=UTF-8']); + } + + if ($current->requires !== null && ! $this->reader->has($current->requires)) { + return new Response($this->render('unavailable', ['page' => $current]), 404, ['Content-Type' => 'text/html; charset=UTF-8']); + } + + if ($slug === 'loggers' && $request->isMethod('POST')) { + return $this->setLoggerLevel($request); + } + + return new Response( + $this->render($slug === '' ? 'overview' : $slug, $this->data($slug)), + 200, + ['Content-Type' => 'text/html; charset=UTF-8'], + ); + } + + /** @return array */ + private function data(string $slug): array + { + /** @var array $data */ + $data = match ($slug) { + '' => [ + 'health' => $this->payload('health'), + 'info' => $this->payload('info'), + 'beans' => $this->listOf('beans', 'beans'), + 'conditions' => $this->payload('conditions'), + 'mappings' => $this->listOf('mappings', 'mappings'), + 'bootMode' => AppScan::cachedFile($this->container, AppScan::ROUTES) !== null ? 'compiled' : 'scanned', + 'endpoints' => $this->reader->available(), + ], + 'beans' => ['beans' => $this->listOf('beans', 'beans')], + 'conditions' => $this->payload('conditions') + ['positiveMatches' => [], 'negativeMatches' => []], + 'mappings' => ['mappings' => $this->listOf('mappings', 'mappings')], + 'scheduled' => ['tasks' => $this->listOf('scheduledtasks', 'tasks')], + 'metrics' => ['metrics' => $this->metrics()], + 'loggers' => $this->payload('loggers') + ['levels' => [], 'loggers' => []], + 'env' => ['env' => $this->flatten($this->subArray($this->payload('env'), 'firefly'), 'firefly')], + default => [], + }; + + return $data; + } + + /** + * An endpoint's payload as a string-keyed array — the shape every actuator endpoint returns. + * + * @return array + */ + private function payload(string $id): array + { + /** @var array $body */ + $body = $this->reader->read($id) ?? []; + + return $body; + } + + /** + * One list-valued key out of an endpoint's payload (e.g. beans => 'beans', mappings => 'mappings'). + * + * @return array + */ + private function listOf(string $id, string $key): array + { + return $this->subArray($this->payload($id), $key); + } + + /** + * @param array $payload + * @return array + */ + private function subArray(array $payload, string $key): array + { + $value = $payload[$key] ?? []; + + return is_array($value) ? $value : []; + } + + /** + * The metrics index returns names only, so each name is read back for its measurements — N in-process + * calls, which is the right trade for a dashboard and keeps MetricsEndpoint's contract untouched. + * + * @return list}> + */ + private function metrics(): array + { + $metrics = []; + foreach ($this->subArray($this->payload('metrics'), 'names') as $name) { + if (! is_string($name)) { + continue; + } + + $detail = $this->reader->read('metrics', [$name]); + /** @var array $detail */ + $detail = is_array($detail) ? $detail : []; + + $metrics[] = ['name' => $name, 'measurements' => $this->subArray($detail, 'measurements')]; + } + + return $metrics; + } + + private function setLoggerLevel(Request $request): RedirectResponse + { + $name = $request->input('logger'); + $level = $request->input('level'); + + if (is_string($name) && $name !== '' && is_string($level) && $level !== '') { + $this->reader->write('loggers', [$name], ['level' => $level]); + } + + return new RedirectResponse($this->settings->url('loggers')); + } + + /** + * Flattens the nested firefly.* config into dotted keys, which is how a developer looks a key up and how + * every other part of the framework names one. + * + * @param array $values + * @return array + */ + private function flatten(array $values, string $prefix): array + { + $flat = []; + foreach ($values as $key => $value) { + $path = $prefix.'.'.(string) $key; + if (is_array($value) && $value !== [] && ! array_is_list($value)) { + $flat = [...$flat, ...$this->flatten($value, $path)]; + + continue; + } + $flat[$path] = $this->scalar($value); + } + + ksort($flat); + + return $flat; + } + + private function scalar(mixed $value): string + { + return match (true) { + is_bool($value) => $value ? 'true' : 'false', + $value === null => 'null', + is_scalar($value) => (string) $value, + is_array($value) => $value === [] ? '[]' : (string) json_encode($value, JSON_UNESCAPED_SLASHES), + default => get_debug_type($value), + }; + } + + /** @param array $data */ + private function render(string $view, array $data): string + { + return $this->views->make('firefly-admin::'.$view, [ + ...$data, + 'settings' => $this->settings, + 'nav' => $this->nav(), + 'active' => $view === 'overview' ? '' : $view, + ])->render(); + } + + /** @return list */ + private function nav(): array + { + return array_values(array_filter( + AdminPage::all(), + fn (AdminPage $page): bool => $page->requires === null || $this->reader->has($page->requires), + )); + } +} diff --git a/packages/admin/src/Web/AdminPage.php b/packages/admin/src/Web/AdminPage.php new file mode 100644 index 0000000..6058c76 --- /dev/null +++ b/packages/admin/src/Web/AdminPage.php @@ -0,0 +1,38 @@ + */ + public static function all(): array + { + return [ + new self('', 'Overview', null, 'Health, build information and what this process wired at boot.'), + new self('beans', 'Beans', 'beans', 'Every bean the container registered, with its stereotype and scope.'), + new self('conditions', 'Conditions', 'conditions', 'Which auto-configurations applied, and which backed off because you supplied your own.'), + new self('mappings', 'Mappings', 'mappings', 'The compiled route table the dispatcher serves from.'), + new self('scheduled', 'Scheduled', 'scheduledtasks', 'Tasks registered by #[Scheduled], with their cron or fixed rate.'), + new self('metrics', 'Metrics', 'metrics', 'Counters, timers and gauges recorded through the meter registry.'), + new self('loggers', 'Loggers', 'loggers', 'Log channels and their levels. Changing a level here affects this process only.'), + new self('env', 'Environment', 'env', 'Resolved firefly.* configuration, with secrets masked.'), + ]; + } +} diff --git a/packages/admin/tests/AdminDisabledTest.php b/packages/admin/tests/AdminDisabledTest.php new file mode 100644 index 0000000..db61041 --- /dev/null +++ b/packages/admin/tests/AdminDisabledTest.php @@ -0,0 +1,18 @@ +get('/firefly')->assertStatus(404); + $this->get('/firefly/env')->assertStatus(404); +}); + +it('leaves the actuator alone when the dashboard is off', function () { + /** @var DisabledAdminTestCase $this */ + $this->getJson('/actuator/health')->assertStatus(200)->assertJsonPath('status', 'UP'); +}); diff --git a/packages/admin/tests/AdminEndpointReaderTest.php b/packages/admin/tests/AdminEndpointReaderTest.php new file mode 100644 index 0000000..7d29e69 --- /dev/null +++ b/packages/admin/tests/AdminEndpointReaderTest.php @@ -0,0 +1,151 @@ + $body */ +function stubEndpoint(string $id, array $body, bool $enabled = true): ActuatorEndpoint +{ + return new class($id, $body, $enabled) implements ActuatorEndpoint + { + /** @param array $body */ + public function __construct( + private readonly string $id, + private readonly array $body, + private readonly bool $enabled, + ) {} + + public function endpointId(): string + { + return $this->id; + } + + public function enabled(): bool + { + return $this->enabled; + } + + public function handle(EndpointRequest $request): ?EndpointResponse + { + if ($request->subPath !== []) { + // 'missing' models an unknown sub-resource, which the contract says is a null return. + return $request->subPath[0] === 'missing' + ? null + : EndpointResponse::json(['sub' => $request->subPath[0]]); + } + + return EndpointResponse::json($this->body); + } + }; +} + +/** @param array $firefly */ +function reader(ActuatorRegistry $registry, array $firefly = []): AdminEndpointReader +{ + return new AdminEndpointReader($registry, new Config(new Repository(['firefly' => $firefly]))); +} + +it('reads a registered endpoint in-process', function () { + $registry = new ActuatorRegistry; + $registry->register(stubEndpoint('beans', ['beans' => [['class' => 'A']]])); + + expect(reader($registry)->read('beans'))->toBe(['beans' => [['class' => 'A']]]); +}); + +// The dashboard deliberately ignores ExposureModel — that is the whole point, since exposure defaults to +// health,info and nobody should have to publish env to the world to read it locally. +it('reads an endpoint that is registered but NOT exposed over HTTP', function () { + $registry = new ActuatorRegistry; + $registry->register(stubEndpoint('env', ['firefly' => ['a' => 1]])); + + $r = reader($registry, ['management' => ['endpoints' => ['web' => ['exposure' => ['include' => 'health,info']]]]]); + + expect($r->has('env'))->toBeTrue() + ->and($r->read('env'))->toBe(['firefly' => ['a' => 1]]); +}); + +// ...but a per-endpoint kill switch means "off", not "unpublished", so it IS honoured. +it('honours the per-endpoint kill switch', function () { + $registry = new ActuatorRegistry; + $registry->register(stubEndpoint('env', ['firefly' => []])); + + $r = reader($registry, ['management' => ['endpoint' => ['env' => ['enabled' => false]]]]); + + expect($r->has('env'))->toBeFalse() + ->and($r->read('env'))->toBeNull() + ->and($r->available())->toBe([]); +}); + +it('honours an endpoint that reports itself disabled', function () { + $registry = new ActuatorRegistry; + $registry->register(stubEndpoint('metrics', [], enabled: false)); + + expect(reader($registry)->available())->toBe([]); +}); + +it('returns null for an unknown endpoint rather than throwing', function () { + expect(reader(new ActuatorRegistry)->read('nope'))->toBeNull(); +}); + +// One broken health indicator should degrade its panel, not take the whole dashboard down. +it('degrades to null when an endpoint throws', function () { + $registry = new ActuatorRegistry; + $registry->register(new class implements ActuatorEndpoint + { + public function endpointId(): string + { + return 'health'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): ?EndpointResponse + { + throw new RuntimeException('indicator exploded'); + } + }); + + expect(reader($registry)->read('health'))->toBeNull(); +}); + +it('passes a sub-path through, which is how metrics are drilled into', function () { + $registry = new ActuatorRegistry; + $registry->register(stubEndpoint('metrics', ['names' => ['http.requests']])); + + expect(reader($registry)->read('metrics', ['http.requests']))->toBe(['sub' => 'http.requests']); +}); + +// A null handle() is the contract's 404 signal (an unknown sub-resource), not an error. +it('returns null when an endpoint reports an unknown sub-resource', function () { + $registry = new ActuatorRegistry; + $registry->register(stubEndpoint('metrics', ['names' => []])); + + expect(reader($registry)->read('metrics', ['missing']))->toBeNull(); +}); + +it('reports a successful write and refuses one to an unknown endpoint', function () { + $registry = new ActuatorRegistry; + $registry->register(stubEndpoint('loggers', ['levels' => []])); + + expect(reader($registry)->write('loggers', ['app'], ['level' => 'DEBUG']))->toBeTrue() + ->and(reader($registry)->write('nope', [], []))->toBeFalse(); +}); + +it('lists only the endpoints that are actually available', function () { + $registry = new ActuatorRegistry; + $registry->register(stubEndpoint('health', [])); + $registry->register(stubEndpoint('beans', [])); + $registry->register(stubEndpoint('metrics', [], enabled: false)); + + expect(reader($registry)->available())->toBe(['health', 'beans']); +}); diff --git a/packages/admin/tests/AdminSettingsTest.php b/packages/admin/tests/AdminSettingsTest.php new file mode 100644 index 0000000..e0688af --- /dev/null +++ b/packages/admin/tests/AdminSettingsTest.php @@ -0,0 +1,49 @@ + $values */ +function adminConfig(array $values): Config +{ + return new Config(new Repository($values)); +} + +it('follows app.debug when firefly.admin.enabled is unset', function (bool $debug) { + expect(AdminSettings::fromConfig(adminConfig(['app' => ['debug' => $debug]]))->enabled)->toBe($debug); +})->with([[true], [false]]); + +// The dashboard reads endpoints in-process, bypassing ExposureModel, so its URL is the only boundary. An +// explicit setting must win in BOTH directions — including turning it ON in a non-debug environment that +// puts the route behind its own auth middleware. +it('lets an explicit setting override the debug default in both directions', function () { + expect(AdminSettings::fromConfig(adminConfig([ + 'app' => ['debug' => true], + 'firefly' => ['admin' => ['enabled' => false]], + ]))->enabled)->toBeFalse(); + + expect(AdminSettings::fromConfig(adminConfig([ + 'app' => ['debug' => false], + 'firefly' => ['admin' => ['enabled' => true]], + ]))->enabled)->toBeTrue(); +}); + +it('defaults to a /firefly base path and normalises slashes', function (string $configured, string $expected) { + $settings = AdminSettings::fromConfig(adminConfig(['firefly' => ['admin' => ['base-path' => $configured]]])); + + expect($settings->basePath)->toBe($expected) + ->and($settings->url())->toBe('/'.$expected) + ->and($settings->url('beans'))->toBe('/'.$expected.'/beans'); +})->with([ + ['/firefly', 'firefly'], + ['firefly/', 'firefly'], + ['/admin/ops/', 'admin/ops'], + ['/', 'firefly'], +]); + +it('titles itself after the application', function () { + expect(AdminSettings::fromConfig(adminConfig(['app' => ['name' => 'Lumen']]))->title)->toBe('Lumen'); +}); diff --git a/packages/admin/tests/CapstoneAdminIntegrationTest.php b/packages/admin/tests/CapstoneAdminIntegrationTest.php new file mode 100644 index 0000000..903011e --- /dev/null +++ b/packages/admin/tests/CapstoneAdminIntegrationTest.php @@ -0,0 +1,58 @@ +get('/firefly') + ->assertStatus(200) + ->assertHeader('Content-Type', 'text/html; charset=UTF-8') + ->assertSee('Overview', false) + ->assertSee('Backed off', false); +}); + +// The point of reading endpoints in-process: exposure is at its secure default of health,info here, so +// /actuator/beans would 404 — yet the dashboard renders beans anyway. +it('renders endpoints that are NOT exposed over HTTP', function () { + /** @var AdminCapstoneTestCase $this */ + $this->getJson('/actuator/beans')->assertStatus(404); + + $this->get('/firefly/beans') + ->assertStatus(200) + ->assertSee('Container', false); + + $this->get('/firefly/env') + ->assertStatus(200) + ->assertSee('Configuration', false); +}); + +it('serves every page in the menu', function (string $slug, string $marker) { + /** @var AdminCapstoneTestCase $this */ + $this->get('/firefly/'.$slug) + ->assertStatus(200) + ->assertSee($marker, false); +})->with([ + ['beans', 'Beans'], + ['conditions', 'Conditions'], + ['mappings', 'Mappings'], + ['scheduled', 'Scheduled tasks'], + ['loggers', 'Loggers'], + ['env', 'Environment'], +]); + +it('404s an unknown page without leaking a stack trace', function () { + /** @var AdminCapstoneTestCase $this */ + $response = $this->get('/firefly/not-a-page'); + + $response->assertStatus(404)->assertSee('No such page', false); + expect($response->getContent())->not->toContain('Stack trace'); +}); + +it('shows the boot mode so nobody ships a reflection-scanning app by accident', function () { + /** @var AdminCapstoneTestCase $this */ + $this->get('/firefly')->assertSee('scanned', false); +}); diff --git a/packages/admin/tests/Support/AdminCapstoneTestCase.php b/packages/admin/tests/Support/AdminCapstoneTestCase.php new file mode 100644 index 0000000..72e059d --- /dev/null +++ b/packages/admin/tests/Support/AdminCapstoneTestCase.php @@ -0,0 +1,58 @@ + */ + protected function configOverrides(): array + { + return [ + 'firefly.management.enabled' => true, + 'firefly.management.endpoint.health.db.enabled' => true, + 'firefly.admin.enabled' => $this->adminEnabled(), + ]; + } + + protected function adminEnabled(): bool + { + return true; + } + + protected function defineFireflyEnvironment(Application $app): void + { + // ScheduledTasksEndpoint is eagerly resolved and needs a bound manifest; no Scheduling provider is + // registered here, so use the shared harness stub (same reasoning as ActuatorCapstoneTestCase). + FireflyBoot::stubScheduledManifest($app); + } +} diff --git a/packages/admin/tests/Support/DisabledAdminTestCase.php b/packages/admin/tests/Support/DisabledAdminTestCase.php new file mode 100644 index 0000000..446ea84 --- /dev/null +++ b/packages/admin/tests/Support/DisabledAdminTestCase.php @@ -0,0 +1,17 @@ + Date: Thu, 3 Sep 2026 14:24:31 -0700 Subject: [PATCH 10/31] fix(actuator): honour * in exposure.exclude, and render an empty body as {} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contract bugs found while building the dashboard against these endpoints. (1) `*` was a wildcard only in `exposure.include`. So the documented kill switch `firefly.management.endpoints.web.exposure.exclude=*` silently exposed everything `include` named instead of nothing — the exact inverse of what an operator reaching for it wants. Spring treats `*` the same way on both sides; exclude still beats include. (2) An endpoint body is a JSON object by contract, but PHP encodes the empty array as `[]`. /actuator/info with no InfoContributor registered — the default for a fresh application — therefore answered `[]`: an array where every client, and every other response from that same endpoint, expects an object. A typed client deserialising into a map breaks on it. Spring returns `{}`. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../actuator/src/Endpoint/ExposureModel.php | 16 ++++++++++------ .../actuator/src/Web/ActuatorDispatchAction.php | 12 +++++++++--- .../tests/CapstoneActuatorIntegrationTest.php | 11 +++++++++++ .../tests/Endpoint/ExposureModelTest.php | 17 +++++++++++++++++ 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/actuator/src/Endpoint/ExposureModel.php b/packages/actuator/src/Endpoint/ExposureModel.php index 4ac72ff..1d421b4 100644 --- a/packages/actuator/src/Endpoint/ExposureModel.php +++ b/packages/actuator/src/Endpoint/ExposureModel.php @@ -33,17 +33,21 @@ public static function fromConfig(Config $config): self return new self($include, $exclude, $base === '' ? 'actuator' : $base); } + /** + * Exclude wins over include, and `*` is a wildcard in BOTH lists. + * + * The wildcard used to be honoured only in `include`, so the documented kill-switch spelling + * `exposure.exclude=*` silently exposed everything `include` named instead of nothing — the exact + * inverse of what an operator reaching for it wants. Spring treats `*` the same way on both sides, and + * so does this now. + */ public function isExposed(string $id): bool { - if (in_array($id, $this->exclude, true)) { + if (in_array('*', $this->exclude, true) || in_array($id, $this->exclude, true)) { return false; } - if (in_array('*', $this->include, true)) { - return true; - } - - return in_array($id, $this->include, true); + return in_array('*', $this->include, true) || in_array($id, $this->include, true); } /** diff --git a/packages/actuator/src/Web/ActuatorDispatchAction.php b/packages/actuator/src/Web/ActuatorDispatchAction.php index f4e22e5..117fb75 100644 --- a/packages/actuator/src/Web/ActuatorDispatchAction.php +++ b/packages/actuator/src/Web/ActuatorDispatchAction.php @@ -67,9 +67,15 @@ public function __invoke(Request $request, string $path): Response private function toResponse(EndpointResponse $response): Response { - $body = is_string($response->body) - ? $response->body - : (string) json_encode($response->body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $body = match (true) { + is_string($response->body) => $response->body, + // An endpoint body is a JSON OBJECT by contract, but PHP encodes the empty array as `[]`. So + // /actuator/info with no InfoContributor answered `[]` — an array where every client, and every + // other response from the same endpoint, expects an object. A typed client deserialising into a + // map breaks on it. Spring returns `{}`. + $response->body === [] => '{}', + default => (string) json_encode($response->body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + }; return new Response($body, $response->status, ['Content-Type' => $response->contentType]); } diff --git a/packages/actuator/tests/CapstoneActuatorIntegrationTest.php b/packages/actuator/tests/CapstoneActuatorIntegrationTest.php index 39fca7a..51a70aa 100644 --- a/packages/actuator/tests/CapstoneActuatorIntegrationTest.php +++ b/packages/actuator/tests/CapstoneActuatorIntegrationTest.php @@ -33,3 +33,14 @@ // needs its OWN boot (see ActuatorEnvExposedCapstoneTestCase) — ExposureModel's include list is a singleton // #[Bean] captured ONCE at boot, so a post-boot config()->set() here (the brief's literal draft) never reaches // the already-resolved instance. Fixed test, not production code — see ActuatorCapstoneTestCase::exposureInclude(). + +// An endpoint body is a JSON OBJECT by contract, but PHP encodes the empty array as `[]`. /actuator/info +// with no InfoContributor therefore answered `[]` — an array where every client, and every other response +// from the same endpoint, expects an object, which breaks a typed client deserialising into a map. +it('renders an empty endpoint body as {} rather than []', function () { + /** @var ActuatorCapstoneTestCase $this */ + $response = $this->get('/actuator/info'); + + $response->assertStatus(200); + expect($response->getContent())->toBe('{}'); +}); diff --git a/packages/actuator/tests/Endpoint/ExposureModelTest.php b/packages/actuator/tests/Endpoint/ExposureModelTest.php index 5b90580..1bf784b 100644 --- a/packages/actuator/tests/Endpoint/ExposureModelTest.php +++ b/packages/actuator/tests/Endpoint/ExposureModelTest.php @@ -36,3 +36,20 @@ function exposure(array $management): ExposureModel expect($model->basePath)->toBe('manage'); }); + +// `*` used to be honoured only in include, so the documented kill switch exposure.exclude=* silently +// exposed everything include named — the inverse of what an operator reaching for it wants. +it('treats * in exclude as a wildcard that shuts everything off', function () { + $model = exposure(['endpoints' => ['web' => ['exposure' => ['include' => '*', 'exclude' => '*']]]]); + + expect($model->isExposed('health'))->toBeFalse() + ->and($model->isExposed('info'))->toBeFalse() + ->and($model->isExposed('env'))->toBeFalse(); +}); + +it('lets exclude=* override even an explicit include list', function () { + $model = exposure(['endpoints' => ['web' => ['exposure' => ['include' => 'health,info', 'exclude' => '*']]]]); + + expect($model->isExposed('health'))->toBeFalse() + ->and($model->isExposed('info'))->toBeFalse(); +}); From ad9c45742be94df345265d49943442a106e15eb8 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 15:10:07 -0700 Subject: [PATCH 11/31] =?UTF-8?q?feat:=20finish=20the=20plan=20=E2=80=94?= =?UTF-8?q?=20OpenAPI=203.1,=20CLI=20archetypes,=20nested=20body=20hydrati?= =?UTF-8?q?on,=20config=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five areas, each built by an owner and independently re-verified by a reviewer that reverted the source and confirmed the new tests fail without it. packages/context — bean resolution after container's #[Primary]/#[Qualifier] change EagerSingletonsPass and RegisterBeanPostProcessorsPass both resolved bean products by $bean->returns, the TYPE key, which stopped being a given bean's own key once several beans compete for one type. Two consequences, both proven by reverting: an application with two same-typed beans and no #[Primary] died AT BOOT even if it only ever injected them by #[Qualifier]; and with a #[Primary], every named sibling escaped the BeanPostProcessor chain entirely — including TransactionalBeanPostProcessor, so #[Transactional] silently would not have applied to them. The eager pass also never instantiated the siblings at all, so the "eager singleton" guarantee was already being violated before this, just quietly. packages/web — nested request bodies ArgumentResolver gained a hydration engine driven by a compiled `dtos` shape table; RouteScanner did not emit that key, so every production route fell through to the "plan cannot say" path and the engine was real, tested and unreachable. The scanner now compiles the table: one row per class reachable from the body DTO, keyed by class so a self-referential DTO is a single row and depth is bounded by the payload rather than the plan. `list` element types are read from the constructor docblock, since PHP's `array` carries no element type, and short names resolve through the declaring namespace and the file's `use` imports. A nested body now hydrates (proven end to end, including four levels of a self-referential DTO); one the plan genuinely cannot describe still answers a clean 400 rather than a TypeError rendered as a 500 that quoted this repository's absolute path back to the client. packages/openapi (new) — OpenAPI 3.1 generation from the manifests already in memory: paths and operations from the RouteManifest, parameter and requestBody schemas derived from the validation constraints, $ref reuse per DTO, the framework's real problem+json error shape as a shared component, a `firefly:openapi` command, and spec + viewer routes with no npm step and no CDN by default. Also here: the generator was documenting the welcome page as application/json. #[Controller] extends #[RestController], so an HTML route lands in the same RouteManifest as every JSON route, and a client generator would have turned that into a typed call expecting a deserialisable body. RouteDescriptor now records `html` at scan time — the stereotype is an attribute, so the question is answered once by the scanner rather than asked again by anything downstream — and HTML routes are excluded by default, or documented as text/html under firefly.openapi.include-html. packages/installer — project archetypes: --api, --web, --full and --with=, with interactive prompts that stay silent under --no-interaction. `firefly new --force` also worked for the first time: it checked the directory itself, then shelled out to composer create-project, which refuses a non-empty directory anyway. firefly:serve now reports the URL and whether the app booted compiled or scanned. skeleton/config/firefly.php — the config reference. It shipped SIXTEEN lines exposing four keys while the framework read 81. It now documents every key the code actually reads, grouped by package, with the real default from the call site, and the advanced ones commented out with their defaults shown. Plus the stale docs the owners flagged as a direct result of their own changes, and a CHANGELOG entry calling out the #[Bean] breaking change. 1668 tests pass, PHPStan max clean, deptrac 0, Pint clean, monorepo-builder validates. Verified on a fresh skeleton: all five generators produce code that firefly:cache compiles, and /, /greetings/{name}, /actuator, /firefly and /openapi.json all serve. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .github/workflows/release.yml | 1 + CHANGELOG.md | 130 ++++ README.md | 27 +- composer.json | 2 + deptrac.yaml | 24 + docs/README.md | 2 +- docs/cli.md | 64 +- docs/getting-started.md | 33 +- docs/installation.md | 15 +- docs/laravel-comparison.md | 8 +- docs/modules/actuator.md | 7 +- docs/modules/configuration.md | 91 ++- docs/modules/cqrs.md | 13 +- docs/modules/dependency-injection.md | 72 +- docs/modules/eda-brokers.md | 24 +- docs/modules/eda.md | 37 +- docs/modules/messaging.md | 8 +- docs/modules/observability.md | 57 +- docs/modules/resilience.md | 104 ++- docs/modules/scheduling.md | 8 +- docs/modules/security.md | 82 ++- docs/modules/transactional.md | 26 +- docs/modules/validation.md | 54 +- docs/modules/web.md | 86 ++- docs/versioning.md | 9 +- packages/cli/src/Command/ServeCommand.php | 70 +- .../cli/tests/Command/ServeCommandTest.php | 131 ++++ packages/context/src/Pass/BeanBindingKeys.php | 113 +++ .../context/src/Pass/EagerSingletonsPass.php | 43 +- .../Pass/RegisterBeanPostProcessorsPass.php | 108 ++- .../src/Pass/RegisterEventListenersPass.php | 78 ++- .../CacheConfiguration.php | 35 + .../tests/CompetingBeanFixtures/CachePing.php | 12 + .../tests/CompetingBeanFixtures/CachePort.php | 18 + .../CompetingBeanFixtures/CacheProbe.php | 39 ++ .../CompetingBeanFixtures/ConcreteCache.php | 43 ++ .../ConcreteCacheConfiguration.php | 32 + .../CompetingBeanFixtures/MemoryCache.php | 40 ++ .../RecordingCacheBpp.php | 43 ++ .../CompetingBeanFixtures/RedisCache.php | 40 ++ .../tests/Pass/CompetingBeansPassTest.php | 319 +++++++++ packages/installer/README.md | 65 +- packages/installer/composer.json | 4 +- packages/installer/src/Archetype.php | 144 ++++ packages/installer/src/ArchetypeApplier.php | 247 +++++++ packages/installer/src/Capability.php | 43 ++ packages/installer/src/CapabilityCatalog.php | 165 +++++ packages/installer/src/Filesystem.php | 130 ++++ packages/installer/src/NewCommand.php | 215 +++++- .../api/tests/Feature/ApiSmokeTest.php.stub | 32 + packages/installer/tests/ArchetypeTest.php | 327 +++++++++ .../installer/tests/CapabilityCatalogTest.php | 107 +++ .../tests/CreateProjectInstallerTest.php | 9 +- packages/installer/tests/FilesystemTest.php | 115 +++ packages/installer/tests/NewCommandTest.php | 130 +++- .../tests/Support/FakeProcessRunner.php | 19 +- packages/installer/tests/Support/Skeleton.php | 177 +++++ packages/openapi/.gitattributes | 2 + packages/openapi/LICENSE | 204 ++++++ packages/openapi/README.md | 147 ++++ .../cache/firefly-openapi-components.php | 76 ++ .../openapi/cache/firefly-openapi-context.php | 87 +++ packages/openapi/composer.json | 44 ++ .../src/Boot/OpenApiRouteRegistrar.php | 76 ++ .../openapi/src/Command/OpenApiCommand.php | 72 ++ .../src/Generator/OpenApiGenerator.php | 240 +++++++ .../src/Generator/OperationFactory.php | 308 ++++++++ .../openapi/src/OpenApiAutoConfiguration.php | 86 +++ packages/openapi/src/OpenApiProperties.php | 120 ++++ .../openapi/src/OpenApiServiceProvider.php | 27 + .../openapi/src/OpenApiWiringProvider.php | 46 ++ .../src/Schema/ConstraintSchemaMapper.php | 331 +++++++++ .../openapi/src/Schema/DtoSchemaFactory.php | 214 ++++++ packages/openapi/src/Schema/MapperState.php | 238 +++++++ packages/openapi/src/Schema/MemberType.php | 66 ++ packages/openapi/src/Schema/ProblemSchema.php | 102 +++ .../openapi/src/Schema/PropertySchema.php | 21 + .../openapi/src/Schema/SchemaRegistry.php | 94 +++ packages/openapi/src/Schema/TypeSchema.php | 104 +++ .../openapi/src/Web/OpenApiSpecAction.php | 34 + .../openapi/src/Web/OpenApiViewerAction.php | 37 + packages/openapi/src/Web/ViewerPage.php | 286 ++++++++ packages/openapi/tests/BeanOverrideTest.php | 30 + .../tests/Command/OpenApiCommandTest.php | 60 ++ .../tests/CompiledManifestFreshnessTest.php | 30 + .../EdgeFixture/Alpha/ReportController.php | 30 + .../EdgeFixture/Beta/ReportController.php | 25 + .../openapi/tests/Fixture/AddressPayload.php | 19 + .../openapi/tests/Fixture/Billing/Address.php | 13 + .../tests/Fixture/CreateOrderRequest.php | 36 + packages/openapi/tests/Fixture/Currency.php | 12 + .../openapi/tests/Fixture/DefaultsPayload.php | 23 + .../openapi/tests/Fixture/LegacyPayload.php | 23 + .../openapi/tests/Fixture/OrderController.php | 47 ++ .../openapi/tests/Fixture/SelfReferential.php | 22 + .../tests/Fixture/Shipping/Address.php | 13 + .../tests/Fixture/WelcomePageController.php | 27 + .../openapi/tests/Generator/HtmlRouteTest.php | 67 ++ .../tests/Generator/OpenApiGeneratorTest.php | 220 ++++++ .../OperationIdAndPathTemplateTest.php | 105 +++ .../openapi/tests/OpenApiPropertiesTest.php | 72 ++ .../Override/AppOpenApiConfiguration.php | 29 + packages/openapi/tests/PackageBootTest.php | 81 +++ .../Schema/ConstraintSchemaMapperTest.php | 183 +++++ .../tests/Schema/DtoSchemaFactoryTest.php | 120 ++++ .../tests/Schema/ProblemSchemaTest.php | 82 +++ .../Support/CustomPathCapstoneTestCase.php | 22 + .../openapi/tests/Support/FixtureDocument.php | 142 ++++ .../tests/Support/OpenApiCapstoneTestCase.php | 58 ++ .../OpenApiDisabledCapstoneTestCase.php | 17 + .../ViewerDisabledCapstoneTestCase.php | 17 + .../Web/CapstoneConfigurablePathTest.php | 21 + .../tests/Web/CapstoneOpenApiDisabledTest.php | 33 + .../tests/Web/CapstoneOpenApiHttpTest.php | 76 ++ .../tests/Web/CapstoneViewerDisabledTest.php | 13 + packages/openapi/tests/Web/ViewerPageTest.php | 54 ++ .../web/src/Dispatch/ArgumentResolver.php | 267 ++++++- packages/web/src/Route/RouteDescriptor.php | 26 +- packages/web/src/Route/RouteScanner.php | 177 ++++- .../tests/Dispatch/ArgumentResolverTest.php | 382 ++++++++++ .../tests/Dispatch/NestedBodyBindingTest.php | 68 ++ .../web/tests/Fixtures/AddressPayload.php | 25 + packages/web/tests/Fixtures/Currency.php | 12 + packages/web/tests/Fixtures/GeoPoint.php | 17 + .../tests/Fixtures/MoneyTransferRequest.php | 26 + packages/web/tests/Fixtures/NodeRequest.php | 23 + .../web/tests/Fixtures/NodesController.php | 32 + packages/web/tests/Fixtures/PricedRequest.php | 18 + packages/web/tests/Fixtures/TransferLine.php | 17 + .../tests/Fixtures/TransfersController.php | 32 + .../tests/Fixtures/UnbindableController.php | 27 + .../web/tests/Fixtures/UnbindableRequest.php | 19 + .../web/tests/Route/ScannedNestedBodyTest.php | 125 ++++ skeleton/.env.example | 55 ++ skeleton/README.md | 42 +- skeleton/config/firefly.php | 656 ++++++++++++++++++ tests/ReleaseWorkflowTest.php | 4 +- 137 files changed, 10741 insertions(+), 270 deletions(-) create mode 100644 packages/cli/tests/Command/ServeCommandTest.php create mode 100644 packages/context/src/Pass/BeanBindingKeys.php create mode 100644 packages/context/tests/CompetingBeanFixtures/CacheConfiguration.php create mode 100644 packages/context/tests/CompetingBeanFixtures/CachePing.php create mode 100644 packages/context/tests/CompetingBeanFixtures/CachePort.php create mode 100644 packages/context/tests/CompetingBeanFixtures/CacheProbe.php create mode 100644 packages/context/tests/CompetingBeanFixtures/ConcreteCache.php create mode 100644 packages/context/tests/CompetingBeanFixtures/ConcreteCacheConfiguration.php create mode 100644 packages/context/tests/CompetingBeanFixtures/MemoryCache.php create mode 100644 packages/context/tests/CompetingBeanFixtures/RecordingCacheBpp.php create mode 100644 packages/context/tests/CompetingBeanFixtures/RedisCache.php create mode 100644 packages/context/tests/Pass/CompetingBeansPassTest.php create mode 100644 packages/installer/src/Archetype.php create mode 100644 packages/installer/src/ArchetypeApplier.php create mode 100644 packages/installer/src/Capability.php create mode 100644 packages/installer/src/CapabilityCatalog.php create mode 100644 packages/installer/src/Filesystem.php create mode 100644 packages/installer/stubs/api/tests/Feature/ApiSmokeTest.php.stub create mode 100644 packages/installer/tests/ArchetypeTest.php create mode 100644 packages/installer/tests/CapabilityCatalogTest.php create mode 100644 packages/installer/tests/FilesystemTest.php create mode 100644 packages/installer/tests/Support/Skeleton.php create mode 100644 packages/openapi/.gitattributes create mode 100644 packages/openapi/LICENSE create mode 100644 packages/openapi/README.md create mode 100644 packages/openapi/cache/firefly-openapi-components.php create mode 100644 packages/openapi/cache/firefly-openapi-context.php create mode 100644 packages/openapi/composer.json create mode 100644 packages/openapi/src/Boot/OpenApiRouteRegistrar.php create mode 100644 packages/openapi/src/Command/OpenApiCommand.php create mode 100644 packages/openapi/src/Generator/OpenApiGenerator.php create mode 100644 packages/openapi/src/Generator/OperationFactory.php create mode 100644 packages/openapi/src/OpenApiAutoConfiguration.php create mode 100644 packages/openapi/src/OpenApiProperties.php create mode 100644 packages/openapi/src/OpenApiServiceProvider.php create mode 100644 packages/openapi/src/OpenApiWiringProvider.php create mode 100644 packages/openapi/src/Schema/ConstraintSchemaMapper.php create mode 100644 packages/openapi/src/Schema/DtoSchemaFactory.php create mode 100644 packages/openapi/src/Schema/MapperState.php create mode 100644 packages/openapi/src/Schema/MemberType.php create mode 100644 packages/openapi/src/Schema/ProblemSchema.php create mode 100644 packages/openapi/src/Schema/PropertySchema.php create mode 100644 packages/openapi/src/Schema/SchemaRegistry.php create mode 100644 packages/openapi/src/Schema/TypeSchema.php create mode 100644 packages/openapi/src/Web/OpenApiSpecAction.php create mode 100644 packages/openapi/src/Web/OpenApiViewerAction.php create mode 100644 packages/openapi/src/Web/ViewerPage.php create mode 100644 packages/openapi/tests/BeanOverrideTest.php create mode 100644 packages/openapi/tests/Command/OpenApiCommandTest.php create mode 100644 packages/openapi/tests/CompiledManifestFreshnessTest.php create mode 100644 packages/openapi/tests/EdgeFixture/Alpha/ReportController.php create mode 100644 packages/openapi/tests/EdgeFixture/Beta/ReportController.php create mode 100644 packages/openapi/tests/Fixture/AddressPayload.php create mode 100644 packages/openapi/tests/Fixture/Billing/Address.php create mode 100644 packages/openapi/tests/Fixture/CreateOrderRequest.php create mode 100644 packages/openapi/tests/Fixture/Currency.php create mode 100644 packages/openapi/tests/Fixture/DefaultsPayload.php create mode 100644 packages/openapi/tests/Fixture/LegacyPayload.php create mode 100644 packages/openapi/tests/Fixture/OrderController.php create mode 100644 packages/openapi/tests/Fixture/SelfReferential.php create mode 100644 packages/openapi/tests/Fixture/Shipping/Address.php create mode 100644 packages/openapi/tests/Fixture/WelcomePageController.php create mode 100644 packages/openapi/tests/Generator/HtmlRouteTest.php create mode 100644 packages/openapi/tests/Generator/OpenApiGeneratorTest.php create mode 100644 packages/openapi/tests/Generator/OperationIdAndPathTemplateTest.php create mode 100644 packages/openapi/tests/OpenApiPropertiesTest.php create mode 100644 packages/openapi/tests/Override/AppOpenApiConfiguration.php create mode 100644 packages/openapi/tests/PackageBootTest.php create mode 100644 packages/openapi/tests/Schema/ConstraintSchemaMapperTest.php create mode 100644 packages/openapi/tests/Schema/DtoSchemaFactoryTest.php create mode 100644 packages/openapi/tests/Schema/ProblemSchemaTest.php create mode 100644 packages/openapi/tests/Support/CustomPathCapstoneTestCase.php create mode 100644 packages/openapi/tests/Support/FixtureDocument.php create mode 100644 packages/openapi/tests/Support/OpenApiCapstoneTestCase.php create mode 100644 packages/openapi/tests/Support/OpenApiDisabledCapstoneTestCase.php create mode 100644 packages/openapi/tests/Support/ViewerDisabledCapstoneTestCase.php create mode 100644 packages/openapi/tests/Web/CapstoneConfigurablePathTest.php create mode 100644 packages/openapi/tests/Web/CapstoneOpenApiDisabledTest.php create mode 100644 packages/openapi/tests/Web/CapstoneOpenApiHttpTest.php create mode 100644 packages/openapi/tests/Web/CapstoneViewerDisabledTest.php create mode 100644 packages/openapi/tests/Web/ViewerPageTest.php create mode 100644 packages/web/tests/Dispatch/NestedBodyBindingTest.php create mode 100644 packages/web/tests/Fixtures/AddressPayload.php create mode 100644 packages/web/tests/Fixtures/Currency.php create mode 100644 packages/web/tests/Fixtures/GeoPoint.php create mode 100644 packages/web/tests/Fixtures/MoneyTransferRequest.php create mode 100644 packages/web/tests/Fixtures/NodeRequest.php create mode 100644 packages/web/tests/Fixtures/NodesController.php create mode 100644 packages/web/tests/Fixtures/PricedRequest.php create mode 100644 packages/web/tests/Fixtures/TransferLine.php create mode 100644 packages/web/tests/Fixtures/TransfersController.php create mode 100644 packages/web/tests/Fixtures/UnbindableController.php create mode 100644 packages/web/tests/Fixtures/UnbindableRequest.php create mode 100644 packages/web/tests/Route/ScannedNestedBodyTest.php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b0b4a4..0f10401 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,7 @@ jobs: - { local: packages/actuator, split: firefly-actuator } - { local: packages/observability, split: firefly-observability } - { local: packages/admin, split: firefly-admin } + - { local: packages/openapi, split: firefly-openapi } - { local: packages/testing, split: firefly-testing } - { local: packages/cli, split: firefly-cli } - { local: packages/firefly, split: firefly-firefly } diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1c869..ea922f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,136 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`YY.MM.Patch`). +## [Unreleased] + +Cut as `26.09.1` when released: `Firefly\Kernel\Version::VERSION`, this heading, and the README version +badge move together (see [Versioning](docs/versioning.md)), and `tests/VersionConsistencyTest.php` fails the +build if any one of the three drifts. + +A correctness release. Several headline features were found not to work at all outside the compiled boot, and +two of the failures were **fail-open** in the security sense — the application kept serving, unguarded, with +nothing logged. Every fix below was reproduced by a failing test first. + +### BREAKING + +- **`packages/container` — two non-`#[Primary]` `#[Bean]` methods returning the same type now THROW at + registration.** They previously booted, and one of the two beans silently did not exist: a bean name was + only ever recorded as `alias($returns, $name)`, and an alias is a pointer to a key rather than a binding of + its own, so both names pointed at the single type key, that key held whichever factory registered last, and + `getByName('memoryCache')` and `getByName('redisCache')` handed back the identical object. `#[Primary]` could + not break the tie because `BeanDescriptor::$primary` was read nowhere in the bean path. **Migration:** give + each competing `#[Bean]` method a distinct name and mark exactly one `#[Primary]` — the type key then + aliases the primary and every candidate stays individually resolvable. Rejected at registration (where the + stack trace still points at the manifest): competing beans that are anonymous, that share a name, that are + named after the contested type itself, or that declare more than one `#[Primary]`. A contested type with no + `#[Primary]` stays *bound* — to a guard that throws naming every candidate — so `#[ConditionalOnMissingBean]` + still sees that a bean of that type exists. See [Dependency Injection](docs/modules/dependency-injection.md). + +### Added +- **`Firefly\Context\Scan\AppScan`** — the seam every capability package uses to resolve its own manifest: + compiled artifact, else an in-process scan of `firefly.scan.paths`, else empty. Routes, `#[ControllerAdvice]` + handlers, CQRS handlers, event/message listeners, scheduled tasks, validation constraints, method-security + rules, `#[ConfigProperties]` DTOs and the `#[Transactional]` manifest all resolve through it, so an uncached + application behaves exactly like a cached one. `firefly/cli` joins the `firefly/firefly` metapackage. +- **`firefly.security.method.strict`** (default `false`) — refuses to boot when no compiled method-security + manifest exists, instead of falling back to the scan. The only defence against a build that ships without + the compile step. +- **`firefly.observability.metrics.store` / `.ttl`** — names a cache store, swapping `SimpleMeterRegistry` for + the new `CacheMeterRegistry` so counters and timers survive the request that recorded them. `increment()` + and `record()` use the store's atomic increment (durations accumulate as integer microseconds, because + `increment()` is integer-only and a float read-modify-write drops samples); `setGauge()` is last-writer-wins; + `meters()` rehydrates from one index rather than a key scan. Opt-in: on the `array` driver it would be no + better than memory. +- **`#[Controller]`** — the HTML stereotype (Spring's `@Controller` to `#[RestController]`'s + `@RestController`), extending `#[RestController]` so `RouteScanner`'s `IS_INSTANCEOF` filter finds it + unchanged. `ResponseFactory` now renders `View`/`Renderable`/`Htmlable` and the new `ModelAndView` as + `text/html`; arrays and scalars still negotiate to JSON. A bare `string` is deliberately **not** a view name. +- **`#[ControllerAdvice]`/`#[ExceptionHandler]` are wired for the first time** — `RouteScanner`'s + `scanExceptionHandlers()` always existed, but nothing compiled the result, so `ExceptionHandlerRegistry` was + empty in every real boot while the docs taught it as working. `firefly:cache` now emits + `exception-handlers.php`, and compiles 13 manifests in total. +- **`packages/config`** — relaxed binding (exact → `snake_case` → `kebab-case` → `SCREAMING_SNAKE_CASE`, + acronym-aware) and `#[Profile]` gating for `#[ConfigProperties]` DTOs. +- **`packages/resilience`** — `circuit-breaker.minimum-number-of-calls` and `.half-open-probe-timeout`, + `bulkhead.permit-ttl`, and `firefly.resilience.store.lock-block-timeout` (default `0.5`s) for the mutex wait + budget. +- **`skeleton/config/firefly.php` is now a full configuration reference** — every `firefly.*` key the framework + reads, grouped by capability, with its real default and what it does; advanced keys stay commented out at + their defaults. `skeleton/.env.example` carries the ones that usually vary per environment. The skeleton + also gains a `#[Controller]` welcome page (nothing on it hard-coded — real bean/condition counts, the real + route table, the real actuator registry) and its first test suite. + +### Changed +- **`#[Qualifier]` on a parameter is honoured.** It declared `TARGET_PARAMETER` from day one and nothing read + it, so `#[Qualifier('redisCache')] Cache $cache` silently received whatever `Cache::class` resolved to. It + now rides `ContextualAttribute` — the seam `#[Value]` already used — adding no reflection that was not + already happening and leaving the compiled manifest shape untouched. +- **`#[Bean]` discovery no longer compares stereotype short names.** The gate was `$shortAttr === + 'configuration'`, the one place in the scanner that abandoned `IS_INSTANCEOF`, so `#[Bean]` methods on a + user-defined stereotype extending `#[Configuration]` — or on a plain `#[Component]`, Spring's "lite mode" — + vanished from the manifest while the class itself was still bound. +- **`make:firefly-*` output.** `-handler` writes two files (the handler *and* the concrete command/query class + its `handle()` takes); `-listener` puts `#[Component]` on the generated class; `-repository` generates a + concrete `#[Repository]` extending `EloquentRepository` instead of an unresolvable interface. +- **`firefly.management.endpoints.web.exposure.exclude` honours `*`**, matching `include` and Spring — the + documented kill switch used to expose everything `include` named. An endpoint body renders as `{}` rather + than `[]` when empty. +- The skeleton drops `app/Support/CachedTransactionalConfiguration.php`, the hand-written workaround every + application needed while `DataAutoConfiguration` bound an empty `TransactionalManifest`. +- Docs corrected against source throughout: the CLI's cached-vs-uncached boot, the resilience circuit-breaker + and bulkhead tables and their state prose, configuration's relaxed binding and profile gating, the web + layer's HTML rendering, security's fail-open note and full config table, observability's cross-process + registry, and the "compilation lands in M15 — until then bind the manifest yourself" caveat that five module + guides still carried. + +### Fixed +- **`packages/security` — method security failed OPEN.** Both enforcement sites treat "no rule for this + method" as ALLOW, so the unconditional empty `SecurityMethodManifest` silently disabled every + `#[PreAuthorize]`, `#[Secured]` and `#[RolesAllowed]` in the application. Only `firefly/cli` — then a + `require-dev` package absent from the metapackage — ever bound the compiled rules. +- **`packages/security` — the expression evaluator failed OPEN.** `SecurityExpressionEvaluator` is a singleton + whose parse state lives on the instance, and `hasPermission()` calls application code (a user-supplied + `PermissionEvaluator`) that may evaluate an expression of its own on that same singleton. The inner call + overwrote the outer parse state, so `hasPermission(#id, 'read') and hasRole('ADMIN')` returned **true** for a + principal holding no authorities at all. State is now saved and restored in a `finally`. +- **Boot — the framework only worked in its compiled state.** `firefly:clear` on a freshly created skeleton + made the app 404 every route it owned, and no quality gate could see it. Fixed by `AppScan` above. +- **`packages/data` — `#[Transactional]` was a silent no-op.** Nothing ever loaded the compiled + `transactional.php`, so `hasProxyFor()` was always false. `ProxyMaterializer` now makes proxies loadable on + both paths (classmap when compiled, generated per-process when not) *before* the manifest is handed out. +- **`packages/eda-postgres` — with `provider=postgres` no `#[EventListener]` was ever subscribed and outbox + rows were ACKed without being delivered**: silent data loss in the headline feature. `firefly:outbox:relay` + could not work either, because `downstream_provider` selected no publisher; it now resolves a shipped alias, + an `EventPublisher` class-string or a bound container id, validates at command time (not boot), refuses a + `PostgresEventPublisher` downstream, and fails loudly instead of exiting successfully when unconfigured. +- **`packages/eda` — `#[EventListener(order:)]` was discarded at dispatch.** It round-tripped through the + manifest and the wiring pass then iterated `all()`; it now iterates `ordered()`. +- **`packages/resilience` — the CircuitBreaker wedged permanently in HALF_OPEN** when a probe threw a + non-recorded exception or its worker died, rejecting 100% of traffic to a healthy dependency until an + operator flushed the cache. Probe permits are now expiring leases, an ignored exception explicitly returns + its permit, and bulkhead permits (which leaked the same way, and could be driven negative by an unmatched + `release()`) are leases too. `state()` reported a stale `open` for a breaker whose wait window had elapsed, + so the actuator gauge called a recovering breaker hard-down; it now reports the state `admit()` would decide. + The store's mutex WAIT budget is separated from its HOLD TTL, so a `timeout: 0` rate limiter no longer blocks + five seconds and then surfaces an unmapped `LockTimeoutException` as a bare HTTP 500 — it raises a 503 + `RESILIENCE_STORE_LOCK_TIMEOUT`. +- **`packages/validation`** — `#[Size]` silently flipped from length to numeric semantics beside any constraint + emitting `numeric`; a present-but-null value failed every constraint instead of only `@NotNull` (Jakarta + semantics); `#[Rules]` lost a custom `ValidationRule`'s constructor arguments on the compiled path, booting + `new StartsWith()` where the developer wrote `new StartsWith('ACME')`. Rules now declare their arguments via + `Compilable`, or have them recovered from promoted properties at COMPILE time, or are rejected then with an + actionable message — never silently stripped at runtime. +- **`packages/config`** — `ProfileResolver` read raw `getenv()`, which returns `false` under both testbench and + `config:cache`, so profiles collapsed to `['default']` exactly where they mattered; `#[Profile]` was + declared, exported and documented with zero production readers. +- **`packages/observability` — `/actuator/metrics` and `/actuator/prometheus` were effectively empty in + production.** Under PHP-FPM every request is a fresh process, so a scrape saw only what that scrape's own + request recorded — worse than empty, because it reads as data. See `CacheMeterRegistry` above. +- **`packages/cli`** — `make:firefly-handler` generated code that made the next `firefly:cache` throw and abort + the whole compile; `make:firefly-repository` generated an interface nothing could resolve; + `make:firefly-listener` generated a class the scanner could not discover. Stub tests now generate from each + stub and assert the output is valid PHP *and* discoverable by the relevant scanner. + ## [26.07.18] - 2026-07-28 ### Added diff --git a/README.md b/README.md index 9b3a84d..2b94b72 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,9 @@ final class GreetingController No service-provider boilerplate, no manual route registration: the component scanner finds `GreetingService` and `GreetingController`, the container autowires `GreetingProperties` into the service by constructor type, -and the route scanner compiles `#[GetMapping('/greetings/{name}')]` into the route table — all from one -`php artisan firefly:cache` run. See [Featured Patterns](#featured-patterns) below for the full CQRS, EDA, +and the route scanner compiles `#[GetMapping('/greetings/{name}')]` into the route table. `php artisan +firefly:cache` compiles all of that ahead of time for a reflection-free boot; without it the same scan simply +runs in-process at boot instead, so the app behaves identically either way. See [Featured Patterns](#featured-patterns) below for the full CQRS, EDA, outbox, and security tour, drawn from the runnable `samples/lumen/` wallet-ledger sample. LaraFly is not a fork of Laravel and does not hide it — every package layers cleanly on top of @@ -167,10 +168,11 @@ php artisan firefly:serve ``` `firefly new` wraps `composer create-project firefly/skeleton` (git-init included by default), which already -wires a sample `#[RestController]`/`#[Service]` pair, sqlite for storage, and a `post-create-project-cmd` hook -that ran `firefly:cache` for you — so the app is already booting reflection-free. Re-run `firefly:cache` -yourself any time you add or change a `#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. class, and -`firefly:clear` to fall back to the in-process scanner. See [Installation](#installation) for the +wires a `#[Controller]` welcome page, a sample `#[RestController]`/`#[Service]` pair, sqlite for storage, and a +`post-create-project-cmd` hook that ran `firefly:cache` for you — so the app is already booting +reflection-free. Re-run `firefly:cache` any time you add or change a +`#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. class; `firefly:clear` drops back to the +in-process scan, which is slower but functionally identical. See [Installation](#installation) for the non-global-installer path and [CLI & Project Scaffolding](#cli--project-scaffolding) for the full command reference. @@ -641,13 +643,16 @@ composer create-project firefly/skeleton my-app ``` **Adding LaraFly to an existing Laravel app** — `firefly/firefly` is a `type: metapackage` (the Maven BOM -analogue) that pulls in the whole runtime family with one line, and `firefly/cli` adds the developer console: +analogue) that pulls in the whole runtime family, developer console included, with one line: ```bash composer require firefly/firefly -composer require --dev firefly/cli ``` +The broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`), the browser dashboard +(`firefly/admin`) and the test kit +(`firefly/testing`) stay separate — require them only if you use them. + Point LaraFly at your app's classes and compile it: ```php @@ -672,10 +677,10 @@ php artisan firefly:serve | Command | What it does | |---|---| -| `firefly:cache` | Compiles the app into `bootstrap/cache/firefly/` — DI, routes, config properties, CQRS handlers, event/message listeners, scheduled tasks, security methods, and `#[Transactional]` proxy classes — for a zero-reflection boot. | +| `firefly:cache` | Compiles the app into `bootstrap/cache/firefly/` — DI, routes, `#[ControllerAdvice]` exception handlers, validation constraints, config properties, CQRS handlers, event/message listeners, scheduled tasks, security methods, and `#[Transactional]` proxy classes — for a zero-reflection boot. | | `firefly:clear` | The inverse — deletes `bootstrap/cache/firefly/`; the app falls back to in-process scanning. | | `firefly:about` / `:routes` / `:health` / `:metrics` | Actuator-over-CLI: render the `info`/`env`/`beans`/`conditions`/`mappings` endpoints, the route table, aggregated health, or the metrics snapshot **in-process**, with no HTTP round-trip. | -| `make:firefly-controller` / `-service` / `-component` / `-handler` / `-listener` / `-entity` / `-repository` / `-config-properties` | One generator per stereotype — `--query` on `-handler` scaffolds a `#[QueryHandler]`, `--message` on `-listener` scaffolds a `#[MessageListener]`. | +| `make:firefly-controller` / `-service` / `-component` / `-handler` / `-listener` / `-entity` / `-repository` / `-config-properties` | One generator per stereotype — `--query` on `-handler` scaffolds a `#[QueryHandler]`, `--message` on `-listener` scaffolds a `#[MessageListener]`. `-handler` writes **two** files: the handler and the concrete command/query class its `handle()` takes. | | `firefly:serve` / `firefly:db` | Thin passthroughs to `artisan serve` (or `octane:start` when Octane is installed) and Laravel's own `migrate`/`db:seed`/`migrate:fresh`. | ```bash @@ -701,7 +706,7 @@ its own installable Composer package with its own tests and its own [module guid | Foundation | [Application Context](docs/modules/context.md) — the phased boot engine (`ApplicationContext` port) | `firefly/context` | | Foundation | [Auto-Configuration](docs/modules/starters.md) — `#[Configuration]`/`#[Bean]` starters, conditions | `firefly/autoconfigure` | | Foundation | [Validation](docs/modules/validation.md) — constraint attributes, `#[Valid]`, structured 422s | `firefly/validation` | -| Web & API | [Web Layer](docs/modules/web.md) — `#[RestController]` routing, `RouteManifest` | `firefly/web` | +| Web & API | [Web Layer](docs/modules/web.md) — `#[RestController]`/`#[Controller]` routing, `RouteManifest`, JSON + HTML negotiation | `firefly/web` | | Web & API | [Web Filters](docs/modules/web-filters.md) — the ordered filter chain onto Laravel middleware | `firefly/web` | | Resilience & Scheduling | [Resilience](docs/modules/resilience.md) — retry, circuit breaker, bulkhead, timeout, rate limiter, fallback | `firefly/resilience` | | Resilience & Scheduling | [Scheduling](docs/modules/scheduling.md) — `#[Scheduled]` + distributed locks (cache or Postgres advisory) | `firefly/scheduling`, `firefly/scheduling-postgres` | diff --git a/composer.json b/composer.json index 899e825..8f98232 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", "firefly/observability": "*@dev", + "firefly/openapi": "*@dev", "firefly/resilience": "*@dev", "firefly/scheduling": "*@dev", "firefly/scheduling-postgres": "*@dev", @@ -65,6 +66,7 @@ "Firefly\\Installer\\Tests\\": "packages/installer/tests/", "Firefly\\Messaging\\Tests\\": "packages/messaging/tests/", "Firefly\\Observability\\Tests\\": "packages/observability/tests/", + "Firefly\\OpenApi\\Tests\\": "packages/openapi/tests/", "Firefly\\Resilience\\Tests\\": "packages/resilience/tests/", "Firefly\\Scheduling\\Postgres\\Tests\\": "packages/scheduling-postgres/tests/", "Firefly\\Scheduling\\Tests\\": "packages/scheduling/tests/", diff --git a/deptrac.yaml b/deptrac.yaml index c3d522b..1d6c419 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -90,6 +90,10 @@ deptrac: collectors: - type: directory value: packages/admin/src/.* + - name: OpenApi + collectors: + - type: directory + value: packages/openapi/src/.* - name: Testing collectors: - type: directory @@ -346,6 +350,26 @@ deptrac: - Web - Actuator + # OpenApi is a top-of-stack capability (like Web/Cqrs/Security/Actuator): it generates an OpenAPI 3.1 + # document from manifests that already exist, so it is nearly all READS. It reads Web's RouteManifest/ + # RouteDescriptor (paths, verbs, statuses, binding plans) and Validation's ConstraintManifest + Rule + # objects (body schemas and their `required` lists), and mirrors Kernel's ErrorResponse/ErrorCategory/ + # ErrorSeverity into the shared RFC-9457 problem component. It reads Config, carries Container + # stereotypes + Context boot/condition attributes, extends AutoConfigure's base, and mounts its two + # routes on the illuminate Router from a BootPass (the ActuatorRouteRegistrar precedent) because an + # attribute route cannot carry a configurable path. NO edge to Actuator (the two surfaces are + # independent; the ordering between their registrars is a tie-break, not a dependency) and NO edge to + # Cli (its firefly:openapi command is registered by its OWN provider, so the command ships wherever the + # package does — firefly/cli is require-dev in a real app). NOTHING depends on OpenApi. + OpenApi: + - Kernel + - Container + - Config + - Context + - AutoConfigure + - Validation + - Web + # Testing is the top-of-stack test-support kit: its recording doubles implement every capability # package's frozen ports, so it depends on ALL layers. It is depended on by NONE — consumers use it # only from their tests/, which Deptrac's src-only collectors never see (no cycle; dev-scoped dep). diff --git a/docs/README.md b/docs/README.md index ff69620..78e2e2d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,7 +44,7 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa | Guide | Description | |-------|-------------| -| [Web Layer](modules/web.md) | `firefly/web` — `#[RestController]` routing, parameter binding, RFC-7807 error rendering | +| [Web Layer](modules/web.md) | `firefly/web` — `#[RestController]`/`#[Controller]` routing, parameter binding, JSON + HTML negotiation, RFC-7807 error rendering | | [Web Filters](modules/web-filters.md) | An ordered `WebFilter` chain bridged onto Laravel's own middleware pipeline | ### Resilience & Scheduling diff --git a/docs/cli.md b/docs/cli.md index 8395ede..bfd9240 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,6 +23,7 @@ bootstrap/cache/firefly/ ├── context.php # application-context manifest ├── config-properties.php # #[ConfigProperties] DTOs ├── routes.php # compiled route table +├── exception-handlers.php # #[ControllerAdvice]/#[ExceptionHandler] manifest ├── constraints.php # validation constraint manifest ├── handlers.php # #[CommandHandler]/#[QueryHandler] manifest ├── event-listeners.php # #[EventListener] manifest @@ -34,10 +35,10 @@ bootstrap/cache/firefly/ └── proxies/ # one generated proxy class file per #[Transactional] target ``` -A `FireflyCacheServiceProvider` (auto-discovered) binds these compiled manifests over each capability's -`bound()`-guarded empty default, registers the `#[ConfigProperties]` bindings, and installs a `spl_autoload_register` -classmap loader for the proxy classes — all before any bean resolution runs, giving a fully cached, reflection-free -boot. Two config keys point the boot path at the cache: +A `FireflyCacheServiceProvider` (auto-discovered with `firefly/cli`) `instance()`s these compiled manifests over +whatever the capability packages resolved, registers the `#[ConfigProperties]` bindings, and installs a +`spl_autoload_register` classmap loader for the proxy classes — all before any bean resolution runs, giving a fully +cached, reflection-free boot. Three config keys point the boot path at the cache: ```php 'firefly' => [ @@ -49,9 +50,33 @@ boot. Two config keys point the boot path at the cache: ], ``` -When `component_manifest`/`context_manifest` point at files that exist, `FireflyAutoConfigureServiceProvider` loads -them via `::load()` instead of scanning `firefly.scan.paths` in-process. Without a cache, the app still boots — via -the in-process scanner fallback — just without the zero-reflection guarantee. +### Cached and uncached boots + +Every manifest above is resolved by the same three-step convention, and `Firefly\Context\Scan\AppScan` is the +seam each capability package uses to do it: + +1. the compiled artifact exists under `firefly.cache.path` → load it, zero reflection (production); +2. otherwise `firefly.scan.paths` is non-empty → scan those PSR-4 roots **in-process**, on every boot (development); +3. otherwise → an empty manifest, and boot still succeeds. + +`FireflyAutoConfigureServiceProvider` has always done this for the component and context manifests (via +`component_manifest`/`context_manifest`). It is now also what routes, `#[ControllerAdvice]` handlers, CQRS handlers, +event and message listeners, scheduled tasks, validation constraints, method-security rules, `#[ConfigProperties]` +DTOs and the `#[Transactional]` manifest do — so an application that has never run `firefly:cache` behaves the same +as one that has, and pays a full reflection scan per boot for the privilege. + +That is a change, not a restatement: **before it, step 2 did not exist.** Every capability bound an *empty* +manifest and only `firefly/cli`'s `FireflyCacheServiceProvider` ever replaced it, which made a `require-dev` tool +the sole owner of the loading half of the contract. An app that skipped the compile step — or that installed the +`firefly/firefly` metapackage, which did not require the CLI — booted with no routes (404 on everything it owned) +and, worse, with an empty method-security manifest: both enforcement sites read "no rule for this method" as ALLOW, +so `#[PreAuthorize]`, `#[Secured]` and `#[RolesAllowed]` all failed **open**. `firefly/cli` is +now part of the `firefly/firefly` metapackage, and `firefly.security.method.strict` (default `false`) makes the +strict reading available to anyone who wants a build that ships without a compiled manifest to refuse to boot +rather than run unprotected. + +Compiling is still worth it — reflection-free boot is the point of `firefly:cache` — but it is now an optimisation +rather than a correctness requirement. ## `firefly:clear` @@ -105,12 +130,31 @@ Pyfly's `generate` command family, one Artisan generator per stereotype: | `make:firefly-controller` | A `#[RestController]` with a sample `#[GetMapping]` action, under `app/Http`. | | `make:firefly-service` | A `#[Service]` bean. | | `make:firefly-component` | A `#[Component]` bean. | -| `make:firefly-handler` | A `#[CommandHandler]` by default, or a `#[QueryHandler]` with `--query`. | -| `make:firefly-listener` | An `#[EventListener]` method by default, or a `#[MessageListener]` with `--message`. | +| `make:firefly-handler` | **Two files**: a `#[CommandHandler]` *and* the command class its `handle()` takes (`#[QueryHandler]` + query with `--query`). | +| `make:firefly-listener` | A `#[Component]` class with an `#[EventListener]` method, or a `#[MessageListener]` one with `--message`. | | `make:firefly-entity` | A DDD entity extending `Firefly\Domain\Entity` (there is no `#[Entity]` attribute). | -| `make:firefly-repository` | A repository interface extending `Firefly\Data\Repository\CrudRepository`. | +| `make:firefly-repository` | A concrete `#[Repository]` class extending `Firefly\Data\Repository\EloquentRepository`, with a `$model` to repoint. | | `make:firefly-config-properties` | A `#[ConfigProperties]`-bound configuration DTO. | +Three of those outputs are shaped by what the scanners actually accept, and it is worth knowing why: + +- **The handler generator emits its message class too.** `HandlerScanner` infers a bare `#[CommandHandler]`'s + message type from `handle()`'s sole parameter, and a builtin type (the old stub's `object $command`) cannot be + resolved — it threw `CqrsConfigurationException` out of `firefly:cache`, aborting the *whole* compile. So the + generated `handle()` takes a concrete class, and that class is written alongside it: `RegisterWidgetHandler` + + `RegisterWidget`, `CountWidgetsHandler` + `CountWidgets`. A message file that already exists is left alone and + reported, never overwritten. Nested names stay together (`make:firefly-handler Widget/RegisterWidgetHandler` + puts both in the same sub-namespace). +- **The listener generator puts `#[Component]` on the class.** `#[EventListener]`/`#[MessageListener]` mark a + method of a *bean*; without a stereotype `ComponentScanner::describe()` returns null, the class never reaches + the component manifest, and the wiring pass's `$container->make()` falls through to Illuminate's reflective + auto-build — a plain object outside Firefly's lifecycle, with no `#[Value]` injection, no post-processing and a + new instance per delivery. +- **The repository generator emits a class, not an interface.** Nothing synthesises an implementation for a + repository interface (there is no Spring-Data dynamic proxy here), so the old `interface X extends CrudRepository` + was unresolvable by construction. The generated class is deliberately not `final`, because `firefly:cache` emits + a `#[Transactional]` proxy that `extends` it. + ``` php artisan make:firefly-controller GreetingController php artisan make:firefly-service GreetingService diff --git a/docs/getting-started.md b/docs/getting-started.md index a8b3e44..1999839 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -3,8 +3,8 @@ ## Quickstart: `firefly/skeleton` The fastest way to a booting, cached LaraFly app is the `firefly/skeleton` create-project template — a Laravel 13 -application pre-wired with the Firefly family, a sample `#[RestController]`/`#[Service]` pair, and -`firefly:cache` already wired into `post-create-project-cmd`: +application pre-wired with the Firefly family, a `#[Controller]` welcome page, a sample +`#[RestController]`/`#[Service]` pair, and `firefly:cache` already wired into `post-create-project-cmd`: ```bash composer create-project firefly/skeleton my-app @@ -14,25 +14,27 @@ php artisan firefly:serve ``` `composer create-project` alone already ran `firefly:cache` for you (via `post-create-project-cmd`), so the app -boots reflection-free from the first request; re-run `firefly:cache` yourself whenever you add or change -`#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. classes, and `firefly:clear` to fall back to the -in-process scanner. `firefly:serve` is a thin passthrough to `artisan serve` (or `octane:start` when -`laravel/octane` is installed) — see [CLI](cli.md) for the full command reference. +boots reflection-free from the first request; re-run `firefly:cache` whenever you add or change +`#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. classes. `firefly:clear` drops back to the +in-process scan, which costs a reflection pass per boot but is functionally identical — every manifest +resolves to the compiled artifact if present, otherwise a scan of `firefly.scan.paths`, otherwise empty. + +`firefly:serve` is a thin passthrough to `artisan serve` (or `octane:start` when `laravel/octane` is +installed) — see [CLI](cli.md) for the full command reference. ## Adding LaraFly to an existing Laravel app Pull in the whole runtime family with one line — `firefly/firefly` is a Composer metapackage (the Maven BOM -analogue) that requires every runtime package (`firefly/kernel` through `firefly/observability`): +analogue) that requires every runtime package, `firefly/cli` included, so `firefly:cache` and the +`make:firefly-*` generators are available straight away: ```bash composer require firefly/firefly ``` -Add `firefly/cli` for the developer-experience console (`firefly:cache`, `make:firefly-*`, and friends): - -```bash -composer require --dev firefly/cli -``` +The broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`), the browser dashboard +(`firefly/admin`) and the test kit +(`firefly/testing`) stay separate — require them only if you use them. Then point LaraFly at your app's classes and compile it: @@ -56,10 +58,11 @@ php artisan firefly:serve `firefly:about`/`:routes`/`:health`/`:metrics`, the `make:firefly-*` generator family, and thin `firefly:serve`/`:db` passthroughs. See [CLI](cli.md). - **`firefly/firefly`** — a `type: metapackage` runtime aggregator; `composer require firefly/firefly` pulls the - whole runtime family in one line. + whole runtime family in one line, `firefly/cli` among them. (It is in the metapackage deliberately: while it + was `require-dev`-only, an application that never ran `firefly:cache` booted with empty manifests.) - **`firefly/skeleton`** — a `type: project` Laravel 13 create-project template, pre-wired with the Firefly family - and a sample `#[RestController]`/`#[Service]`, that yields a booting, cached app straight out of - `composer create-project`. + and a sample `#[Controller]`/`#[RestController]`/`#[Service]` slice, that yields a booting, cached app + straight out of `composer create-project`. ## Where to next diff --git a/docs/installation.md b/docs/installation.md index b24d3cc..d47c2ed 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -49,13 +49,16 @@ A runnable Laravel 13 application, already on the cached, zero-reflection boot p - **sqlite** for the database and the **array**/**sync** drivers for cache/queue — zero external services required to boot. -- A sample `#[RestController]` + `#[Service]` pair wired end-to-end, so `php artisan firefly:serve` gives you - a working HTTP endpoint immediately. +- A `#[Controller]` welcome page (the HTML stereotype) plus a sample `#[RestController]` + `#[Service]` pair + wired end-to-end, so `php artisan firefly:serve` gives you a working page and a working JSON endpoint + immediately. - A sample `#[ConfigProperties]` DTO showing typed config binding. -- `bootstrap/cache/firefly/` already populated — every scanner→compiler pair (DI, routes, config, CQRS - handlers, event listeners, scheduled tasks, security methods, `#[Transactional]` proxies) has already run, - so the first request boots with no reflection at all. Re-run `php artisan firefly:cache` whenever you add or - change an annotated class; `php artisan firefly:clear` falls back to the in-process scanner. +- `config/firefly.php` as a full, commented reference of every `firefly.*` key the framework reads. +- `bootstrap/cache/firefly/` already populated — every scanner→compiler pair (DI, routes, exception handlers, + validation constraints, config properties, CQRS handlers, event/message listeners, scheduled tasks, security + methods, `#[Transactional]` proxies) has already run, so the first request boots with no reflection at all. + Re-run `php artisan firefly:cache` whenever you add or change an annotated class; `php artisan + firefly:clear` drops back to the in-process scan, which is slower per boot but functionally identical. ## Next steps diff --git a/docs/laravel-comparison.md b/docs/laravel-comparison.md index 0d82a40..e50ab75 100644 --- a/docs/laravel-comparison.md +++ b/docs/laravel-comparison.md @@ -14,7 +14,7 @@ draws for Python, mapped onto Laravel instead. | Entry point | Service providers registered in `bootstrap/providers.php`, wired by hand | The same providers, plus auto-discovered `AutoConfiguration` classes assembled by a kernel-decided `BootPass` pipeline | | Dependency injection | `app()->bind()`/`app()->singleton()` in a provider's `register()` | `#[Component]`/`#[Service]`/`#[Repository]`/`#[Configuration]` stereotypes on the class itself; compiled component scan resolves constructor dependencies | | Configuration | `config('mail.host')` (array access, untyped) | `#[ConfigProperties]` DTOs bound from a config subtree — typed, fail-fast on a missing/mismatched key | -| HTTP routing | `routes/web.php`/`routes/api.php` route files | `#[RestController]` + verb attributes (`#[GetMapping]`, …), compiled to a `RouteManifest`, still dispatched through native Laravel routes | +| HTTP routing | `routes/web.php`/`routes/api.php` route files | `#[RestController]` (JSON) / `#[Controller]` (HTML) + verb attributes (`#[GetMapping]`, …), compiled to a `RouteManifest`, still dispatched through native Laravel routes | | Validation | `FormRequest::rules()` (array rules) | `#[Valid]` parameter interception over a Bean-Validation-style constraint model, still backed by Laravel's validator | | Transactions | `DB::transaction(fn () => …)` (closure-scoped) | `#[Transactional]` on a class/method — declarative propagation/isolation/rollback rules, manual `beginTransaction`/`commit`/`rollBack` under the hood | | Events | `Event::listen()` / `#[AsEventListener]`-style Laravel listeners, in-process only | Two distinct surfaces: the in-process bus (`#[AsEventListener]`) **and** a broker-backed EDA bus (`#[EventListener]`) — see below | @@ -55,8 +55,10 @@ files remain the source of truth; LaraFly reads them, it doesn't replace them. Laravel routes live in `routes/*.php`, separate from the controller class. `firefly/web`'s `#[RestController]` + `#[GetMapping]`/`#[PostMapping]`/etc. attributes put the route on the controller -method itself; a `RouteScanner` compiles them into a `RouteManifest` at cache time, and that manifest is what -actually registers native Laravel routes at boot — there is no custom dispatch mechanism underneath. See +method itself; a `RouteScanner` compiles them into a `RouteManifest` at cache time (or scans in-process when +there is no cache), and that manifest is what actually registers native Laravel routes at boot — there is no +custom dispatch mechanism underneath. `#[Controller]` is the HTML sibling: same routing, but a returned +`View`/`ModelAndView`/`Htmlable` renders as `text/html` instead of negotiating to JSON. See [Web Layer](modules/web.md). ## Validation: `FormRequest` vs. `#[Valid]` diff --git a/docs/modules/actuator.md b/docs/modules/actuator.md index 7ae895e..7d7177c 100644 --- a/docs/modules/actuator.md +++ b/docs/modules/actuator.md @@ -23,7 +23,8 @@ indicators. A throwing indicator degrades to DOWN — never a 500. ## Exposure & security (recommended) Default `firefly.management.endpoints.web.exposure.include = "health,info"`; sensitive endpoints return **404** until -explicitly exposed. Lock them down with `firefly.security.http.rules` (no second management port — doesn't fit PHP-FPM): +explicitly exposed. An endpoint body is always a JSON **object**: `/actuator/info` with no `InfoContributor` +registered answers `{}`, not `[]`, so a typed client deserialising into a map does not break on the default. Lock them down with `firefly.security.http.rules` (no second management port — doesn't fit PHP-FPM): ```php 'firefly' => [ @@ -59,10 +60,10 @@ endpoint that is reachable at all only once explicitly exposed. ## Configuration (`firefly.management.*`, kebab-case) - `firefly.management.enabled` (default `true`) — master gate -- `firefly.management.endpoints.web.exposure.include` / `.exclude` (CSV or `*`, exclude wins) +- `firefly.management.endpoints.web.exposure.include` / `.exclude` (CSV or `*`; `*` is a wildcard in **both** lists and exclude wins, so `.exclude = "*"` is the kill switch) - `firefly.management.endpoints.web.base-path` (default `/actuator`) - `firefly.management.endpoint.{id}.enabled` (per-endpoint) -- `firefly.management.endpoint.health.show-details` (`never`|`when-authorized`|`always`) +- `firefly.management.endpoint.health.show-details` (default `never`; only the literal `always` shows component details — see Known-latent for `when-authorized`) - `firefly.management.endpoint.health.group.{name}.include` - `firefly.management.endpoint.health.db.enabled` (default `false`) — opt-in `Db` health indicator - `firefly.management.info.app.*`, `firefly.management.info.build.path` diff --git a/docs/modules/configuration.md b/docs/modules/configuration.md index 59a029b..2b64a31 100644 --- a/docs/modules/configuration.md +++ b/docs/modules/configuration.md @@ -17,8 +17,58 @@ $profiles->isActive('prod'); // bool $profiles->all(); // list ``` -`#[Profile('prod')]` marks a component as active only under a given profile (enforced by conditional -registration in a later milestone). +### Where each setting is read from + +`ProfileResolver` consults three sources per setting, in this order, and treats a blank or non-scalar value +at any level as absent: + +1. **`Illuminate\Support\Env`** — the reader behind Laravel's `env()` helper. It sees `$_ENV`, `$_SERVER` + and `putenv()` values, so PHPUnit `` entries, `docker --env`, `php-fpm` `env[]` and a parsed `.env` + all resolve here. A real environment variable is the most specific signal available, so it wins. +2. **The config repository** — `firefly.profiles.active`, then `app.env`. A list is accepted here, because + `['prod', 'eu']` reads far better in a PHP config file than `'prod,eu'`; both spellings converge. +3. **Raw `getenv()`** — last resort, for a process that called `putenv()` after Env's repository was built, + or that runs with no Laravel application at all. + +Reading `getenv()` *only* — which is what this used to do — collapsed profiles to `['default']` in exactly +the two places they matter most. Under `orchestra/testbench` the environment is set on the config repository +and `putenv()` is never called, so a test asserting that a `#[Profile('test')]` bean is registered watched it +silently not be. And under `php artisan config:cache`, Laravel's `LoadEnvironmentVariables` bootstrapper +returns early, so `.env` is never parsed while the cached repository holds the correct `app.env` the whole +time — profiles switched themselves off in production the moment an app followed the deployment guide. + +### `#[Profile]` gating + +`#[Profile('prod')]` on a `#[ConfigProperties]` DTO means the DTO is bound **only** when one of the named +profiles is active. Multiple names are OR, never AND: + +```php +use Firefly\Config\Attributes\ConfigProperties; +use Firefly\Config\Profile\Profile; + +#[Profile('prod', 'staging')] +#[ConfigProperties('payments')] +final readonly class PaymentsProperties +{ + public function __construct(public string $gatewayUrl) {} +} +``` + +The chain is: `ProfileRequirement` reads the attribute **once, at scan time**; `ConfigPropertiesScanner` +records the result on the descriptor; the compiled `config-properties.php` carries it; and `ConfigRegistrar` +skips the binding when the profiles are not active. Nothing reflects a user class at boot to discover the +gate, and an excluded DTO simply **does not exist** — injecting it fails loudly at resolution time rather +than quietly handing back configuration that was meant to be unreachable. + +Until this landed the attribute was pure decoration: it was exported and documented, `grep -rn 'Profile::class' +packages/*/src` matched zero lines of production code, and a class marked `#[Profile('prod')]` was registered +under every profile including the ones the annotation exists to exclude. + +**For a non-DTO bean** — anything that is not `#[ConfigProperties]` — use `firefly/context`'s +`#[ConditionalOnProfile]` instead. It is the same predicate, already wired into `ConditionEvaluator`. +Gating a general `#[Component]` with `#[Profile]` additionally needs `firefly/context` to record the +requirement while it scans, and `firefly/config` sits below Context in the layer graph, so it cannot reach +up to do it. ## Typed access @@ -55,6 +105,43 @@ The DTO is registered as a container singleton bound from `config('mail')`, so i sub-arrays. Discovery compiles to a cached manifest (Octane-safe). The binder sits behind a `ConfigBinder` seam, so a richer binder can be swapped in without touching your DTOs. +### Relaxed binding + +A constructor parameter is **not** matched by its exact name alone. Each one is looked up under four +spellings, in this fixed precedence order — the same relaxed binding Spring Boot performs: + +| # | Spelling | Example for `$dailyTransferLimitMinor` | +|---|---|---| +| 1 | exact parameter name | `dailyTransferLimitMinor` | +| 2 | `snake_case` | `daily_transfer_limit_minor` | +| 3 | `kebab-case` | `daily-transfer-limit-minor` | +| 4 | `SCREAMING_SNAKE_CASE` | `DAILY_TRANSFER_LIMIT_MINOR` | + +The order depends only on the parameter name, never on the iteration order of the config array, so binding +stays deterministic even when an array carries two spellings of the same property at once. Duplicate +spellings collapse — a parameter already written in snake_case yields two candidates, not four. + +This exists because the two worlds otherwise never met. A `config/*.php` file is written by hand in whatever +casing the application's house style prefers, and its values very often arrive from environment variables, +which are `SCREAMING_SNAKE` by convention; a PHP constructor parameter is camelCase because PSR-12 says so. +Matching only the exact name meant `'daily_transfer_limit_minor' => 250000` bound **nothing** onto +`public int $dailyTransferLimitMinor` — and since an unmatched parameter with a default is not an error, the +DTO came out holding the default. No exception, no log line, no failing test, just a wrong limit in +production. This repo's own book shipped exactly that example, which is how the defect was caught. + +Two details worth knowing: + +- **Acronyms survive.** The camelCase → snake_case step breaks a lower-or-digit → upper boundary *and* an + acronym running into a following word, so `$apiURL` becomes `api_url` and `$HTTPProxyHost` becomes + `http_proxy_host` — not `api_u_r_l` and `_h_t_t_p_proxy_host`, which nobody would ever type into a config + file. +- **A present-but-null key does not stop the search.** `'port' => env('MAIL_PORT')` yields `null` when the + variable is unset — the ubiquitous Laravel idiom — so a `null` under the exact name must not mask a real + value written in snake_case. It is read as "not supplied", exactly as `Config::required()` reads it. + +A parameter with no matching key, no default and no nullable type throws a `ConfigurationException` naming +the property, the class, and every key that was tried. + ## `#[Value]` from config With `firefly/config` installed, `#[Value]` injection resolves against config first, then the environment, diff --git a/docs/modules/cqrs.md b/docs/modules/cqrs.md index 6b3d790..2abaa38 100644 --- a/docs/modules/cqrs.md +++ b/docs/modules/cqrs.md @@ -123,8 +123,7 @@ edge): the bridge listens for `DomainEvent`s on the in-process `Context` dispatc |---|---|---| | `firefly.cqrs.default_destination` | `cqrs.events` | Fallback integration-event destination. | | `firefly.cqrs.event_failure_strategy` | `log` | `log` (swallow) or `raise` (re-throw) on a post-commit publish failure. | -| `firefly.cqrs.query.cache_ttl` | _(unset)_ | Reserved for the real query cache (firefly/cache). | -| `firefly.cqrs.enabled` | `true` | Reserved — the auto-configuration is always-on in v1. | +| `firefly.cqrs.query.cache_ttl` | _(unset)_ | Default TTL in seconds passed to `QueryCache::put()` for a `Cacheable` query result. **Unset means "no TTL"**, not zero — so leave the key out unless you want one. The shipped `QueryCache` is `NoOpQueryCache`; a real store arrives with `firefly/cache`. | ## Laravel comparison @@ -140,8 +139,8 @@ edge): the bridge listens for `DomainEvent`s on the in-process `Context` dispatc Read-model / projection scaffolding (→ `firefly/eventsourcing`), real authorization (→ M11), real query cache (→ firefly/cache), CQRS metrics + health (→ M12), attribute-driven command validation, the fluent builder / distributed-tracing ergonomics, and exactly-once outbox durability (→ SP-4) are deferred and -documented honestly. As with `#[Transactional]`/`#[EventListener]`, **app-level `HandlerManifest` compilation -via `firefly:cache` lands in M15** — until then an application (or its test suite) supplies its compiled -manifest inline (run `HandlerScanner::scan()` + bind the resulting `HandlerManifest`) rather than through an -automated cache-warm command; a handler absent from the bound manifest simply never registers. Under -Octane, `CorrelationContext` is per-request state and the `HandlerRegistry` is rebuilt per worker boot. +documented honestly. The `HandlerManifest` needs no hand-wiring: like every other +compiled manifest it resolves to the `firefly:cache` artifact if present, otherwise an in-process scan of +`firefly.scan.paths`, otherwise empty — so an uncached app registers the same handlers a cached one does. A +handler outside `firefly.scan.paths` still never registers. Under Octane, `CorrelationContext` is +per-request state and the `HandlerRegistry` is rebuilt per worker boot. diff --git a/docs/modules/dependency-injection.md b/docs/modules/dependency-injection.md index ce346bc..770949d 100644 --- a/docs/modules/dependency-injection.md +++ b/docs/modules/dependency-injection.md @@ -55,6 +55,25 @@ $container->getByName('spanish'); // SpanishGreeter $container->getAll(Greeter::class); // all implementations, sorted by #[Order] ``` +`#[Qualifier]` also works **on an injected parameter**, which is how you ask for a specific bean without +going through `getByName()`: + +```php +final class Notifier +{ + public function __construct( + #[Qualifier('spanish')] private readonly Greeter $greeter, + ) {} +} +``` + +It rides `Illuminate\Contracts\Container\ContextualAttribute`, the same seam `#[Value]` uses, so it adds +no reflection that was not already happening and leaves the compiled manifest shape untouched. It works on +`#[Bean]` factory-method parameters as well as constructors. A name that is not registered throws a +`ConfigurationException` naming the qualifier — it does not fall back to the type. (Parameter qualifiers were +declared but read by nothing until recently: `#[Qualifier('redisCache')] Cache $cache` silently received +whatever `Cache::class` resolved to.) + ## `#[Order]` `#[Order]` sets list precedence (lower first, Spring convention). `getAll()` returns implementations sorted by it. @@ -67,8 +86,7 @@ already resolves bindings lazily by default, so there is nothing extra to defer ## `#[Bean]` factory methods -A `#[Configuration]` class exposes `#[Bean]` methods; each is registered under its return type, with parameters -injected: +A component exposes `#[Bean]` methods; each is registered under its return type, with parameters injected: ```php use Firefly\Container\Attributes\{Bean, Configuration}; @@ -81,6 +99,56 @@ final class AppConfig } ``` +`#[Bean]` methods are collected from **any** component — `#[Configuration]`, a user-defined stereotype that +extends it, and plain `#[Component]`/`#[Service]`/`#[Repository]` classes (Spring's "lite mode"). Discovery +uses the same `IS_INSTANCEOF` rule as every other stereotype check; it does not compare attribute short +names, which used to make `#[Bean]` methods on an `ApiConfiguration extends Configuration` disappear from the +manifest while the class itself was still bound. + +### Two or more `#[Bean]` methods returning the same type + +!!! warning "Breaking change" + Two non-`#[Primary]` `#[Bean]` methods returning the same type now **throw at registration**. They + previously booted, and one of the two beans silently did not exist. + +Per return type: + +- **One bean produces the type** (the common case): unchanged. The factory is bound on the return type, and + the name, if any, is aliased to it — the type and the name resolve to the same singleton. +- **Several beans produce the type**: each is bound under its **own name key**, so every one is individually + resolvable, and the bare type key becomes an **alias** of the `#[Primary]` winner. An alias, never a second + binding — a second binding of the same factory would quietly mint a second "singleton". +- **Several beans, no `#[Primary]`**: the type key is bound to a guard that throws a `ConfigurationException` + naming every candidate. The type stays *bound*, so `#[ConditionalOnMissingBean]` still sees that a bean of + that type exists; leaving it unbound would let a concrete return type silently auto-wire past every + `#[Bean]` factory. + +Four shapes are rejected at registration time, where the stack trace still points at the manifest rather +than at some unlucky consumer: + +| Rejected | Why | +|---|---| +| Competing beans where one or more is **anonymous** | An unnamed bean is reachable only through its return type, which its competitors already claim — it could never be resolved. | +| Competing beans **sharing one name** | A bean name is a container key; the second would silently overwrite the first. | +| Competing beans where one is **named after the type itself** | That name *is* the group's type key, claimed by the `#[Primary]` winner or the guard. | +| **More than one `#[Primary]`** for a type | `#[Primary]` names the single default; at most one candidate may carry it. | + +What this replaces: names were previously only ever recorded as `alias($returns, $name)`, and an alias is a +pointer to a key rather than a binding of its own. Two `#[Bean]` methods returning the same type therefore +collapsed — both names pointed at the one type key, which held whichever factory registered last, so +`getByName('memoryCache')` and `getByName('redisCache')` handed back the identical object. `#[Primary]` could +not break the tie because it was read nowhere in the bean path at all. + +**To migrate**, give each competing method a distinct name and mark one primary: + +```php +#[Bean('memoryCache')] #[Primary] +public function memoryCache(): Cache { /* … */ } + +#[Bean('redisCache')] +public function redisCache(): Cache { /* … */ } +``` + ## `#[Value]` injection Inject configuration and expressions into constructor parameters: diff --git a/docs/modules/eda-brokers.md b/docs/modules/eda-brokers.md index 03921d0..8f440dc 100644 --- a/docs/modules/eda-brokers.md +++ b/docs/modules/eda-brokers.md @@ -61,7 +61,7 @@ provider selected the whole package resolves nothing and requires nothing. | `firefly.eda.postgres.connection` | the default DB connection | The named Laravel connection the outbox publisher/consumer/relay use — must be `pgsql` for `pg_notify`/`LISTEN` to activate (any other driver, e.g. `sqlite` in tests, silently skips the NOTIFY optimization and falls back to polling). | | `firefly.eda.postgres.channel` | `firefly_eda_events` | The `LISTEN`/`NOTIFY` channel name and the outbox row's `channel` column value. | | `firefly.eda.postgres.max_attempts` | `3` | How many `nack()`s (in-process consumer) or failed relay attempts an outbox row tolerates before it is marked `FAILED`. | -| `firefly.eda.postgres.relay.downstream_provider` | *(unset)* | **OPTIONAL.** `rabbitmq`\|`kafka` — when set, `firefly:outbox:relay` forwards `PENDING` rows to that distinct downstream broker. When unset, the relay command is a documented no-op and delivery is entirely the terminal in-process consumer's job. | +| `firefly.eda.postgres.relay.downstream_provider` | *(unset)* | **OPTIONAL.** Selects the relay's downstream: the shipped aliases `rabbitmq`\|`kafka`, the class-string of any `EventPublisher`, or a bound container id. When set, `firefly:outbox:relay` forwards `PENDING` rows there; when unset the command refuses to run with an error naming this key, and delivery is entirely the terminal in-process consumer's job. An app that binds its own publisher under the container id `firefly.eda.relay.downstream` may leave this key unset — that binding is checked first. Resolution is validated when the relay command runs (not at boot), so a misconfiguration fails before a single row is claimed instead of at the first publish. Whatever it resolves to, a `PostgresEventPublisher` is **refused**: it would re-insert `PENDING` rows into the same outbox. | ### Kafka (`firefly/eda-kafka`) @@ -181,10 +181,21 @@ Kafka — just the natural insertion order of one table). ### (b) The optional relay — `firefly:outbox:relay` -`firefly:outbox:relay` is a **distinct, optional** path that fronts a **different downstream broker** — -set `firefly.eda.postgres.relay.downstream_provider=rabbitmq|kafka` to enable it. When that key is unset, -running the command is a documented no-op (it logs and exits `SUCCESS` immediately): terminal delivery -via `firefly:eda:consume` is assumed instead. +`firefly:outbox:relay` is a **distinct, optional** path that fronts a **different downstream broker**. Enable +it by setting `firefly.eda.postgres.relay.downstream_provider` — to a shipped alias (`rabbitmq`/`kafka`), to +an `EventPublisher` class-string, or to the id of a binding you supply — or by binding your own publisher +under the container id `firefly.eda.relay.downstream` (checked first, so the key may then stay unset). + +With neither configured, running the command **fails** with a console error naming the key and the available +aliases, and exits `FAILURE`. That is deliberate: an operator who starts the relay expects rows to move, and a +silent success would look like a working relay that delivers nothing. If you did not mean to front a second +broker, simply do not run the command — `provider=postgres` already delivers committed rows in-process via +`firefly:eda:consume`. + +The downstream is resolved **when the command runs**, not at boot, and a bad value is reported before a single +row is claimed. Boot-time validation was considered and rejected: a downstream bound in another provider's +`boot()` may not exist yet when the check would run, so it would fail correctly-configured applications — and +it would fail every web request and every unrelated artisan command over a relay-only concern. `OutboxRelay::relayBatch()` claims a batch of `PENDING` rows (`FOR UPDATE SKIP LOCKED` on pgsql, inside a short transaction so concurrent relay workers never double-claim; a plain per-row `WHERE id=? AND @@ -194,7 +205,8 @@ status='PENDING'` guard on drivers without row locking), publishes each through Both the command and `OutboxRelay`'s constructor **refuse a `PostgresEventPublisher` as the downstream** — that would re-INSERT `PENDING` rows into the very same outbox, an infinite loop — throwing a `LogicException` -/ printing a clear error instead. The relay is for genuinely bridging to a *different* broker (e.g. you want +/ printing a clear error instead. The refusal applies however the downstream was named: alias, class-string +or container binding. The relay is for genuinely bridging to a *different* broker (e.g. you want Kafka as your public-facing bus but still want the same-tx outbox guarantee for the write); it is not an alternative in-process delivery mechanism. diff --git a/docs/modules/eda.md b/docs/modules/eda.md index e5a5cbe..740b2cf 100644 --- a/docs/modules/eda.md +++ b/docs/modules/eda.md @@ -70,7 +70,9 @@ encodes. ### `InMemoryEventBus` — the default -A `SubscriberRegistry` (pattern → handler pairs) delivered to in subscription order via `fnmatch()`. +A `SubscriberRegistry` (pattern → handler pairs) delivered to in subscription order via `fnmatch()` — and +subscription order is the manifest's `order` order, because `EventListenerWiringPass` subscribes listeners +sorted by `#[EventListener(order:)]`. `publish()` builds the envelope and calls `deliver()` **synchronously** — every matching handler runs before `publish()` returns. `start()`/`stop()` are no-ops. Zero external services; this is the skeleton default (`firefly.eda.provider` unset or `memory`). @@ -124,10 +126,14 @@ several patterns (or several separately-ordered annotations) at once. - **`EventListenerWiringPass`** runs at `BootPhase::WiringPasses` (1000), in *every* process — web request and queue worker alike. For each manifest row it wraps the target invocation in `RetryingEventHandler` (config-driven retries/delay + the bound `DeadLetterStore`) and calls `$bus->subscribe($pattern, $wrapped)` - for each of the descriptor's patterns. The invoking closure resolves the target bean **fresh from the - container on every dispatch** — never cached at registration — so it always observes the fully - post-processed (possibly proxied) bean, exactly like `RegisterEventListenersPass` does for the in-process - surface. + for each of the descriptor's patterns. It iterates `EventListenerManifest::ordered()` — descriptors sorted + by their declared `order` **ascending** (lower first, the `#[Order]` convention used throughout the + framework), ties keeping compiled-manifest order — so `#[EventListener(order:)]` genuinely determines + dispatch order. (It used to iterate `all()`, so the parameter round-tripped through the manifest and was + then discarded: ordering was whatever the scanner happened to emit.) The invoking closure resolves the + target bean **fresh from the container on every dispatch** — never cached at registration — so it always + observes the fully post-processed (possibly proxied) bean, exactly like `RegisterEventListenersPass` does + for the in-process surface. ## Retry / DLQ model @@ -186,18 +192,20 @@ bytes on a real wire. | Key | Type | Default | Meaning | |---|---|---|---| -| `firefly.eda.provider` | `memory`\|`queue` | `memory` | Selects `InMemoryEventBus` or `QueueEventBus` (`EdaAutoConfiguration`). | +| `firefly.eda.provider` | `memory`\|`queue`\|`rabbitmq`\|`postgres`\|`kafka` | `memory` | `EdaAutoConfiguration` selects `InMemoryEventBus` or `QueueEventBus`; the three broker values are honoured by the adapter packages (see [EDA brokers](eda-brokers.md)) and read here as "not queue". | | `firefly.eda.serialization_format` | string | `json` | Selects `Serializer`; anything but `json` throws `SerializationException`. | | `firefly.eda.retries` | int | `0` | Handler-level retry count applied to every `#[EventListener]` by `EventListenerWiringPass`. | | `firefly.eda.retry_delay` | float (seconds) | `0.0` | Linear-backoff base delay (`retry_delay * attempt`). | | `firefly.eda.queue.connection` | string\|null | `null` (default connection) | Queue connection `QueueEventBus`/`DispatchEventJob` dispatch onto, when `provider=queue`. | | `firefly.eda.queue.name` | string\|null | `null` (default queue) | Queue name, when `provider=queue`. | +| `firefly.eda.destinations` | list\ | `[]` | The broker destinations `php artisan firefly:eda:consume` binds when `--destination` is not passed. Read **only** by that command; it must be a list of strings or the command throws a `ConfigurationException`. | -!!! note "`destination` is a call-site argument, not a config key" +!!! note "A publish `destination` is a call-site argument, not a config key" `EventPublisher::publish(string $destination, ...)` takes the destination explicitly at the call site — - it is not read from configuration. The test suite and capstones use `'firefly.events'` by convention as - an app-facing default destination name, but no shipped code binds or reads a - `firefly.eda.destinations` config key; nothing in `EdaAutoConfiguration` or the wiring pass consults one. + it is never read from configuration, and neither `EdaAutoConfiguration` nor the wiring pass consults a + key for it. `firefly.eda.destinations` above is the *consumer* side: which destinations to subscribe to, + used by the consume command alone. `--destination` on the command line overrides it, so one compiled + config can still serve several workers. Pick whatever destination string suits your application. `EdaAutoConfiguration` (`#[Configuration] #[Order(1000)]`) binds the `EventPublisher`, `Serializer`, and @@ -253,11 +261,10 @@ These are carried-forward, documented limitations of the M9 shipment — not bug - **"Async" is queue-backed, not coroutine-based.** Under the `sync` queue driver (or with no worker running), `QueueEventBus` delivers synchronously, identically to `InMemoryEventBus`. Genuine asynchronous, cross-process delivery requires a running queue worker (`queue:work`, Horizon, …). -- **App-level `#[EventListener]` manifests compile via `firefly:cache` — landing in M15.** Until then, an - application supplies its compiled `EventListenerManifest` inline (bind it directly, or hand-run - `EventListenerScanner` + the manifest compiler) rather than through an automated cache-warm command; a - listener method absent from the compiled manifest silently never subscribes — the same compile-inline - caveat as web routes and scheduled tasks. +- **A listener outside `firefly.scan.paths` never subscribes.** The `EventListenerManifest` resolves to the + `firefly:cache` artifact if present, otherwise an in-process scan of `firefly.scan.paths`, otherwise empty + — so no hand-wiring is needed, but a listener the scan cannot see is silently absent rather than an error. + Run `firefly:cache` in production for the reflection-free path. - **`firefly/eda` and `firefly/messaging` are independent sibling packages** — see [Messaging § Sibling of `firefly/eda`](messaging.md#sibling-of-fireflyeda-no-shared-code) for why there is no dependency in either direction. diff --git a/docs/modules/messaging.md b/docs/modules/messaging.md index 926f414..305257c 100644 --- a/docs/modules/messaging.md +++ b/docs/modules/messaging.md @@ -223,9 +223,9 @@ These are carried-forward, documented limitations of the M9 shipment — not bug - **"Async" is queue-backed, not coroutine-based** — under the `sync` queue driver (or with no worker running), `QueueMessageBroker` delivers synchronously (minus the group-drop above), same async→sync contract as the rest of LaraFly. -- **App-level `#[MessageListener]` manifests compile via `firefly:cache` — landing in M15.** Until then, an - application supplies its compiled `MessageListenerManifest` inline rather than through an automated - cache-warm command; a consumer method absent from the compiled manifest silently never subscribes — the - same compile-inline caveat as `firefly/eda`'s listeners. +- **A consumer outside `firefly.scan.paths` never subscribes.** The `MessageListenerManifest` resolves to + the `firefly:cache` artifact if present, otherwise an in-process scan of `firefly.scan.paths`, otherwise + empty — no hand-wiring needed, but a listener the scan cannot see is silently absent rather than an error. + Same shape as `firefly/eda`'s listeners. - **`firefly/messaging` and `firefly/eda` are independent sibling packages** with no dependency in either direction — see [Sibling of `firefly/eda`](#sibling-of-fireflyeda-no-shared-code) above. diff --git a/docs/modules/observability.md b/docs/modules/observability.md index 4fc5427..79d1325 100644 --- a/docs/modules/observability.md +++ b/docs/modules/observability.md @@ -14,13 +14,52 @@ flag, secure-by-default-on, zero boot reflection. calls with the same identity return the same instance. - `MetricsRecorder` — the narrow write-facing port instrumentation actually depends on (`increment()`, `record()`, `setGauge()`), so callers never need the full registry. -- `SimpleMeterRegistry` — the shipped in-memory implementation of **both** ports. `Counter` (monotonic), `Gauge` - (pull-based, backed by a `callable(): float` supplier — sampled at *read* time, not write time), `Timer` (count + - total-seconds, exposed as a Prometheus summary — **no** histogram buckets/percentiles yet). +- `SimpleMeterRegistry` — the shipped in-memory implementation of **both** ports, and the default. `Counter` + (monotonic), `Gauge` (pull-based, backed by a `callable(): float` supplier — sampled at *read* time, not write + time), `Timer` (count + total-seconds, exposed as a Prometheus summary — **no** histogram buckets/percentiles yet). +- `CacheMeterRegistry` — the cross-process implementation of both ports, bound instead when + `firefly.observability.metrics.store` names a cache store. See [Surviving the request](#surviving-the-request) + below. - A metric **name** has exactly one type, globally: registering the same name under a different `MeterType` (e.g. `counter('foo')` then `gauge('foo', ...)`) throws `InvalidArgumentException` — Prometheus scopes one `# TYPE` line per name, so a silent type conflict would emit invalid exposition. +## Surviving the request + +`SimpleMeterRegistry` keeps every meter in process memory. That is correct for a long-lived worker (Octane, +RoadRunner) and wrong for PHP's usual deployment: under PHP-FPM each request is a fresh process, so by the time a +scrape reaches `/actuator/metrics` or `/actuator/prometheus`, the only meters in memory are the ones that scrape's +own request recorded. The endpoints were effectively empty in production — and the numbers they *did* show were a +single request's, which is worse than empty, because it reads as data. + +Naming a cache store swaps in `CacheMeterRegistry`, which writes through to that store: + +- `increment()` and `record()` use the store's **atomic increment**, so concurrent workers cannot lose writes on a + driver that supports it (redis, memcached, apc, dynamodb). Durations accumulate in integer **microseconds**, + because `increment()` is integer-only and a float read-modify-write would drop samples under concurrency. +- `setGauge()` is a plain `put()`: a gauge is a snapshot, so last-writer-wins is the correct semantic. +- `meters()` rehydrates `Counter`/`Timer`/`Gauge` from a single index of every meter identity ever written, so it + costs one read rather than a key scan — which not every cache driver supports. +- Tag order never splits a meter in two; identities sort their tags. + +```php +// config/firefly.php +'observability' => [ + 'metrics' => [ + 'store' => env('FIREFLY_METRICS_STORE', ''), // '' = in-process SimpleMeterRegistry + 'ttl' => 0, // seconds; 0 = no expiry + ], +], +``` + +It is **opt-in** rather than the default on purpose: a metrics registry that silently starts writing to whatever +cache an application happens to have configured is a surprise, and on the `array` driver it would be no better +than memory anyway. + +The documented boundary: the factory methods (`counter()`/`timer()`/`gauge()`) still hand back the **in-process** +meters, and mutating one of those directly stays process-local. Everything the framework itself records goes +through the `MetricsRecorder` methods, which are the durable path. + ## Exposition - `/actuator/prometheus` (`PrometheusEndpoint`) — pure-PHP Prometheus text-exposition format 0.0.4. Names/labels are @@ -99,6 +138,8 @@ seam above. | Key | Default | Meaning | |---|---|---| | `firefly.observability.metrics.enabled` | `true` | Master gate. Binds `MeterRegistry`/`MetricsRecorder`/`PrometheusTextFormat`/the real `CqrsMetrics`, and survives on the endpoints + `MetricsFilter`. Disabled → `NoOpMetricsRecorder`, the M10 `NoOpCqrsMetrics` stays bound, no `MeterRegistry`, `/prometheus`+`/metrics` unmounted. | +| `firefly.observability.metrics.store` | `''` | Names a **cache store**. Empty (or no `cache` binding) → the in-process `SimpleMeterRegistry`; a store name → `CacheMeterRegistry` over `cache()->store($name)`, keyed under `firefly:metrics:`. | +| `firefly.observability.metrics.ttl` | `0` | Expiry in seconds for each cache-backed meter. `0` or less means no expiry. Only consulted when `store` is set. | | `firefly.resilience.circuit-breaker.*` | _(unset)_ | Read by `MeterBindingsPass` (not owned by this package) — one named instance here gets one `resilience_circuit_breaker_state{name}` gauge. | ## Laravel comparison @@ -117,8 +158,12 @@ seam above. span overhead; nothing downstream needs to change when the adapter lands. - **Histogram buckets / percentiles** — `Timer` only exposes as a Prometheus *summary* (`_count`/`_sum`); no `histogram_quantile`-friendly buckets yet. -- **Multiprocess aggregation** — `SimpleMeterRegistry` is a single-process, in-memory store; under PHP-FPM/Octane - with multiple workers, each process/worker exposes only its own counters (no shared-memory or Redis aggregation - layer, unlike `prometheus_client`'s APCu/Redis adapters). +- **Multiprocess aggregation is opt-in, and partial.** `firefly.observability.metrics.store` gives counters, + timers and set-gauges cross-process totals through the cache (see [Surviving the + request](#surviving-the-request)); without it, `SimpleMeterRegistry` exposes only the calling process's own + meters. Two limits remain even with a store: the `counter()`/`timer()`/`gauge()` factory objects stay + process-local, and a pull-based gauge registered by `MeterBindingsPass` is sampled in whichever process serves + the scrape (which is the correct semantic for `php_memory_peak_bytes`, and the only possible one for a live + circuit-breaker read). - **A second Octane management-port listener** — deferred alongside `firefly/actuator`'s own known-latent (no second management port; doesn't fit PHP-FPM). An SP-7 option for Octane deployments. diff --git a/docs/modules/resilience.md b/docs/modules/resilience.md index 2f286b1..3deed54 100644 --- a/docs/modules/resilience.md +++ b/docs/modules/resilience.md @@ -49,17 +49,25 @@ return [ 'aggressive' => ['max-attempts' => 5, 'wait-duration' => '250ms', 'backoff-multiplier' => 2.0], ], 'circuit-breaker' => [ - 'payments' => ['failure-threshold' => 5, 'wait-duration-in-open' => '30s'], + 'payments' => [ + 'failure-threshold' => 5, + 'minimum-number-of-calls' => 5, + 'wait-duration-in-open' => '30s', + 'half-open-probe-timeout' => '30s', + ], ], 'rate-limiter' => [ 'api' => ['max-tokens' => 10, 'refill-rate' => 10.0, 'timeout' => '100ms'], ], 'bulkhead' => [ - 'db' => ['max-concurrent' => 20, 'max-wait' => '50ms'], + 'db' => ['max-concurrent' => 20, 'max-wait' => '50ms', 'permit-ttl' => '30s'], ], 'time-limiter' => [ 'payments' => ['timeout' => '2s'], ], + + // Not a pattern: how long any cache-backed pattern waits for the shared-state mutex. + 'store' => ['lock-block-timeout' => '500ms'], ], ]; ``` @@ -92,19 +100,45 @@ Exhausting `max-attempts` rethrows the last exception unchanged — `Retry` neve A CLOSED/OPEN/HALF_OPEN breaker over a bounded window of recent outcomes. CLOSED trips to OPEN once the window accumulates `failure-threshold` failures (or, when `failure-rate-threshold` is set, once a *full* -window's failure ratio reaches it). OPEN rejects every call with `CircuitBreakerOpenException` +window's failure ratio reaches it) — but never before `minimum-number-of-calls` outcomes have accumulated. +OPEN rejects every call with `CircuitBreakerOpenException` (`Firefly\Kernel\Exception\Infrastructure\CircuitBreakerOpenException`) until `wait-duration-in-open` has elapsed, then admits up to `half-open-max-calls` probe calls; a probe success closes the breaker fresh, a probe failure re-opens it. +The whole state — the phase, the outcome window, the open timestamp and the outstanding probe permits — +lives in **one** store record, and every transition runs inside `ResilienceStore::withLock()`, so the +read-decide-write is atomic and a trip on one FPM worker is visible to the next. + | Key | Type | Default | Meaning | |---|---|---|---| | `failure-threshold` | int | `5` | Failures within the window before tripping (ignored when `failure-rate-threshold` is set). | | `failure-rate-threshold` | float\|null | `null` | Failure ratio (0..1) over a *full* window that trips instead of a raw count. | | `window-size` | int | `10` | Size of the sliding outcome window. | +| `minimum-number-of-calls` | int | `0` | Statistical floor: the window must hold at least this many outcomes before the breaker may trip. Clamped to `window-size` (a larger value could never be reached, and the failure mode of a config typo must be "still protected", not "silently unprotected"). | | `wait-duration-in-open` | duration | `30s` | How long OPEN rejects before allowing a HALF_OPEN probe. | | `half-open-max-calls` | int | `1` | Probe calls admitted per HALF_OPEN episode. | -| `record-on` | list\\> | `[Throwable::class]` | Only these exceptions count as failures; anything else propagates without affecting the breaker's state. | +| `half-open-probe-timeout` | duration | `30s` | Lease length of a half-open probe permit. An expired permit is pruned before permits are counted, so a probe whose worker died does not consume a slot forever. `0` disables permit holding entirely. | +| `record-on` | list\\> | `[Throwable::class]` | Only these exceptions count as failures; anything else propagates without affecting the breaker's state — and explicitly *returns* the probe permit it took, so an ignored exception leaves the episode exactly as it found it. | + +#### Probe permits are leases, and `state()` reports the effective state + +Two details of the HALF_OPEN phase are worth knowing, because both fix behaviour earlier versions got +wrong and both are observable: + +- **A probe permit expires.** `half-open-max-calls` is not a counter that only a success or a failure can + reset; each admitted probe takes a permit stamped `now + half-open-probe-timeout`, and expired permits + are pruned before the budget is counted. A worker killed mid-probe (OOM, deploy `SIGKILL`, fatal error) + therefore costs one slot for at most that long instead of wedging the breaker in HALF_OPEN forever, + rejecting 100% of traffic to a dependency that is perfectly healthy. Set `half-open-probe-timeout` + above the longest legitimate probe. +- **`state()` reports the state `admit()` *would* decide, not the one last written.** The OPEN → HALF_OPEN + transition is lazy — it is driven by the next call, and no timer fires it — so a breaker that opened and + then went idle keeps `open` in its record indefinitely even though its wait window elapsed long ago and + the very next call would be admitted as a probe. Every read-only observer (the actuator gauge, the + `resilience_circuit_breaker_state` metric, an operator) would otherwise be told a dependency is hard-down + when it is merely quiet. `state()` computes the answer as a pure read and does not write the transition + back, so it stays safe to poll at any frequency. ### RateLimiter @@ -122,17 +156,28 @@ callers that want to check admission without invoking a callable. ### Bulkhead -A concurrency semaphore: `acquire()` (called by `call()` on entry, released in `finally`) atomically -increments a shared permit counter and backs off immediately if it exceeds `max-concurrent`, so two racing -callers can never both slip past the limit. `max-wait` briefly polls for a freed permit before rejecting with -`Firefly\Resilience\Exception\BulkheadFullException` (a `firefly/resilience` infrastructure exception — the -kernel package is frozen, so this one exception lives in `firefly/resilience` itself rather than the kernel; -the other five patterns reuse shipped kernel exceptions verbatim). +A distributed concurrency semaphore: `acquire()` (called by `call()` on entry, released in `finally`) takes +a permit and backs off when `max-concurrent` are already held. `max-wait` briefly polls for a freed permit +before rejecting with `Firefly\Resilience\Exception\BulkheadFullException` (a `firefly/resilience` +infrastructure exception — the kernel package is frozen, so this one exception lives in `firefly/resilience` +itself rather than the kernel; the other five patterns reuse shipped kernel exceptions verbatim). + +**Permits are expiring leases, not a counter.** The permit set is a list of expiry timestamps in one store +record, pruned on every read and mutated inside `ResilienceStore::withLock()`; `release()` gives back a lease +*this* instance actually holds (a per-object LIFO), so an unmatched `release()` is a no-op rather than a way +to manufacture capacity. That is what makes the bulkhead crash-safe: a worker killed between `acquire()` and +`release()` runs no `finally`, and with a plain counter its permit was lost permanently — after +`max-concurrent` crashes the bulkhead rejected everything until an operator flushed the cache. The trade-off +is the mirror image: a call slower than `permit-ttl` has its lease reclaimed while it is still running, so +one extra caller may join. Bounded, transient over-admission beats unbounded, permanent capacity loss — set +`permit-ttl` above the longest legitimate guarded call (pairing the bulkhead with a `TimeLimiter` makes that +bound explicit rather than hopeful). | Key | Type | Default | Meaning | |---|---|---|---| | `max-concurrent` | int | `10` | Concurrent permits allowed. | | `max-wait` | duration | `0` | How long to poll for a freed permit before rejecting (`0` = fail fast). | +| `permit-ttl` | duration | `60s` | Lease length of one permit, and the TTL of the permit-set record itself (refreshed on every write). An expired lease is reclaimed, which is the self-heal for a holder that died. | ### TimeLimiter @@ -187,9 +232,9 @@ enforced ordering; compose the nesting that matches the semantics you want. ## Cache-backed state -`CircuitBreaker`, `RateLimiter`, and `Bulkhead` are **stateful across calls** — a breaker's outcome window, a -bucket's token count, a bulkhead's permit count — and PHP-FPM shares nothing between requests, so that state -cannot live on the pattern object. It lives behind the `Firefly\Resilience\Store\ResilienceStore` port +`CircuitBreaker`, `RateLimiter`, and `Bulkhead` are **stateful across calls** — a breaker's outcome window and +probe leases, a bucket's token count, a bulkhead's permit leases — and PHP-FPM shares nothing between +requests, so that state cannot live on the pattern object. It lives behind the `Firefly\Resilience\Store\ResilienceStore` port instead, keyed `firefly:resilience::`, and every transition that reads-then-writes runs inside `ResilienceStore::withLock()` so concurrent FPM workers never race a compare-and-set. @@ -200,6 +245,28 @@ injected `Illuminate\Contracts\Cache\Repository`. `ResilienceRegistry` itself is `resilience` config section still boots a working, empty registry — install the package and it's live with no required configuration. +### The mutex wait budget + +`withLock()` separates two numbers that are easy to conflate: + +- **How long the mutex is *held*** once taken. Every pattern passes 5 seconds — pure headroom over a section + that is one read plus one write, so a worker killed inside it leaves a self-expiring lock rather than a + tombstone. Not configurable, because it is a property of the critical section, not a policy. +- **How long a caller *waits* to take it**, which *is* a policy and *is* configurable: + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `firefly.resilience.store.lock-block-timeout` | duration | `0.5` (500ms) | How long a resilience primitive waits for the shared-state mutex before failing fast. | + +Exhausting the budget raises `Firefly\Kernel\Exception\Infrastructure\ServiceUnavailableException` — a 503 +with the retryable error code `RESILIENCE_STORE_LOCK_TIMEOUT`, which is the honest description of "the shared +state store is too contended to answer right now". + +It is exposed rather than hardcoded because the right answer depends on the driver: an array store or a local +Redis hands the mutex over in microseconds, while a database-backed cache across an availability zone can +legitimately need tens of milliseconds. The value applies to the `CacheResilienceStore` bean the auto-config +builds; a store you bind yourself owns its own policy. + `Retry`, `TimeLimiter`, and `Fallback` are stateless and never touch the store. ## Known-latent @@ -225,8 +292,9 @@ These are carried-forward, documented limitations of the M7 shipment — not bug cross-request behaviour. The `null` cache driver's locks are no-ops (`withLock()` degrades to running the callback without a critical section whenever the driver isn't a `LockProvider`), so it must not be used in production for these patterns either. -- **`CircuitBreaker` records have no idle TTL.** A breaker's cache record (state, window, open timestamp) is - written with no expiry, so an idle key (a payment integration nobody calls for a month) lingers in the - cache store indefinitely rather than being reclaimed. This is inert (no functional impact — the next call - simply reads whatever state is there) but is a known, un-bounded cache-growth characteristic worth knowing - about for capacity planning. +- **`CircuitBreaker` and `RateLimiter` records have no idle TTL.** Their cache records are written with no + expiry, so an idle key (a payment integration nobody calls for a month) lingers in the cache store + indefinitely rather than being reclaimed. This is inert — the next call simply reads whatever state is + there — but it is a known, un-bounded cache-growth characteristic worth knowing about for capacity + planning. `Bulkhead` is the exception: its permit-set record carries `permit-ttl` and is refreshed on + every write, so it disappears once nothing has touched the bulkhead for a full lease. diff --git a/docs/modules/scheduling.md b/docs/modules/scheduling.md index e0a5904..5a58c2f 100644 --- a/docs/modules/scheduling.md +++ b/docs/modules/scheduling.md @@ -183,7 +183,7 @@ These are carried-forward, documented limitations of the M7 shipment — not bug Laravel `Event` — no initial-delay offset is set. Laravel's frequency DSL has no native way to express "run once after an initial delay, then resume the normal cadence", so this is deferred to **SP-5** alongside the cron shims above. (`zone` **is** applied — see `#[Scheduled]` above.) -- **The app's `ScheduledManifest` compiles via `firefly:cache` — landing in M15.** Until then, an - application supplies its compiled manifest inline (bind `ScheduledManifest` yourself, e.g. from a hand-run - `ScheduledScanner` + `ScheduledManifestCompiler`, or bind descriptors directly) rather than through the - automated cache-warm command the framework will eventually ship. +- **A `#[Scheduled]` method outside `firefly.scan.paths` never registers.** The `ScheduledManifest` resolves + to the `firefly:cache` artifact if present, otherwise an in-process scan of `firefly.scan.paths`, otherwise + empty — so no hand-wiring is needed, but a task the scan cannot see is silently absent rather than an + error. diff --git a/docs/modules/security.md b/docs/modules/security.md index 248cb00..ea1d717 100644 --- a/docs/modules/security.md +++ b/docs/modules/security.md @@ -40,7 +40,9 @@ model. - **Method:** `#[PreAuthorize('…')]`, `#[Secured('…')]`, `#[RolesAllowed('…')]`. A single `MethodSecurityScanner` compiles them into a `var_export` manifest (the sole reflection site). Enforced at the CQRS bus (real `Command`/`QueryAuthorizer`), the controller dispatcher (`ControllerSecurityGuard`), and imperatively - (`AuthorizationChecker`). The scanner rejects, at compile (cache) time, any `#[Secured]`/`#[RolesAllowed]` + (`AuthorizationChecker`). Method security is **additive, not a second deny-by-default gate** — see + [Method security fails open on an empty manifest](#method-security-fails-open-on-an-empty-manifest). The + scanner rejects, at compile (cache) time, any `#[Secured]`/`#[RolesAllowed]` role/authority value containing a single quote — even one that would otherwise compile into *grammar-valid* expression text (e.g. a value that splices in `or permitAll()`) — so a malicious or malformed attribute value can never widen access silently; it fails the `firefly:cache`-equivalent scan step loudly instead. @@ -57,6 +59,56 @@ model. likewise reject a role/authority value containing a single quote, for the same expression-injection reason as method security above — a legitimate role/authority string never needs one. +### Method security fails open on an empty manifest + +Both enforcement sites (`MethodSecurityMessageEnforcer::enforce()` and the controller guard) read "no rule +recorded for this method" as **ALLOW**. That is correct — method security adds rules on top of URL security, +it is not a second gate — but it makes an *empty* `SecurityMethodManifest` indistinguishable from an +application that declares no rules at all. An empty manifest silently disables every `#[PreAuthorize]`, +`#[Secured]` and `#[RolesAllowed]` in the app, with nothing logged. + +The manifest is therefore resolved like every other compiled manifest: **the artifact `firefly:cache` wrote if +it exists, otherwise an in-process scan of `firefly.scan.paths`, otherwise empty**. An uncached app enforces +the same rules a cached one does. (Before that fallback existed, only `firefly/cli` — a `require-dev` package +that was not in the `firefly/firefly` metapackage — ever bound the compiled rules, so a production install +could run entirely unguarded.) + +`firefly.security.method.strict` (default `false`) closes the remaining hole: with it on, a boot that finds no +compiled artifact **refuses to start** rather than falling back to the scan. Set it in any image that runs +`firefly:cache` — it converts "someone forgot the compile step" from silently unguarded handlers into a +startup failure, and it is the only defence against a build that ships without the manifest. + +### Expression evaluation is re-entrant + +`SecurityExpressionEvaluator` is a singleton whose recursive-descent parse state lives on the instance, and +`hasPermission()` is the one dispatch path that calls **application** code — a user-supplied +`PermissionEvaluator`, which is a documented extension point and may evaluate an expression of its own on the +same singleton. The inner call used to overwrite the outer parse state; on return the outer parse resumed +against the inner token stream, saw EOF, and returned the inner result — silently discarding every term after +`hasPermission(...)`. `hasPermission(#id, 'read') and hasRole('ADMIN')` returned **true** for a principal +holding no authorities at all: a fail-open, not a fail-closed. The parse state is now saved and restored +around `evaluate()`/`parse()` in a `finally`, so a throwing inner evaluator cannot strand torn state either. + +### The config access vocabulary is fixed, and fail-closed + +The fluent `HttpSecurity` DSL (`permitAll()`, `denyAll()`, `authenticated()`, `hasRole()`, `hasAuthority()`) +compiles to the same expression grammar method security uses. The **config** spelling in +`firefly.security.http.rules` does not accept that grammar — `HttpSecurity::fromConfig()` maps a fixed set of +tokens: + +| `access` value | Compiled expression | +|---|---| +| `permitAll` | `permitAll()` | +| `denyAll` | `denyAll()` | +| `authenticated` | `isAuthenticated()` | +| `hasRole:` | `hasRole('')` | +| `hasAuthority:` | `hasAuthority('')` | + +**Anything else compiles to `denyAll()`.** So `hasRole('ADMIN')` — the expression spelling — is not a valid +config access spec, and a rule written that way locks the path down instead of opening it. That direction is +deliberate: an unrecognised spec must fail closed. Interpolated role/authority values containing a single +quote are rejected outright (expression injection). + ## Web hardening - `CsrfFilter` — stateless double-submit cookie (safe-method + path exemptions, constant-time compare). @@ -78,20 +130,30 @@ also enabled — pair it with `http.enabled` + master, or with method security, | Key | Default | Meaning | |---|---|---| | `firefly.security.enabled` | `false` | Master flag — enables the core stack (password encoder, user store, role hierarchy, permission evaluator, expression evaluator, authentication manager, `AuthorizationChecker`, CQRS authorizers, `AuditorAware`). Required by the `http` surface flag below (its filter depends on master-gated beans); `jwt`/`oauth2.resource_server`/`csrf`/`headers` do not require it. | -| `firefly.security.users` | _(unset)_ | `InMemoryUserDetailsService` map (`{username: {password, authorities, enabled, locked}}`). | +| `firefly.security.method.strict` | `false` | Refuse to boot when no compiled method-security manifest exists, instead of falling back to the in-process scan. See [above](#method-security-fails-open-on-an-empty-manifest). Independent of the master flag — the manifest binding is registered whether or not security is enabled. | +| `firefly.security.users` | _(unset)_ | `InMemoryUserDetailsService` map (`{username: {password, authorities, enabled, locked}}`). `password` is the **encoded** string, typically `{id}`-prefixed; `authorities` defaults to `[]`, `enabled` to `true`, `locked` to `false`. | | `firefly.security.role_hierarchy` | `[]` | Single-arrow implication rules, e.g. `["ROLE_ADMIN > ROLE_USER"]` (one implication per entry — not chainable in one string). | | `firefly.security.jwt.enabled` | `false` | Enables `JwtAuthenticationFilter` + `JwtService` (refuses a weak secret at boot). Independent of the master flag. Mutually exclusive with `oauth2.resource_server.enabled` (refused at boot). | | `firefly.security.jwt.secret` | _(required when jwt.enabled)_ | HMAC signing secret (≥ 32 bytes, no placeholders). | +| `firefly.security.jwt.algorithm` | `HS256` | HMAC algorithm passed to `JwtService`. | +| `firefly.security.jwt.leeway` | `0` | Clock-skew leeway in seconds when validating `exp`. | | `firefly.security.jwt.authorities_claim` | `authorities` | Claim carrying the authority list. | | `firefly.security.oauth2.resource_server.enabled` | `false` | Enables the JWKS resource-server filter. Independent of the master flag. Mutually exclusive with `jwt.enabled` (refused at boot). | | `firefly.security.oauth2.resource_server.jwks_uri` | _(required when enabled)_ | Issuer JWKS URI (cached). | -| `firefly.security.oauth2.resource_server.issuer` | _(unset)_ | Expected `iss` claim. When set, a token whose `iss` doesn't match is rejected (confused-deputy protection, RFC 9700). | -| `firefly.security.oauth2.resource_server.audience` | _(unset)_ | Expected `aud` claim (checked against a string or array `aud`, per RFC 7519). When set, a token whose `aud` doesn't include it is rejected. | +| `firefly.security.oauth2.resource_server.cache_ttl` | `3600` | Seconds the fetched JWKS is cached for. | +| `firefly.security.oauth2.resource_server.issuer` | `''` | Expected `iss` claim. When set, a token whose `iss` doesn't match is rejected (confused-deputy protection, RFC 9700); empty skips the check. | +| `firefly.security.oauth2.resource_server.audience` | `''` | Expected `aud` claim (checked against a string or array `aud`, per RFC 7519). When set, a token whose `aud` doesn't include it is rejected; empty skips the check. | +| `firefly.security.oauth2.resource_server.authorities_claim` | `roles` | Claim carrying the authority list (distinct from the local-JWT default). | | `firefly.security.http.enabled` | `false` | Enables the deny-by-default `HttpSecurityFilter`. **Requires the master flag** (see above). | -| `firefly.security.http.rules` | `[]` | Ordered `{pattern, access}` URL rules. | +| `firefly.security.http.rules` | `[]` | Ordered `{pattern, access}` URL rules — see [the access vocabulary](#the-config-access-vocabulary-is-fixed-and-fail-closed). With the filter on, an empty list denies **everything** — deny-by-default is the point. | | `firefly.security.csrf.enabled` | `false` | Enables the double-submit CSRF filter. Independent of the master flag. | | `firefly.security.csrf.except` | `[]` | Path globs exempt from CSRF. | -| `firefly.security.headers.enabled` | `false` | Enables the security-headers filter (all values overridable). Independent of the master flag. | +| `firefly.security.headers.enabled` | `false` | Enables the security-headers filter. Independent of the master flag. | +| `firefly.security.headers.hsts` | `max-age=31536000; includeSubDomains` | `Strict-Transport-Security`. | +| `firefly.security.headers.frame_options` | `DENY` | `X-Frame-Options`. | +| `firefly.security.headers.content_type_options` | `nosniff` | `X-Content-Type-Options`. | +| `firefly.security.headers.referrer_policy` | `no-referrer` | `Referrer-Policy`. | +| `firefly.security.headers.csp` | `default-src 'self'` | `Content-Security-Policy`. | ## Laravel comparison @@ -109,7 +171,7 @@ The OAuth2 authorization-server, OAuth2 client/login, and real IdP adapters (Key are deferred to their own future SP-cycle packages (matching the Java 20-repo topology). Generalising `#[PreAuthorize]` to **any** bean method (a second interceptor composed into the M8 transaction proxy) is a flagged P0 spike, not in M11 — method security here is enforced only at the CQRS bus, the controller dispatcher, and the imperative -`AuthorizationChecker`. As with the other capability modules, **app-level manifest compilation via `firefly:cache` -lands in M15**; until then an application (or its test suite) supplies its compiled `SecurityMethodManifest` inline -(run `MethodSecurityScanner::scan()` + bind the result). Under Octane the `SecurityContextHolder` is per-request state -cleared by every auth filter on exit. +`AuthorizationChecker`. The `SecurityMethodManifest` needs no hand-wiring: it is resolved from the +`firefly:cache` artifact, else an in-process scan, else empty (with `firefly.security.method.strict` available +to refuse the last case). Under Octane the `SecurityContextHolder` is per-request state cleared by every auth +filter on exit. diff --git a/docs/modules/transactional.md b/docs/modules/transactional.md index eb07bfa..eaa5406 100644 --- a/docs/modules/transactional.md +++ b/docs/modules/transactional.md @@ -176,20 +176,18 @@ $template->execute($work, new TransactionalDescriptor( ## Known-latent -- **App-level `#[Transactional]` proxy classes + manifests compile via `firefly:cache` (M15) — this has not - shipped yet.** Out of the box, the shipped `DataAutoConfiguration` (`#[Order(1000)]`) binds an **empty** - `TransactionalManifest` (`#[ConditionalOnMissingBean]`), so `#[Transactional]` proxies **nothing** in a - freshly-installed application until it runs `firefly:cache`. When M15 lands, `firefly:cache` MUST emit — as - **one matched unit** — the compiled `TransactionalManifest`, the generated - `{Target}__FireflyTransactionalProxy` classes (autoloaded), **and** a manifest-loader bean: a - `#[Configuration]` `#[Bean]` at `#[Order]` **less than** `1000` that calls `TransactionalManifest::load()` on - the compiled manifest file, so that loaded manifest wins `#[ConditionalOnMissingBean]` ahead of - `DataAutoConfiguration`'s empty default. Until M15 ships, tests wire all three of these inline (scan with - `TransactionalScanner::scan()`, generate/require proxies with `ProxyClassGenerator`, and bind the resulting - `TransactionalManifest` directly) exactly as `firefly:cache` will. This fails **loud**, not silently: - `TransactionalBeanPostProcessor` throws a `ConfigurationException` if the manifest promises a proxy for a - class whose generated proxy class isn't loaded — so a half-emitted cache fails at boot rather than quietly - running unproxied. +- **The manifest and its proxies must stay one matched unit — and they now are, on both boot paths.** + `DataAutoConfiguration::transactionalManifest()` resolves the compiled `transactional.php` if + `firefly:cache` wrote one (registering the `proxies.php` classmap autoloader first, so `firefly/cli` is not + required at runtime), otherwise scans `firefly.scan.paths` and materialises each + `{Target}__FireflyTransactionalProxy` per process through `ProxyMaterializer` — a private `0700` directory + written with `O_EXCL`, dev-time cost only. Proxies are made loadable **before** the manifest is handed out, + because `TransactionalBeanPostProcessor` throws a `ConfigurationException` when the manifest promises a + proxy class it cannot find; a half-emitted cache therefore fails at boot rather than quietly running + unproxied. Until this landed, the auto-config bound an unconditional empty manifest and *nothing* loaded + the compiled `transactional.php`, so `#[Transactional]` was a **silent no-op** in any application that did + not hand-write its own manifest configuration — which is precisely what the skeleton's + `app/Support/CachedTransactionalConfiguration.php` existed to do, and why it has been deleted. - **The proxy's state-copy cannot see state private to a non-framework parent of the proxied class.** `ProxyFactory`'s scoped closure copies `get_object_vars()` visible from `$declaredClass`'s own scope; state declared `private` on some class *above* `$declaredClass` in its inheritance chain is invisible to it. A diff --git a/docs/modules/validation.md b/docs/modules/validation.md index e0ea7e8..f1045d4 100644 --- a/docs/modules/validation.md +++ b/docs/modules/validation.md @@ -13,9 +13,57 @@ $validated = $validator->validate( ``` On failure it throws the kernel's `ValidationException` (HTTP 422, `errorCode` `VALIDATION_ERROR`) carrying one -`FieldError` per failed field message, each with the rejected value. The web layer (M6) renders these as -RFC-7807. Method-parameter `#[Valid]` interception on a `#[RequestBody]` DTO is also an M6/web concern; in M5, -`#[Valid]` is an inert marker. +`FieldError` per failed field message, each with the rejected value. `firefly/web` renders these as RFC-7807, +and it is `firefly/web` that performs the `#[Valid]` interception on a `#[RequestBody]` DTO — this package +owns the constraints and the primitive, not the HTTP plumbing. + +## Constraint attributes + +Declare constraints on a DTO's promoted constructor parameters (or properties) and let `ConstraintScanner` +compile them into the manifest `BeanValidator` reads: `#[NotNull]`, `#[NotEmpty]`, `#[NotBlank]`, `#[Min]`, +`#[Max]`, `#[Size]`, `#[Digits]`, `#[Pattern]`, `#[Email]`, `#[Past]`, `#[Future]`, `#[AssertTrue]`, +`#[AssertFalse]`, `#[Positive]`, `#[PositiveOrZero]`, `#[Negative]`, `#[NegativeOrZero]`, plus the +domain-shaped `#[Iban]`, `#[Bic]`, `#[Swift]`, `#[Isin]`, `#[Cusip]`, `#[RoutingNumber]`, `#[Luhn]`, +`#[CurrencyCode]`, `#[CountryCode]`, `#[LanguageTag]`, `#[UuidValue]`, `#[Phone]`, `#[PostalCode]`, +`#[Percentage]`, `#[Money]`, `#[DecimalScale]`. + +### `null` is valid for every constraint except `#[NotNull]` + +Jakarta's null contract, honoured literally: rejecting `null` is `@NotNull`'s single job (and that of the +constraints subsuming it, `#[NotEmpty]`/`#[NotBlank]`), and every other constraint short-circuits to "valid" +on null, so an optional field never has to be spelled "email-or-null". `ConstraintScanner` implements that by +prepending Laravel's `nullable` flag to a nullable property's compiled rule list at **compile** time. + +The one exception is a rule **object** whose whole purpose is to have an opinion about null: rule objects are +never implicit to Illuminate, so `nullable` would silently disable them. Such a rule implements +`Firefly\Validation\Rule\NullAware` (Firefly's own `NotNull` does) and keeps firing. A present-but-null +value used to fail *every* constraint on the property rather than only `@NotNull`. + +### `#[Size]` always means length + +`#[Size]` is a length/size constraint, always, whatever else is declared on the same property. It used to emit +Laravel's `min:`/`max:`/`between:` strings, whose meaning `Validator::getSize()` decides at runtime from the +property's **other** rules — value semantics when a sibling contributes `numeric`, size semantics otherwise. +Pairing `#[Size]` with `#[Min]`/`#[Max]`/`#[Digits]`/`#[Positive]` (all of which emit `numeric`) therefore +turned a length check into a magnitude check with no warning. It now wraps a first-party +`Firefly\Validation\Rule\Size` that measures the value and never reads the sibling list. An unbounded +`#[Size]` (neither `min` nor `max`) contributes nothing. + +### `#[Rules]` — the escape hatch, and `Compilable` + +`#[Rules]` attaches any Laravel rule string or `ValidationRule` object directly, for a rule with no bespoke +constraint attribute. Because the compiled manifest is a `var_export`ed array literal, a rule object cannot be +written into it; it is stored as `['@rule' => Class, 'args' => [...]]` and rebuilt with +`new $class(...$args)` at load. + +`ConstraintManifestCompiler` recovers `args` automatically for the ordinary PHP 8 shape — every constructor +parameter promoted to a property — since promotion guarantees a property mirrors each parameter. A rule that +is **not** promotion-shaped (it normalises its input, renames, or does not keep a value) must implement +`Firefly\Validation\Rule\Compilable` and declare its arguments itself; the values must be `var_export`-safe +(null, scalars, enums, or arrays of those). A rule that is neither is **rejected at compile time** with an +actionable `ConfigurationException` — it is never silently rehydrated with defaults at boot, which is what +used to happen (`new StartsWith('ACME')` compiled to `['@rule' => StartsWith::class]` and booted as +`new StartsWith()`). ## Domain rules diff --git a/docs/modules/web.md b/docs/modules/web.md index 05a1b5c..f862907 100644 --- a/docs/modules/web.md +++ b/docs/modules/web.md @@ -1,9 +1,9 @@ # Web Layer -`firefly/web` is LaraFly's HTTP layer: `#[RestController]` routing compiled to a `RouteManifest`, -parameter binding with `#[Valid]` interception, JSON-native content negotiation, and RFC-7807 error -rendering — all dispatched through native Laravel routes, inside the real HTTP-kernel middleware -pipeline. +`firefly/web` is LaraFly's HTTP layer: `#[RestController]`/`#[Controller]` routing compiled to a +`RouteManifest`, parameter binding with `#[Valid]` interception, content negotiation (JSON for data, HTML +for views), and RFC-7807 error rendering — all dispatched through native Laravel routes, inside the real +HTTP-kernel middleware pipeline. ![Request lifecycle](../assets/diagrams/request-lifecycle.svg) @@ -43,6 +43,48 @@ final class AccountsController The `RouteScanner` (routing metadata) and the component scan (DI wiring) are two separate passes over the same class — a `#[RestController]` never has to declare its own route registration. +## `#[Controller]` — the HTML stereotype + +`#[Controller]` is to `#[RestController]` what Spring's `@Controller` is to `@RestController`: same routing, +different intent. It **extends** `#[RestController]`, so `RouteScanner`'s `IS_INSTANCEOF` filter finds it with +no scanner change, its routes compile into the same `RouteManifest`, and constructor DI is identical. What +differs is what the method returns and how the response is built. + +```php +use Firefly\Web\Attributes\{Controller, GetMapping}; +use Firefly\Web\View\ModelAndView; +use Illuminate\Contracts\View\View; + +#[Controller] +final class WelcomeController +{ + #[GetMapping('/', name: 'welcome')] + public function index(): View + { + return view('welcome', ['name' => 'Ada']); // rendered as text/html + } + + #[GetMapping('/about')] + public function about(): ModelAndView + { + return ModelAndView::of('about', ['version' => '1.0'])->withStatus(200); + } +} +``` + +`ModelAndView` is a view **name** plus its model, resolved through the application's view factory. It exists +for a handler that should not reach for the `view()` helper — one under test, or one in a package that must +not depend on `illuminate/view` — and carries `of()`, `withModel()`, `withStatus()` and `withHeader()`. If no +view factory is bound, returning one fails loudly rather than rendering nothing. + +Two deliberate boundaries: + +- **A bare `string` return is *not* a view name.** `#[RestController]` methods legitimately return strings + that must negotiate to JSON, and the meaning of a return value must not depend on the class that declares + it. Explicit beats magic. +- **A `#[Controller]` may still return an array or a DTO**, which negotiates to JSON exactly as before — the + same latitude Spring gives a `@Controller` method carrying `@ResponseBody`. + ## `#[RequestMapping]` and the verb mappings `#[RequestMapping(path: '...')]` is class-level and prepends a base path to every method mapping on the @@ -121,11 +163,26 @@ final class CreateAccountRequest ## Content negotiation -Content negotiation is JSON-native: the only shipped `MessageConverter` is `JsonMessageConverter` -(`application/json` and any `+json` suffix type). A controller return value that is not already a -`Response`/`Responsable` is written by the converter chosen from the request's `Accept` header — parsed -for q-values with a header-order tiebreak — falling back to the first (JSON) converter when nothing -matches or `Accept` is absent. Request bodies are read the same way, keyed off `Content-Type`. +`ResponseFactory` decides what to do with a controller's return value in this order: + +| Return value | Response | +|---|---| +| A Symfony or Illuminate `Response` (including `JsonResponse`) | passed through untouched | +| A `Responsable` | `toResponse($request)` | +| A `ModelAndView` | resolved through the view factory, rendered `text/html; charset=UTF-8` | +| A `View` or any `Renderable` | `render()`, rendered as `text/html; charset=UTF-8` | +| An `Htmlable` | `toHtml()`, rendered as `text/html; charset=UTF-8` | +| Anything else (array, `JsonSerializable`, `Arrayable`, scalar) | written by the negotiated `MessageConverter` | + +The HTML arms are why a server-rendered page is possible at all. Before they existed, a Blade `View` was +neither a `SymfonyResponse` nor a `Responsable`, so it fell through to the converter chain and was +`json_encode`d — and because a `View` exposes no public properties, **every returned view became the body +`{}` with HTTP 200 and `Content-Type: application/json`**, silently. + +Data negotiation is JSON-native: the only shipped `MessageConverter` is `JsonMessageConverter` +(`application/json` and any `+json` suffix type). The converter is chosen from the request's `Accept` +header — parsed for q-values with a header-order tiebreak — falling back to the first (JSON) converter when +nothing matches or `Accept` is absent. Request bodies are read the same way, keyed off `Content-Type`. `MessageConverterRegistry` is an ordinary container binding (guarded `#[ConditionalOnMissingBean]`-style via `if (! $app->bound(...))`), so an application can register additional `MessageConverter`s — XML @@ -142,12 +199,11 @@ descriptor and hands each one a dispatch closure (`ControllerDispatcher`) — so generation, and Laravel's own route-caching machinery all apply to LaraFly routes unmodified. !!! note "Known-latent: manifest compilation, config ordering, and negotiation scope" - - **App manifests compile inline today.** `RouteManifest`/`ConstraintManifest` are meant to be - produced ahead of time by `firefly/cli`'s `firefly:cache` command (M14/M15). Until that command - ships, an application must compile its own `RouteScanner`/`ConstraintManifestCompiler` output and - bind the resulting `RouteManifest`/`ConstraintManifest` instances itself (exactly what the - package's own capstone test does); `WebServiceProvider` only binds empty defaults so the package - boots standalone. + - **App manifests need no hand-wiring.** `RouteManifest`, `ConstraintManifest` and + `ExceptionHandlerRegistry` are each resolved through `Firefly\Context\Scan\AppScan`: the artifact + `firefly:cache` compiled if it exists, otherwise an in-process scan of `firefly.scan.paths`, otherwise + empty. An application therefore never has to compile and bind these itself — it did have to before the + scan fallback existed, and until then an uncached app 404'd every route it owned. - **`route:cache` interplay.** Because dispatch runs through ordinary native Laravel routes, Laravel's own `route:cache` works unmodified once those routes are registered — there is no separate LaraFly route cache to keep in sync with it. diff --git a/docs/versioning.md b/docs/versioning.md index 64588ba..4b02105 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -20,7 +20,7 @@ The single place the current version *is* asserted in code is: // packages/kernel/src/Version.php final class Version { - public const string VERSION = '26.07.16'; + public const string VERSION = '26.07.18'; } ``` @@ -28,14 +28,17 @@ final class Version endpoint. Consistency across the three human-visible surfaces that *should* always agree with it — the `Version::VERSION` constant, the CHANGELOG's latest `## [x.y.z]` heading, and the README version badge — is enforced by `tests/VersionConsistencyTest.php`, which fails the build the moment any of the three drifts from -the others. A release always updates all three together. +the others. A release always updates all three together. Work merged between releases therefore accumulates +under a `## [Unreleased]` heading in the CHANGELOG — the test reads the first *versioned* heading, so an +unreleased section is invisible to it and the constant stays the single source of truth until the release is +actually cut. ## Reading the version at runtime ```php use Firefly\Kernel\Version; -echo Version::VERSION; // "26.07.16" +echo Version::VERSION; // "26.07.18" ``` This is the only version string LaraFly itself exposes; there is no runtime version-detection mechanism diff --git a/packages/cli/src/Command/ServeCommand.php b/packages/cli/src/Command/ServeCommand.php index 3164757..b552e1d 100644 --- a/packages/cli/src/Command/ServeCommand.php +++ b/packages/cli/src/Command/ServeCommand.php @@ -4,6 +4,7 @@ namespace Firefly\Cli\Command; +use Firefly\Context\Scan\AppScan; use Illuminate\Console\Command; use Laravel\Octane\Octane; @@ -11,6 +12,16 @@ * Thin passthrough to `artisan serve` (or `octane:start` when laravel/octane is installed) — * reimplements nothing. laravel/octane is an OPTIONAL runtime dependency: probed via class_exists() * only, never required by this package's composer.json. + * + * Before delegating it prints the URL and the BOOT MODE. The boot mode is the single most useful fact + * about a running LaraFly app and it was previously invisible: an app whose manifests are compiled reads + * routes, handlers, listeners, scheduled tasks and method-security rules straight off bootstrap/cache/ + * firefly with zero reflection, whereas an app without them re-scans firefly.scan.paths on every boot. + * The two behave identically until they do not — a stale compiled artifact serves the routes you compiled, + * not the ones you just wrote — and "why is my new #[GetMapping] 404ing" is exactly the question this line + * answers. AppScan::cachedFile() is the same probe the framework itself uses to choose between the two + * (see Firefly\Context\Scan\AppScan), so the report can never disagree with the boot it describes; + * firefly/cli already requires firefly/context, so reading it costs no new dependency. */ final class ServeCommand extends Command { @@ -18,14 +29,67 @@ final class ServeCommand extends Command protected $signature = 'firefly:serve {--host=127.0.0.1} {--port=8000}'; /** @var string */ - protected $description = 'Thin passthrough to artisan serve (or octane:start when laravel/octane is installed).'; + protected $description = 'Thin passthrough to artisan serve (or octane:start when laravel/octane is installed), reporting the URL and whether the app booted compiled or scanned.'; public function handle(): int { - $params = ['--host' => $this->option('host'), '--port' => $this->option('port')]; + $host = $this->stringOption('host', '127.0.0.1'); + $port = $this->stringOption('port', '8000'); + $octane = class_exists(Octane::class); - return class_exists(Octane::class) + $this->report($host, $port, $octane); + + $params = ['--host' => $host, '--port' => $port]; + + return $octane ? $this->call('octane:start', $params) : $this->call('serve', $params); } + + private function report(string $host, string $port, bool $octane): void + { + $compiled = AppScan::cachedFile($this->laravel, AppScan::ROUTES); + + $this->newLine(); + $this->line(' URL http://'.$this->reachableHost($host).':'.$port.''); + $this->line(' Runtime '.($octane ? 'Octane (octane:start)' : 'PHP dev server (artisan serve)')); + $this->line(' Boot '.($compiled !== null + ? 'compiled — '.$this->relative($compiled) + : 'scanned — no compiled manifests; run `php artisan firefly:cache` to compile')); + $this->newLine(); + } + + /** + * The host to PRINT, which is not always the host to BIND. `--host=0.0.0.0` (or `::`) is the usual way + * to expose the dev server to a container host or a phone on the LAN, but those are wildcard bind + * addresses: pasting http://0.0.0.0:8000 into a browser is a coin flip across platforms. The bind + * address passed to serve/octane is left exactly as the user typed it; only the printed link is + * rewritten to something a browser will actually open. + */ + private function reachableHost(string $host): string + { + return match ($host) { + '0.0.0.0', '::', '[::]' => '127.0.0.1', + default => $host, + }; + } + + /** + * Trim the application base path off an absolute artifact path so the line stays readable in a narrow + * terminal. Falls back to the absolute path when the file lives outside the project (a configured + * `firefly.cache.path` may). + */ + private function relative(string $path): string + { + $base = rtrim($this->laravel->basePath(), '/').'/'; + + return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; + } + + private function stringOption(string $name, string $fallback): string + { + $value = $this->option($name); + + return is_string($value) && $value !== '' ? $value : $fallback; + } } diff --git a/packages/cli/tests/Command/ServeCommandTest.php b/packages/cli/tests/Command/ServeCommandTest.php new file mode 100644 index 0000000..f0561f8 --- /dev/null +++ b/packages/cli/tests/Command/ServeCommandTest.php @@ -0,0 +1,131 @@ +set('firefly.cache.path', $dir); + + return $dir; +} + +it('prints the URL it is about to serve', function () { + /** @var PassthroughCommandsTestCase $this */ + stubOctaneStart(); + + ArtisanAssertions::outputContains($this->artisan('firefly:serve'), 0, 'http://127.0.0.1:8000'); +}); + +it('prints the URL for an explicit host and port', function () { + /** @var PassthroughCommandsTestCase $this */ + stubOctaneStart(); + + ArtisanAssertions::outputContains( + $this->artisan('firefly:serve', ['--host' => '192.168.1.5', '--port' => '9001']), + 0, + 'http://192.168.1.5:9001', + ); +}); + +/** + * 0.0.0.0 is a bind address, not an address a browser can open. The server still binds the wildcard — + * only the printed link is rewritten — so the container/LAN use case keeps working while the link stays + * clickable. + */ +it('prints a browsable link for the 0.0.0.0 wildcard bind', function () { + /** @var PassthroughCommandsTestCase $this */ + stubOctaneStart(); + + ArtisanAssertions::outputContains( + $this->artisan('firefly:serve', ['--host' => '0.0.0.0']), + 0, + 'http://127.0.0.1:8000', + ); +}); + +it('reports a scanned boot when no compiled manifests exist', function () { + /** @var PassthroughCommandsTestCase $this */ + config()->set('firefly.cache.path', sys_get_temp_dir().'/fserve-absent-'.bin2hex(random_bytes(6))); + stubOctaneStart(); + + ArtisanAssertions::outputContains($this->artisan('firefly:serve'), 0, 'scanned'); +}); + +it('tells a scanned app how to compile itself', function () { + /** @var PassthroughCommandsTestCase $this */ + config()->set('firefly.cache.path', sys_get_temp_dir().'/fserve-absent-'.bin2hex(random_bytes(6))); + stubOctaneStart(); + + ArtisanAssertions::outputContains($this->artisan('firefly:serve'), 0, 'firefly:cache'); +}); + +it('reports a compiled boot when the routes manifest is on disk', function () { + /** @var PassthroughCommandsTestCase $this */ + $dir = compiledCacheDir(); + stubOctaneStart(); + + try { + ArtisanAssertions::outputContains($this->artisan('firefly:serve'), 0, 'compiled'); + } finally { + exec('rm -rf '.escapeshellarg($dir)); + } +}); + +it('names the artifact that put it in compiled mode', function () { + /** @var PassthroughCommandsTestCase $this */ + $dir = compiledCacheDir(); + stubOctaneStart(); + + try { + ArtisanAssertions::outputContains($this->artisan('firefly:serve'), 0, AppScan::ROUTES); + } finally { + exec('rm -rf '.escapeshellarg($dir)); + } +}); + +it('still returns the delegated command exit code', function () { + /** @var PassthroughCommandsTestCase $this */ + stubOctaneStart(); + + ArtisanAssertions::exitCode($this->artisan('firefly:serve'), 0); +}); diff --git a/packages/context/src/Pass/BeanBindingKeys.php b/packages/context/src/Pass/BeanBindingKeys.php new file mode 100644 index 0000000..e09e96d --- /dev/null +++ b/packages/context/src/Pass/BeanBindingKeys.php @@ -0,0 +1,113 @@ +returns`, and for a + * long time that was right — the registrar bound each bean under its return type and treated the + * #[Bean] name as a mere alias of it. It stopped being right when firefly/container learned + * #[Primary]/#[Qualifier] for #[Bean] methods. The registrar's rule now (see + * ContainerRegistrar::registerBeans(), which is the authority this class mirrors): + * + * - ONE #[Bean] method produces the type — the overwhelmingly common case, unchanged: the factory + * is bound on the RETURN TYPE and the name, if any, is aliased to it. Return type is the key. + * - SEVERAL produce it (a CONTESTED type): each competitor is bound under its OWN #[Bean] NAME, + * and the bare type key becomes either an ALIAS of the #[Primary] winner or — with no + * #[Primary] — a factory that throws a NoUniqueBeanDefinition-style ConfigurationException. + * The name is the key; the return type is a key belonging to NO individual bean. + * + * Keying on `$bean->returns` regardless produced three distinct silent failures, one per pass: + * EagerSingletonsPass built only the #[Primary] winner (or crashed boot outright when there was + * none), RegisterBeanPostProcessorsPass extended only the winner's binding so every sibling + * escaped the BeanPostProcessor chain, and RegisterEventListenersPass invoked a contested type's + * listener through the type key, hitting the winner (or the throwing guard) rather than the bean + * that declared it. All three now ask this class instead. See CompetingBeansPassTest, which pins + * every one of them end-to-end through the REAL registrar. + * + * WHY THE COUNT IS TAKEN FROM THE SAME DESCRIPTORS THE PASS ITERATES. "Contested" has to mean + * exactly what it meant to the registrar, or this class and the container disagree about the key. + * FlushDefinitionsPass hands ContainerRegistrar::register() the manifest built by + * BeanDefinitionRegistry::toComponentManifest() — a lossless array_map over the SAME, already + * condition-filtered definitions every instance-stage pass then reads — so counting over those + * descriptors reproduces the registrar's grouping exactly, #[ConditionalOn*]-removed #[Bean] + * methods included. + */ +final class BeanBindingKeys +{ + /** + * @param array $producers return type => how many #[Bean] methods produce it + */ + private function __construct(private readonly array $producers) {} + + public static function fromDefinitions(BeanDefinitionRegistry $definitions): self + { + return self::fromDescriptors(array_map( + static fn (BeanDefinition $definition): ComponentDescriptor => $definition->descriptor, + $definitions->all(), + )); + } + + /** + * @param list $descriptors + */ + public static function fromDescriptors(array $descriptors): self + { + /** @var array $producers */ + $producers = []; + + foreach ($descriptors as $descriptor) { + foreach ($descriptor->beans as $bean) { + // A builtin or untyped return records '' (see ComponentScanner): the registrar + // skips it entirely, so it never competes for anything and has no key at all. + if ($bean->returns === '') { + continue; + } + + $producers[$bean->returns] = ($producers[$bean->returns] ?? 0) + 1; + } + } + + return new self($producers); + } + + /** + * The container key this #[Bean] method's product is registered under, or null when it has no + * key at all. + * + * Null has exactly two causes, both of which mean "there is nothing for a pass to resolve + * here", never "resolve it some other way": + * - an untyped/builtin return (`$bean->returns === ''`), which the registrar never binds; + * - a competitor with no name. That shape cannot reach a booted application — the registrar + * rejects an anonymous competing bean at REGISTRATION time, because a bean reachable only + * through a return type its competitors already claim can never be resolved — so this arm + * exists to keep the null-safety honest rather than to handle a live case. + */ + public function keyFor(BeanDescriptor $bean): ?string + { + if ($bean->returns === '') { + return null; + } + + return $this->isContested($bean->returns) ? $bean->name : $bean->returns; + } + + /** + * True when more than one surviving #[Bean] method produces $type — i.e. when the bare type key + * belongs to the GROUP (alias of the #[Primary] winner, or the ambiguity guard) rather than to + * any single bean. + */ + public function isContested(string $type): bool + { + return ($this->producers[$type] ?? 0) > 1; + } +} diff --git a/packages/context/src/Pass/EagerSingletonsPass.php b/packages/context/src/Pass/EagerSingletonsPass.php index a79e517..ca8d8d3 100644 --- a/packages/context/src/Pass/EagerSingletonsPass.php +++ b/packages/context/src/Pass/EagerSingletonsPass.php @@ -11,8 +11,8 @@ /** * Eagerly resolves every non-#[Lazy] Scope::Singleton component and #[Bean] factory, sorted from the - * MANIFEST by (order, abstract) — never from resolved instances (the same INVARIANT 3 rule the other - * instance-stage passes document). + * MANIFEST by (order, container key) — never from resolved instances (the same INVARIANT 3 rule the + * other instance-stage passes document). * * Runs AFTER EventListeners (800) deliberately: an event published from a #[PostConstruct] callback * fired DURING eager resolution must already find its listeners registered, or it reaches nobody, @@ -24,6 +24,27 @@ * #[Bean] factory METHOD is read straight off BeanDescriptor::$lazy — no reflection, no boot-time * attribute lookup at all in either case (see ComponentScanner, which captures both onto the * manifest at scan time). + * + * EACH EAGER #[Bean] IS RESOLVED BY ITS OWN CONTAINER KEY (BeanBindingKeys), never by + * `$bean->returns`. That distinction only became visible when firefly/container taught + * ContainerRegistrar to honour #[Primary]/#[Qualifier] on #[Bean] methods, but it exposed a bug + * this pass had carried since it was written, and it broke a second way at the same time: + * + * - THE OLD, SILENT BUG. With SEVERAL #[Bean] methods producing one type, this pass queued that + * TYPE once per competitor and make()d it each time. The type key is a single binding, so every + * call after the first returned the SAME cached singleton: exactly ONE of the competitors was + * ever constructed, and every named sibling — a non-#[Lazy] Scope::Singleton bean, which this + * pass exists to guarantee is built at boot — was quietly never built at all. No error, no + * warning; the "eager singleton" guarantee simply did not hold for it. + * - THE NEW, LOUD ONE. A contested type with no #[Primary] is now bound to a factory that throws + * a NoUniqueBeanDefinition-style ConfigurationException, so make()ing the bare type turned a + * perfectly valid application — two same-typed beans, injected only by #[Qualifier] — into a + * hard failure AT BOOT. + * + * Resolving by the registrar's own key fixes both at once, and does it without special-casing the + * common shape: for an UNCONTESTED type the key IS `$bean->returns` (the registrar binds the + * factory there and aliases the name to it), so single-#[Bean] applications resolve byte-identically + * to before. See CompetingBeansPassTest. */ final class EagerSingletonsPass implements BootPass { @@ -49,6 +70,8 @@ public function run(BootContext $context): void */ private function orderedEagerAbstracts(BootContext $context): array { + $keys = BeanBindingKeys::fromDefinitions($context->definitions); + /** @var list $entries */ $entries = []; @@ -60,13 +83,19 @@ private function orderedEagerAbstracts(BootContext $context): array } foreach ($descriptor->beans as $bean) { - $isEager = $bean->returns !== '' - && $bean->scope === Scope::Singleton - && ! $bean->lazy; + if ($bean->scope !== Scope::Singleton || $bean->lazy) { + continue; + } - if ($isEager) { - $entries[] = [$bean->order, $bean->returns]; + // Null means the registrar bound nothing for this #[Bean] method at all (an + // untyped return; or an anonymous competitor, which it rejects outright) — there + // is no key to eagerly resolve, so skip rather than invent one. + $key = $keys->keyFor($bean); + if ($key === null) { + continue; } + + $entries[] = [$bean->order, $key]; } } diff --git a/packages/context/src/Pass/RegisterBeanPostProcessorsPass.php b/packages/context/src/Pass/RegisterBeanPostProcessorsPass.php index 607c0d7..d5ef205 100644 --- a/packages/context/src/Pass/RegisterBeanPostProcessorsPass.php +++ b/packages/context/src/Pass/RegisterBeanPostProcessorsPass.php @@ -35,6 +35,13 @@ * #[Bean] return type) — never $o::class — and is what gets passed to every * BeanPostProcessor::before/afterInitialization() call. * + * The CONTAINER KEY each extender is installed on is a separate question, answered by + * BeanBindingKeys and not by $declaredClass: when several #[Bean] methods produce one type, the + * registrar binds each competitor under its own #[Bean] name and the bare type key belongs to the + * group. Extending the type key there reached only the #[Primary] winner, so every named sibling + * escaped this chain entirely — #[PostConstruct], #[PreDestroy] and #[Transactional] included, in + * silence. See abstractsToExtend()'s own docblock for the full account. + * * INVARIANT 4, REFINED — "key on the declared class, never $bean::class" exists to be PROXY-safe: * a proxy's runtime class has no manifest entry, so using it as a lookup key silently finds nothing. * But its own corollary contract (see docs/modules/context.md's "proxy contract") is that a proxy is @@ -141,15 +148,16 @@ static function (string $class) use ($container): BeanPostProcessor { $bppClassSet = array_fill_keys($bppClasses, true); - /** @var array $listenersRegisteredFor keyed by the ABSTRACT ($declaredClass) — see registerLateBoundListeners() */ + /** @var array $listenersRegisteredFor keyed by the CONTAINER KEY ($boundKey) — see registerLateBoundListeners() */ $listenersRegisteredFor = []; - foreach ($this->abstractsToExtend($descriptors, $bppClassSet) as $abstract => $target) { + foreach ($this->abstractsToExtend($descriptors, $bppClassSet) as $boundKey => $target) { [$declaredClass, $scope] = $target; - $container->extend($abstract, static function (object $bean) use ( + $container->extend($boundKey, static function (object $bean) use ( $chain, $declaredClass, + $boundKey, $scope, $disposables, $container, @@ -174,6 +182,7 @@ static function (string $class) use ($container): BeanPostProcessor { $container, $declaredClass, $concreteClass, + $boundKey, $listenersRegisteredFor, ); } @@ -228,21 +237,36 @@ private function disposableBeanRegistry(Container $container, InitDestroyInvoker * reason (abstract classes are unscanned too, so `forClass()` is null there as well), which the * old identity gate only got right by accident. * - * $registered is keyed by the ABSTRACT ($declaredClass) — NOT the runtime concrete class (M4 - * review #8, Minor; the prior version keyed on $concreteClass, the same inferred-vs-asked - * substitution review #7 fixed one guard above: this dedupe question is "have listeners already - * been registered FOR THIS ABSTRACT", never "has this runtime class been seen under ANY - * abstract", and only the abstract answers that. Keying on $concreteClass was too COARSE across - * abstracts — two #[Bean] factories producing distinct singletons of the very SAME concrete class - * but bound under two DIFFERENT abstracts (e.g. `#[Bean] fn(): ReadPort` and + * $registered is keyed by the CONTAINER KEY this extender was installed on ($boundKey) — NOT the + * runtime concrete class (M4 review #8, Minor; the prior version keyed on $concreteClass, the + * same inferred-vs-asked substitution review #7 fixed one guard above: this dedupe question is + * "have listeners already been registered FOR THIS BINDING", never "has this runtime class been + * seen under ANY binding", and only the binding answers that. Keying on $concreteClass was too + * COARSE across bindings — two #[Bean] factories producing distinct singletons of the very SAME + * concrete class but bound under two DIFFERENT abstracts (e.g. `#[Bean] fn(): ReadPort` and * `#[Bean] fn(): WritePort`, both implemented by one `Repo` class) shared one * `$registered[Repo::class]` entry, so the SECOND abstract's extender found it already `true` * and silently never registered that bean's listener at all — undisclosed, and measured false - * (see DedupeKeyTest's cross-abstract case). Keying on $declaredClass fixes it: each abstract's - * own extender consults its own entry, so both abstracts register. + * (see DedupeKeyTest's cross-abstract case). Keying on the binding fixes it: each binding's own + * extender consults its own entry, so both register. + * + * $boundKey RATHER THAN $declaredClass, and the two only differ for a CONTESTED type. For a + * #[Component] and for an uncontested #[Bean] the container key IS the declared class, so every + * word above holds verbatim. But when SEVERAL #[Bean] methods produce one type, the registrar + * binds each under its own #[Bean] NAME while `$declaredClass` stays the shared return type — + * so keying this guard on $declaredClass would collapse the competitors onto ONE entry and the + * second bean's listener would silently never register, reintroducing exactly the too-coarse + * failure the cross-abstract case above describes, one level down. $boundKey is also what gets + * passed as registerListenersFor()'s $invokeThrough, for the same reason: the contested type key + * is an alias of the #[Primary] winner (or, with no #[Primary], a factory that throws), so + * invoking a sibling's listener through it would reach the wrong bean, or none. + * + * The manifest GATE above stays on $declaredClass, deliberately: it asks whether + * RegisterEventListenersPass's boot sweep already handled this bean, and that sweep looks + * listener metadata up by the declared TYPE. Gate on the type, dedupe and invoke on the key. * * $registered is passed BY REFERENCE from the ONE composite extender closure created in run() - * for this abstract. Under Octane that SAME closure instance (installed once, at worker boot) + * for this binding. Under Octane that SAME closure instance (installed once, at worker boot) * survives for the worker's entire life — a shallow `clone $this->app` per request copies the * extenders array's closure REFERENCES, never deep-clones them (see OctaneListener's own * invariant-7 note on Illuminate\Container's clone semantics) — so this guard is what stops a @@ -290,16 +314,17 @@ private static function registerLateBoundListeners( Container $container, string $declaredClass, string $concreteClass, + string $boundKey, array &$registered, ): void { $swept = $contextManifest->forClass($declaredClass); $sweptListeners = $swept === null ? [] : $swept->listeners; - if ($sweptListeners !== [] || isset($registered[$declaredClass])) { + if ($sweptListeners !== [] || isset($registered[$boundKey])) { return; } - $registered[$declaredClass] = true; + $registered[$boundKey] = true; - RegisterEventListenersPass::registerListenersFor($dispatcher, $contextManifest, $container, $concreteClass, $declaredClass); + RegisterEventListenersPass::registerListenersFor($dispatcher, $contextManifest, $container, $concreteClass, $boundKey); } /** @@ -320,12 +345,41 @@ private function orderedBeanPostProcessorClasses(array $descriptors): array } /** + * The container keys that get a composite extender, each mapped to the DECLARED CLASS threaded + * into that extender. + * + * THE TWO ARE NOT THE SAME THING, and conflating them is what let competing #[Bean] methods + * escape post-processing entirely. The KEY is whatever ContainerRegistrar bound the factory + * under (BeanBindingKeys — the #[Bean] NAME for a contested type, the return type otherwise); + * the VALUE's class is the bean's DECLARED TYPE, which is what BeanPostProcessor::$declaredClass + * is contractually required to be (a real class-string: TransactionalBeanPostProcessor calls + * class_exists() on it, and a #[Bean] name is a container key, not a class). + * + * WHAT WAS BROKEN. Both roles used to be `$bean->returns`. N competing beans therefore collapsed + * onto ONE map entry (same key, overwritten), and that single entry named the bare type key — + * which for a contested type is an ALIAS of the #[Primary] winner, and which Illuminate's + * Container::extend() resolves before installing anything (`$abstract = $this->getAlias($abstract)`). + * So the one extender that got installed went onto the WINNER's binding, and every named sibling + * got no extender at all: it never reached the BeanPostProcessorChain, so its #[PostConstruct] + * never fired, it was never handed to DisposableBeanRegistry (no #[PreDestroy] at context + * close), and — the reason this is not a niche concern — TransactionalBeanPostProcessor never + * saw it, meaning #[Transactional] silently did not apply to that bean. Nothing errored. + * + * With no #[Primary] the type key is not an alias but the ambiguity-guard binding, so the + * extender was installed on a factory that only ever throws — every sibling escaped there too. + * + * Keying on the registrar's own key fixes both shapes and leaves the common one untouched: for + * an UNCONTESTED bean the key IS the return type, exactly as before, and for a #[Component] the + * key is (and always was) its class. + * * @param list $descriptors * @param array $bppClassSet - * @return array + * @return array container key => [declared class, scope] */ private function abstractsToExtend(array $descriptors, array $bppClassSet): array { + $keys = BeanBindingKeys::fromDescriptors($descriptors); + /** @var array $abstracts */ $abstracts = []; @@ -337,11 +391,23 @@ private function abstractsToExtend(array $descriptors, array $bppClassSet): arra } foreach ($descriptor->beans as $bean) { - if ($bean->returns !== '' && ! isset($bppClassSet[$bean->returns])) { - /** @var class-string $returns */ - $returns = $bean->returns; - $abstracts[$returns] = [$returns, $bean->scope]; + // The BPP exclusion is asked of the declared TYPE, not the binding key: what must + // never get an extender is a bean that IS a BeanPostProcessor, and only its type + // says whether it is one. + if ($bean->returns === '' || isset($bppClassSet[$bean->returns])) { + continue; } + + // Null means the registrar bound nothing for this #[Bean] method (see + // BeanBindingKeys::keyFor()) — there is no binding to extend. + $key = $keys->keyFor($bean); + if ($key === null) { + continue; + } + + /** @var class-string $returns */ + $returns = $bean->returns; + $abstracts[$key] = [$returns, $bean->scope]; } } diff --git a/packages/context/src/Pass/RegisterEventListenersPass.php b/packages/context/src/Pass/RegisterEventListenersPass.php index b5826c1..e0eaf92 100644 --- a/packages/context/src/Pass/RegisterEventListenersPass.php +++ b/packages/context/src/Pass/RegisterEventListenersPass.php @@ -51,12 +51,15 @@ * `EagerSingletonsPass` and `RegisterBeanPostProcessorsPass` both already iterate * `$descriptor->beans`/`$bean->returns` for exactly this reason (a `#[Bean]` output is a * first-class lifecycle-managed thing, not merely its declaring class); this pass does the same - * here in its own boot-time sweep. `$bean->returns` is resolved via `$container->make($bean->returns)` - * — the same abstract `ContainerRegistrar::registerBeans()` bound the factory under — so the - * listener always observes the fully post-processed (possibly proxied) bean, exactly like a plain - * `#[Component]`. A class already visited (as either a definition's own class OR an earlier bean's - * return type) is never visited twice, so a class reachable both ways cannot register the same - * listener method twice. + * here in its own boot-time sweep. The listener resolves its bean through the key + * `ContainerRegistrar::registerBeans()` actually bound the factory under (`BeanBindingKeys` — the + * return type for the ordinary single-#[Bean] case, the #[Bean] NAME when several #[Bean] methods + * compete for one type), so it always observes the fully post-processed (possibly proxied) bean, + * exactly like a plain `#[Component]`. A BINDING already visited (reachable as either a + * definition's own class OR a bean's key) is never visited twice, so a class reachable both ways + * cannot register the same listener method twice — while two competing beans, which are two + * distinct bindings, each register their own. See `orderedListeners()` for why the class and the + * key had to stop being one string. * * 🔴 THE CANONICAL HEXAGONAL SHAPE IS NOT HANDLED BY THIS SWEEP (M4 review #6, Important 1 — the * untreated twin of `d3a7688`, corrected here; do NOT reintroduce the false claim this replaces). @@ -115,10 +118,10 @@ public function run(BootContext $context): void /** @var Dispatcher $dispatcher */ $dispatcher = $container->make('events'); - foreach ($this->orderedListeners($context) as [$class, $method, $event]) { - $raw = static function (mixed ...$arguments) use ($container, $class, $method): mixed { + foreach ($this->orderedListeners($context) as [$boundKey, $method, $event]) { + $raw = static function (mixed ...$arguments) use ($container, $boundKey, $method): mixed { /** @var object $bean */ - $bean = $container->make($class); + $bean = $container->make($boundKey); return $bean->{$method}(...$arguments); }; @@ -128,22 +131,55 @@ public function run(BootContext $context): void } /** - * @return list + * Discovery is per CLASS (listener metadata lives on a class); registration is per BEAN. + * + * Those coincide for a #[Component] and for an uncontested #[Bean], which is why one loop keyed + * on `$bean->returns` was right for as long as a #[Bean] method's return type was also its + * container key. It stopped being right when firefly/container taught ContainerRegistrar to + * honour #[Primary]/#[Qualifier] on #[Bean] methods: with SEVERAL #[Bean] methods producing one + * type, each competitor is bound under its own #[Bean] NAME and the bare type key becomes an + * ALIAS of the #[Primary] winner — or, with no #[Primary], a factory that throws a + * NoUniqueBeanDefinition-style ConfigurationException. Both halves then went wrong at once: + * + * - ONE registration for N beans. `$visited` is keyed by class, so a type produced twice was + * collected once. That is still exactly right for DISCOVERY — re-reading one class's + * metadata would duplicate the listener — but it meant only ONE listener existed for two + * beans that each declared it, and the sibling's #[AsEventListener] simply never fired. + * - INVOKED THROUGH THE WRONG KEY. `make($bean->returns)` on a contested type reaches the + * #[Primary] winner, so the listener ran against the winner's instance no matter which bean + * declared it; with no #[Primary] it hit the ambiguity guard and threw at DISPATCH time — + * turning a valid application (two same-typed beans, injected only by #[Qualifier]) into a + * runtime failure the first time any event was published. + * + * So `$visited` now keys on the CONTAINER KEY (BeanBindingKeys), which IS the class for every + * component and every uncontested #[Bean] — identical behavior, including the "reachable as + * both a component class and a bean return type" dedupe this guard was written for — and is the + * distinct #[Bean] name for each competitor, so each one registers its own listener and invokes + * it through its own binding. See CompetingBeansPassTest. + * + * @return list [container key, method, event, order] */ private function orderedListeners(BootContext $context): array { + $keys = BeanBindingKeys::fromDefinitions($context->definitions); + $entries = []; - /** @var array $visited guards against visiting the same class twice */ + /** @var array $visited guards against registering the same binding twice */ $visited = []; foreach ($context->definitions->all() as $definition) { - $this->collectListenersFor($context, $definition->class(), $visited, $entries); + $this->collectListenersFor($context, $definition->class(), $definition->class(), $visited, $entries); foreach ($definition->descriptor->beans as $bean) { - if ($bean->returns !== '') { - $this->collectListenersFor($context, $bean->returns, $visited, $entries); + // Null means the registrar bound nothing for this #[Bean] method (see + // BeanBindingKeys::keyFor()) — there is no binding to invoke a listener through. + $boundKey = $keys->keyFor($bean); + if ($boundKey === null) { + continue; } + + $this->collectListenersFor($context, $bean->returns, $boundKey, $visited, $entries); } } @@ -153,23 +189,27 @@ private function orderedListeners(BootContext $context): array } /** + * $lookupClass is the class whose manifest entry carries the #[AsEventListener] metadata; + * $boundKey is the container key the listener resolves its bean through at dispatch. They are + * the same string everywhere except for a competing #[Bean] — see orderedListeners(). + * * @param array $visited * @param list $entries */ - private function collectListenersFor(BootContext $context, string $class, array &$visited, array &$entries): void + private function collectListenersFor(BootContext $context, string $lookupClass, string $boundKey, array &$visited, array &$entries): void { - if (isset($visited[$class])) { + if (isset($visited[$boundKey])) { return; } - $visited[$class] = true; + $visited[$boundKey] = true; - $descriptor = $context->contextManifest->forClass($class); + $descriptor = $context->contextManifest->forClass($lookupClass); if ($descriptor === null) { return; } foreach ($descriptor->listeners as $listener) { - $entries[] = [$class, $listener['method'], $listener['event'], $listener['order']]; + $entries[] = [$boundKey, $listener['method'], $listener['event'], $listener['order']]; } } diff --git a/packages/context/tests/CompetingBeanFixtures/CacheConfiguration.php b/packages/context/tests/CompetingBeanFixtures/CacheConfiguration.php new file mode 100644 index 0000000..16b0318 --- /dev/null +++ b/packages/context/tests/CompetingBeanFixtures/CacheConfiguration.php @@ -0,0 +1,35 @@ + factory, 'redisCache' => factory, CachePort::class => alias('memoryCache') + * — so CachePort::class is NOT a registration key of its own for either bean, which is the single + * fact every boot pass in firefly/context used to get wrong by keying on $bean->returns. + */ +#[Configuration] +final class CacheConfiguration +{ + #[Bean('memoryCache')] + #[Primary] + public function memoryCache(CacheProbe $probe): CachePort + { + return new MemoryCache($probe); + } + + #[Bean('redisCache')] + public function redisCache(CacheProbe $probe): CachePort + { + return new RedisCache($probe); + } +} diff --git a/packages/context/tests/CompetingBeanFixtures/CachePing.php b/packages/context/tests/CompetingBeanFixtures/CachePing.php new file mode 100644 index 0000000..98d755e --- /dev/null +++ b/packages/context/tests/CompetingBeanFixtures/CachePing.php @@ -0,0 +1,12 @@ + */ + public array $events = []; + + public function record(string $event): void + { + $this->events[] = $event; + } + + /** + * @return list + */ + public function matching(string $prefix): array + { + return array_values(array_filter( + $this->events, + static fn (string $event): bool => str_starts_with($event, $prefix), + )); + } +} diff --git a/packages/context/tests/CompetingBeanFixtures/ConcreteCache.php b/packages/context/tests/CompetingBeanFixtures/ConcreteCache.php new file mode 100644 index 0000000..36a7264 --- /dev/null +++ b/packages/context/tests/CompetingBeanFixtures/ConcreteCache.php @@ -0,0 +1,43 @@ +probe->record("construct:{$this->tag}"); + } + + public function tag(): string + { + return $this->tag; + } + + #[AsEventListener] + public function onPing(CachePing $event): void + { + $this->probe->record("listener:{$this->tag}"); + } +} diff --git a/packages/context/tests/CompetingBeanFixtures/ConcreteCacheConfiguration.php b/packages/context/tests/CompetingBeanFixtures/ConcreteCacheConfiguration.php new file mode 100644 index 0000000..1758e74 --- /dev/null +++ b/packages/context/tests/CompetingBeanFixtures/ConcreteCacheConfiguration.php @@ -0,0 +1,32 @@ +probe->record('construct:memory'); + } + + public function name(): string + { + return 'memory'; + } + + #[PostConstruct] + public function warm(): void + { + $this->probe->record('postConstruct:memory'); + } + + #[AsEventListener] + public function onPing(CachePing $event): void + { + $this->probe->record('listener:memory'); + } +} diff --git a/packages/context/tests/CompetingBeanFixtures/RecordingCacheBpp.php b/packages/context/tests/CompetingBeanFixtures/RecordingCacheBpp.php new file mode 100644 index 0000000..73f756b --- /dev/null +++ b/packages/context/tests/CompetingBeanFixtures/RecordingCacheBpp.php @@ -0,0 +1,43 @@ +probe->record("bpp:before:{$bean->name()}:{$declaredClass}"); + } + + return $bean; + } + + public function afterInitialization(object $bean, string $declaredClass): object + { + if ($bean instanceof CachePort) { + $this->probe->record("bpp:after:{$bean->name()}:{$declaredClass}"); + } + + return $bean; + } +} diff --git a/packages/context/tests/CompetingBeanFixtures/RedisCache.php b/packages/context/tests/CompetingBeanFixtures/RedisCache.php new file mode 100644 index 0000000..8658684 --- /dev/null +++ b/packages/context/tests/CompetingBeanFixtures/RedisCache.php @@ -0,0 +1,40 @@ +probe->record('construct:redis'); + } + + public function name(): string + { + return 'redis'; + } + + #[PostConstruct] + public function warm(): void + { + $this->probe->record('postConstruct:redis'); + } + + #[AsEventListener] + public function onPing(CachePing $event): void + { + $this->probe->record('listener:redis'); + } +} diff --git a/packages/context/tests/Pass/CompetingBeansPassTest.php b/packages/context/tests/Pass/CompetingBeansPassTest.php new file mode 100644 index 0000000..d525201 --- /dev/null +++ b/packages/context/tests/Pass/CompetingBeansPassTest.php @@ -0,0 +1,319 @@ +returns` is + * therefore no longer a registration key for any individual bean, yet all three instance-stage + * passes keyed on it: + * + * 1. EagerSingletonsPass make()d `$bean->returns`, so with a #[Primary] only the WINNER was ever + * built (the "eager singleton" guarantee was violated, silently, for every named sibling — + * a bug that predates the container change and was merely exposed by it), and with no + * #[Primary] boot itself blew up on the ambiguity guard even though the application only ever + * injects those beans by #[Qualifier]. + * 2. RegisterBeanPostProcessorsPass extend()ed `$bean->returns`, which Illuminate resolves + * through the alias to the winner's key — so every named sibling escaped the + * BeanPostProcessor chain entirely: no #[PostConstruct], no DisposableBeanRegistry entry, and + * (in a real application) no #[Transactional] proxy, with no error anywhere. + * 3. RegisterEventListenersPass registered one listener per contested TYPE and invoked it through + * that type key, so a sibling's #[AsEventListener] never fired — and with no #[Primary] the + * listener closure hit the ambiguity guard at DISPATCH time. + * + * Everything below runs the REAL ComponentScanner + ContextScanner over + * packages/context/tests/CompetingBeanFixtures/ and the REAL ContainerRegistrar, because the bug is + * precisely a disagreement between what the registrar BOUND and what the passes ASSUMED it bound — + * a hand-built container would let that disagreement pass unnoticed. + */ + +/** + * @return array{0: ComponentManifest, 1: ContextManifest} + */ +function competingScan(): array +{ + /** @var array{0: ComponentManifest, 1: ContextManifest}|null $cached */ + static $cached = null; + + if ($cached === null) { + $psr4 = ['Firefly\\Context\\Tests\\CompetingBeanFixtures\\' => dirname(__DIR__).'/CompetingBeanFixtures']; + + $cached = [ + new ComponentManifest((new ComponentScanner)->scan($psr4)), + new ContextManifest((new ContextScanner)->scan($psr4)), + ]; + } + + return $cached; +} + +/** + * Rebuilds the scanned manifest with every BeanDescriptor passed through $rewrite. + * + * The fixtures carry a real #[Primary] and no #[Lazy] (that is the documented, supported shape), + * so the variants below are produced by rewriting the SCANNED descriptors rather than by + * duplicating the whole fixture tree under a second namespace per variant. Only the #[Bean] + * attributes under test change; everything else — including the ContextManifest, which is what + * carries the #[PostConstruct]/#[AsEventListener] metadata — is the real scanner's output. + * ComponentDescriptor/BeanDescriptor are `final readonly`, hence the rebuild rather than a mutation. + * + * @param callable(BeanDescriptor): BeanDescriptor $rewrite + */ +function competingRewrittenManifest(ComponentManifest $manifest, callable $rewrite): ComponentManifest +{ + return new ComponentManifest(array_map( + static fn (ComponentDescriptor $component): ComponentDescriptor => new ComponentDescriptor( + class: $component->class, + stereotype: $component->stereotype, + name: $component->name, + scope: $component->scope, + primary: $component->primary, + order: $component->order, + qualifier: $component->qualifier, + interfaces: $component->interfaces, + beans: array_map($rewrite, $component->beans), + lazy: $component->lazy, + ), + $manifest->components, + )); +} + +function competingManifestWithoutPrimary(ComponentManifest $manifest): ComponentManifest +{ + return competingRewrittenManifest($manifest, static fn (BeanDescriptor $bean): BeanDescriptor => new BeanDescriptor( + $bean->method, + $bean->returns, + $bean->name, + $bean->scope, + false, + $bean->order, + $bean->lazy, + )); +} + +/** + * Marks the NON-#[Primary] competitor of each contested type #[Lazy], leaving its #[Primary] + * sibling eager. + */ +function competingManifestWithLazySiblings(ComponentManifest $manifest): ComponentManifest +{ + return competingRewrittenManifest($manifest, static fn (BeanDescriptor $bean): BeanDescriptor => new BeanDescriptor( + $bean->method, + $bean->returns, + $bean->name, + $bean->scope, + $bean->primary, + $bean->order, + ! $bean->primary, + )); +} + +/** + * Boots the fixture application exactly as FlushDefinitionsPass would: ONE + * ContainerRegistrar::register() over the same condition-filtered manifest the + * BeanDefinitionRegistry then hands to the instance-stage passes. + */ +function competingContext(bool $withPrimary = true, bool $lazySiblings = false): BootContext +{ + [$components, $contextManifest] = competingScan(); + + if (! $withPrimary) { + $components = competingManifestWithoutPrimary($components); + } + + if ($lazySiblings) { + $components = competingManifestWithLazySiblings($components); + } + + $container = new Container; + $container->instance('events', new IlluminateDispatcher($container)); + (new ContainerRegistrar($container))->register($components); + + $definitions = new BeanDefinitionRegistry; + foreach ($components->components as $component) { + $definitions->add(new BeanDefinition($component)); + } + + $config = new Config(new Repository([])); + $profiles = new Profiles([]); + + return new BootContext( + container: $container, + definitions: $definitions, + config: $config, + profiles: $profiles, + conditions: new ConditionEvaluator($config, $profiles), + report: new ConditionEvaluationReport, + contextManifest: $contextManifest, + ); +} + +function competingProbe(BootContext $context): CacheProbe +{ + /** @var CacheProbe $probe */ + $probe = $context->container->make(CacheProbe::class); + + return $probe; +} + +/** + * The real instance-stage order: BeanPostProcessors (700), EventListeners (800), EagerSingletons + * (900). Running them out of order would make several assertions below vacuously true. + */ +function competingBoot(BootContext $context): void +{ + (new RegisterBeanPostProcessorsPass)->run($context); + (new RegisterEventListenersPass)->run($context); + (new EagerSingletonsPass)->run($context); +} + +it('eagerly instantiates EVERY competing #[Bean], not just the #[Primary] winner', function () { + $context = competingContext(); + + competingBoot($context); + + // Both halves of the contested CachePort, and both halves of the contested ConcreteCache. + expect(competingProbe($context)->matching('construct:')) + ->toEqualCanonicalizing(['construct:memory', 'construct:redis', 'construct:near', 'construct:far']); +}); + +it('still honours #[Lazy] on a competing #[Bean] — resolving by name must not resolve everything', function () { + $context = competingContext(lazySiblings: true); + + competingBoot($context); + $probe = competingProbe($context); + + // Only the eager (#[Primary]) half of each contested pair. Resolving competitors by their own + // names is what makes the eager guarantee hold for ALL of them; it must not quietly promote a + // #[Lazy] sibling to eager along the way. + expect($probe->matching('construct:'))->toEqualCanonicalizing(['construct:memory', 'construct:near']); + + // And the #[Lazy] sibling still builds — with its extender intact — on first real use. + /** @var CachePort $lazySibling */ + $lazySibling = $context->container->make('redisCache'); + + expect($lazySibling->name())->toBe('redis') + ->and($probe->matching('postConstruct:')) + ->toEqualCanonicalizing(['postConstruct:memory', 'postConstruct:redis']); +}); + +it('boots a contested type that has NO #[Primary] instead of tripping the ambiguity guard', function () { + $context = competingContext(withPrimary: false); + + // The bare type key is bound to a throwing guard factory here. An application that injects + // these beans only by #[Qualifier] is perfectly valid, so boot must never touch that key. + competingBoot($context); + + expect(competingProbe($context)->matching('construct:')) + ->toEqualCanonicalizing(['construct:memory', 'construct:redis', 'construct:near', 'construct:far']); +}); + +it('runs the BeanPostProcessor chain for every competing #[Bean], threading the DECLARED TYPE as $declaredClass', function () { + $context = competingContext(); + + competingBoot($context); + + // $declaredClass stays CachePort::class for both: a bean NAME is a container key, not a class, + // and TransactionalBeanPostProcessor calls class_exists() on what it receives here. + expect(competingProbe($context)->matching('bpp:'))->toEqualCanonicalizing([ + 'bpp:before:memory:'.CachePort::class, + 'bpp:after:memory:'.CachePort::class, + 'bpp:before:redis:'.CachePort::class, + 'bpp:after:redis:'.CachePort::class, + ]); +}); + +it('fires #[PostConstruct] on every competing #[Bean], not only the #[Primary] one', function () { + $context = competingContext(); + + competingBoot($context); + + expect(competingProbe($context)->matching('postConstruct:')) + ->toEqualCanonicalizing(['postConstruct:memory', 'postConstruct:redis']); +}); + +it('registers each competing bean listener exactly once, for both the concrete-return and interface-return shapes', function () { + $context = competingContext(); + + competingBoot($context); + $probe = competingProbe($context); + $probe->events = []; + + $context->container->make('events')->dispatch(new CachePing); + + // interface return (CachePort) — recovered by the late-bound extender path; + // concrete return (ConcreteCache) — found by RegisterEventListenersPass's own sweep. + // EXACTLY ONCE each: a second entry for any of them would be a duplicate registration. + expect($probe->matching('listener:')) + ->toEqualCanonicalizing(['listener:memory', 'listener:redis', 'listener:near', 'listener:far']); +}); + +it('dispatches to competing bean listeners without touching the contested type key when there is no #[Primary]', function () { + $context = competingContext(withPrimary: false); + + competingBoot($context); + $probe = competingProbe($context); + $probe->events = []; + + // The listener closures must resolve each bean by its OWN key. Resolving the contested type + // instead would throw the ambiguity ConfigurationException here, at dispatch time. + $context->container->make('events')->dispatch(new CachePing); + + expect($probe->matching('listener:')) + ->toEqualCanonicalizing(['listener:memory', 'listener:redis', 'listener:near', 'listener:far']); +}); + +it('keeps a contested type key resolving to the #[Primary] winner, with each sibling a distinct singleton', function () { + $context = competingContext(); + + competingBoot($context); + + // The counterpart to everything above: resolving competitors by their OWN keys must not + // disturb the GROUP key. ConcreteCache is contested, so its type key is an alias of the + // #[Primary] 'nearCache' — it still resolves, still resolves to the winner, and the named + // sibling is still a SEPARATE singleton rather than the winner handed back twice. + // + // (The uncontested shape — where the key IS the return type — is unchanged by this work and + // stays pinned by BeanProducedInterfaceListenerTest, DedupeKeyTest and IntegrationTest.) + /** @var ConcreteCache $viaType */ + $viaType = $context->container->make(ConcreteCache::class); + /** @var ConcreteCache $viaPrimaryName */ + $viaPrimaryName = $context->container->make('nearCache'); + /** @var ConcreteCache $viaSiblingName */ + $viaSiblingName = $context->container->make('farCache'); + + expect($viaType->tag())->toBe('near'); + expect($viaPrimaryName)->toBe($viaType); + expect($viaSiblingName)->not->toBe($viaType); + expect($viaSiblingName->tag())->toBe('far'); +}); diff --git a/packages/installer/README.md b/packages/installer/README.md index 5a196db..8f31a94 100644 --- a/packages/installer/README.md +++ b/packages/installer/README.md @@ -1,14 +1,71 @@ # firefly/installer -The LaraFly global installer — the `laravel/installer` analog. +The LaraFly global installer — the `laravel/installer` analog, with a Spring-Initializr-shaped project +picker. ```bash composer global require firefly/installer firefly new my-app ``` -`firefly new ` wraps `composer create-project firefly/skeleton`, then (unless `--no-git`) runs -`git init` + an initial commit, and prints the next steps. It depends only on `symfony/console` + -`symfony/process` — never the firefly runtime family — so a global install stays light. +`firefly new ` wraps `composer create-project firefly/skeleton`, shapes the result into the requested +archetype, then (unless `--no-git`) runs `git init` + an initial commit and prints the next steps. It +depends only on `symfony/console` + `symfony/process` — never the firefly runtime family — so a global +install stays light. + +## Archetypes + +| flag | shape | +| -------- | ------------------------------------------------------------------------- | +| `--web` | **default.** HTML + JSON: the `#[Controller]` welcome page and the sample `#[RestController]` | +| `--api` | JSON only: the sample `#[RestController]`, no view layer, no welcome page | +| `--full` | `--web` plus every non-adapter capability pre-wired | + +```bash +firefly new my-api --api +firefly new my-app --with=security,eda,scheduling +firefly new my-shop --full --with=eda-postgres +``` + +`--with=` takes a comma-separated capability list (repeat the flag if you prefer). Run `firefly new --help` +for the current list — it is interpolated from the catalog, so it cannot drift from what the flag accepts. +A capability's only footprint is a line in the generated `composer.json`: firefly's conditional +auto-configuration means an installed capability is a wired capability, so there is no second copy of the +package's own defaults for you to keep in sync. + +Adapters (`eda-kafka`, `eda-rabbitmq`, `eda-postgres`, `scheduling-postgres`) pull their port in with them +and are deliberately **excluded from `--full`**: which broker or engine an application talks to is not +something an archetype can guess, and guessing would install a broker client — or demand a PHP extension — +the machine may not have. + +With no flags on an interactive terminal, `firefly new` asks for the shape and the capabilities. Under +`--no-interaction` it asks nothing and generates `--web` with no capabilities. + +An archetype that adds or removes files (today, `--api`) also **recompiles the manifests**. The skeleton's +`post-create-project-cmd` ends in `php artisan firefly:cache`, so `create-project` hands back a project +whose compiled `routes.php` and `component.php` already name the controller `--api` is about to delete — +left alone, the generated app answered `GET /` with a 500 instead of a 404. The stale artifacts are dropped +(an app with no manifests boots by scanning, which is slower but always correct) and `firefly:cache` is +re-run to restore the compiled path; if that ever fails you keep a working, scanned app and +`firefly:serve` tells you so. + +## `--force` + +`--force` scaffolds into a directory that is not empty. It **empties that directory first**, after printing +the path and asking for confirmation (auto-confirmed under `--no-interaction`, which is what makes `--force` +usable in scripts). It refuses outright when the target is a filesystem root or your home directory, and it +unlinks symlinks rather than following them. + +This used to be a broken promise: the old `--force` skipped the installer's own "directory is not empty" +error and then handed the still-non-empty directory to `composer create-project`, which refuses it too and +has no flag that says otherwise. + +## Where the capability list comes from + +`Firefly\Installer\CapabilityCatalog` — a declarative map owned by this package, not a scan. A global +install has no monorepo on disk to enumerate and no firefly runtime package to introspect; the installer +runs before the framework exists. The enumeration happens in CI instead: `CapabilityCatalogTest` reads the +real `packages/*` directory and fails the build when a firefly package is neither a capability nor listed, +with a reason, in `CapabilityCatalog::corePackages()`. Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/installer/composer.json b/packages/installer/composer.json index d2e6a46..302d3bc 100644 --- a/packages/installer/composer.json +++ b/packages/installer/composer.json @@ -1,13 +1,13 @@ { "name": "firefly/installer", - "description": "The LaraFly global installer — `firefly new ` scaffolds a fresh LaraFly app by wrapping `composer create-project firefly/skeleton`, then git-inits and prints next steps. A thin Symfony Console binary with no firefly runtime dependencies.", + "description": "The LaraFly global installer — `firefly new ` scaffolds a fresh LaraFly app by wrapping `composer create-project firefly/skeleton`, shapes it into an archetype (--api/--web/--full/--with=), then git-inits and prints next steps. A thin Symfony Console binary with no firefly runtime dependencies.", "type": "library", "license": "Apache-2.0", "homepage": "https://github.com/fireflyframework/fireflyframework-php", "authors": [ { "name": "Firefly Software Solutions Inc.", "homepage": "https://github.com/fireflyframework" } ], - "keywords": ["firefly", "laravel", "installer", "scaffold", "create-project"], + "keywords": ["firefly", "laravel", "installer", "scaffold", "create-project", "archetype", "initializr"], "support": { "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/installer" diff --git a/packages/installer/src/Archetype.php b/packages/installer/src/Archetype.php new file mode 100644 index 0000000..7640eba --- /dev/null +++ b/packages/installer/src/Archetype.php @@ -0,0 +1,144 @@ + 'JSON only — the sample #[RestController], no view layer, no welcome page', + self::Web => 'HTML + JSON — the #[Controller] welcome page and the sample #[RestController]', + self::Full => 'HTML + JSON plus every optional capability pre-wired', + }; + } + + /** + * Paths, relative to the generated project root, that this archetype deletes. + * + * The welcome test goes with the welcome page on purpose: leaving `test_the_welcome_page_renders_html` + * behind in a project whose welcome page has just been deleted hands the user a red suite on the first + * `composer test`, which is a worse first impression than no test at all. The api archetype replaces it + * (see self::stubs()) with the two cases that survive. + * + * @return list + */ + public function prunes(): array + { + return match ($this) { + self::Api => [ + 'app/Http/WelcomeController.php', + 'resources/views/welcome.blade.php', + 'tests/Feature/WelcomeTest.php', + ], + self::Web, self::Full => [], + }; + } + + /** + * Files this archetype writes into the generated project: relative target path => absolute source. + * + * The api smoke test is the only stub the installer owns. It is coupled to the skeleton's `Tests\` + * namespace and to the sample controller's `/greetings/{name}` route; ArchetypeTest pins both against + * the real skeleton so the coupling breaks a build rather than a user's first run. + * + * The source carries a `.stub` suffix (the Laravel generator convention) because packages/ is a PHPSTAN + * ANALYSIS ROOT: a real .php file here referencing Tests\TestCase and $this->getJson() would be analysed + * as installer source and fail level max on classes that only exist inside a generated app. + * + * @return array + */ + public function stubs(): array + { + $stubs = dirname(__DIR__).'/stubs'; + + return match ($this) { + self::Api => ['tests/Feature/ApiSmokeTest.php' => $stubs.'/api/tests/Feature/ApiSmokeTest.php.stub'], + self::Web, self::Full => [], + }; + } + + /** + * A project-relative file each stub NEEDS in order to compile: stub target => prerequisite. + * + * This is not defensive padding. `skeleton/.gitattributes` marks `/tests export-ignore`, so a real + * `composer create-project firefly/skeleton` ships NO tests/ directory at all — no tests/TestCase.php, + * and therefore nothing for `namespace Tests\Feature; ... extends TestCase` to extend. Copying the stub + * in regardless turned the generated api project's first `composer test` from the web baseline's + * "Test directory tests/Feature not found" (exit 2) into a hard `Class "Tests\TestCase" not found` + * fatal (exit 255) — strictly worse than adding nothing. Writing a test whose base class is absent is + * never the right move, so the stub lands only where it can actually run; if the skeleton ever ships + * its tests/ again, the prerequisite is satisfied and the stub comes back with no change here. + * + * @return array + */ + public function stubPrerequisites(): array + { + return match ($this) { + self::Api => ['tests/Feature/ApiSmokeTest.php' => 'tests/TestCase.php'], + self::Web, self::Full => [], + }; + } + + /** + * True when applying this archetype adds or removes files, i.e. when the compiled manifests + * `composer create-project` already wrote (the skeleton's post-create-project-cmd ends in + * `php artisan firefly:cache`) no longer describe what is on disk. + */ + public function reshapesFiles(): bool + { + return $this->prunes() !== [] || $this->stubs() !== []; + } + + /** + * The capabilities this archetype pre-wires before `--with=` is merged on top. + * + * @return list + */ + public function capabilities(): array + { + return match ($this) { + self::Api, self::Web => [], + self::Full => CapabilityCatalog::full(), + }; + } + + /** + * The single archetype the given flag set selects. + * + * @param array $flags archetype value => whether its flag was passed + * + * @throws InvalidArgumentException when more than one archetype flag is set + */ + public static function fromFlags(array $flags): ?self + { + $selected = array_keys(array_filter($flags)); + if (count($selected) > 1) { + throw new InvalidArgumentException(sprintf( + 'Pick one archetype: --%s are mutually exclusive.', + implode(' / --', $selected), + )); + } + + return $selected === [] ? null : self::from($selected[0]); + } +} diff --git a/packages/installer/src/ArchetypeApplier.php b/packages/installer/src/ArchetypeApplier.php new file mode 100644 index 0000000..ab3c009 --- /dev/null +++ b/packages/installer/src/ArchetypeApplier.php @@ -0,0 +1,247 @@ + $capabilities */ + public function __construct( + private readonly Archetype $archetype, + private readonly array $capabilities, + ) {} + + /** + * @return list one human-readable line per change, for the command to echo + */ + public function applyTo(string $directory): array + { + return [...$this->rewriteManifest($directory), ...$this->shapeFiles($directory)]; + } + + /** + * @return list + */ + private function rewriteManifest(string $directory): array + { + $path = $directory.'/composer.json'; + if (! is_file($path)) { + // Not an error: ProcessRunner is a seam, and under a fake runner nothing was ever generated. + // A create-project that genuinely failed has already returned non-zero and never reached here. + return []; + } + + $raw = file_get_contents($path); + $decoded = $raw === false ? null : json_decode($raw, true); + if (! is_array($decoded)) { + return []; + } + /** @var array $manifest */ + $manifest = $decoded; + + $require = $this->stringMap($manifest['require'] ?? null); + $requireDev = $this->stringMap($manifest['require-dev'] ?? null); + $constraint = $this->fireflyConstraint($require); + + $notes = []; + foreach ($this->capabilities as $capability) { + $target = $capability->dev ? 'require-dev' : 'require'; + $existing = $capability->dev ? $requireDev : $require; + if (isset($existing[$capability->package])) { + continue; // already a direct dependency — the skeleton's own, or a duplicate --with + } + if ($capability->dev) { + $requireDev[$capability->package] = $constraint; + } else { + $require[$capability->package] = $constraint; + } + $notes[] = sprintf('composer.json: + %s (%s)', $capability->package, $target); + } + + $manifest['require'] = $this->sortPackages($require); + if ($requireDev !== []) { + $manifest['require-dev'] = $this->sortPackages($requireDev); + } + + /** @var array $extra */ + $extra = is_array($manifest['extra'] ?? null) ? $manifest['extra'] : []; + $extra['firefly'] = [ + 'archetype' => $this->archetype->value, + 'capabilities' => array_map(static fn (Capability $c): string => $c->id, $this->capabilities), + ]; + $manifest['extra'] = $extra; + + $json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return $notes; + } + file_put_contents($path, $json."\n"); + + return $notes; + } + + /** + * @return list + */ + private function shapeFiles(string $directory): array + { + $notes = []; + + foreach ($this->archetype->prunes() as $relative) { + $path = $directory.'/'.$relative; + if (! file_exists($path) && ! is_link($path)) { + continue; + } + if (Filesystem::delete($path)) { + Filesystem::pruneEmptyDirectories($directory, dirname($path)); + $notes[] = 'removed '.$relative; + } + } + + $prerequisites = $this->archetype->stubPrerequisites(); + foreach ($this->archetype->stubs() as $relative => $source) { + $needs = $prerequisites[$relative] ?? null; + if ($needs !== null && ! is_file($directory.'/'.$needs)) { + // The generated project does not carry what this stub extends (see + // Archetype::stubPrerequisites()); writing it anyway would fatal the user's first + // `composer test` rather than green it. + $notes[] = sprintf('skipped %s (this project ships no %s)', $relative, $needs); + + continue; + } + if (is_file($source) && Filesystem::copy($source, $directory.'/'.$relative)) { + $notes[] = 'added '.$relative; + } + } + + return [...$notes, ...$this->invalidateCompiledManifests($directory)]; + } + + /** + * Drop the compiled manifests `composer create-project` already wrote. + * + * THE BUG THIS FIXES: the skeleton's post-create-project-cmd ends in `php artisan firefly:cache`, so by + * the time the archetype prunes app/Http/WelcomeController.php the compiled routes.php and + * component.php ALREADY name it. Verified end-to-end against a real create-project: a generated `--api` + * project answered `GET /` with a 500 — "Target class [App\Http\WelcomeController] does not exist" — + * because the compiled manifest outlived the class it pointed at, where the same project with no + * manifests correctly 404s. + * + * Deleting is the half of the fix that cannot fail: an app with no compiled manifests boots by + * SCANNING, which is slower but always correct. NewCommand re-runs firefly:cache afterwards to put the + * compiled path back, and if that ever fails the app is merely scanned, never broken. + * + * `.gitkeep` is preserved: skeleton/.gitignore ignores `/bootstrap/cache/firefly/*` but negates that + * one file, so removing it would drop the directory out of the user's very first commit. + * + * @return list + */ + private function invalidateCompiledManifests(string $directory): array + { + if (! $this->archetype->reshapesFiles()) { + return []; + } + + $dir = $directory.'/bootstrap/cache/firefly'; + if (! is_dir($dir)) { + return []; + } + + $removed = 0; + foreach ((array) scandir($dir) as $entry) { + if (! is_string($entry) || $entry === '.' || $entry === '..' || $entry === '.gitkeep') { + continue; + } + if (Filesystem::delete($dir.'/'.$entry)) { + $removed++; + } + } + + return $removed === 0 ? [] : ['invalidated the compiled manifests firefly:cache wrote before the prune']; + } + + /** + * The version constraint to write for a newly required firefly package. + * + * Read from the generated manifest rather than hard-coded, because the right answer changes over the + * + * project's life: the skeleton pins `*@dev` while the family is pre-Packagist and will pin `^26.0` after + * the first tagged release. Copying whatever firefly/firefly (or, failing that, any firefly/* package) + * is already pinned to means the added lines always agree with the ones the skeleton shipped, and this + * file never has to be edited for a release. + * + * @param array $require + */ + private function fireflyConstraint(array $require): string + { + if (isset($require['firefly/firefly'])) { + return $require['firefly/firefly']; + } + foreach ($require as $package => $version) { + if (str_starts_with($package, 'firefly/')) { + return $version; + } + } + + return '*'; + } + + /** + * Composer's own `config.sort-packages` ordering — platform requirements (php, ext-*, lib-*, composer-*) + * first, then everything else by natural case-insensitive name. The skeleton turns sort-packages on, so + * writing the block back in any other order would produce a spurious diff the first time the user runs + * `composer require`. + * + * @param array $packages + * @return array + */ + private function sortPackages(array $packages): array + { + uksort($packages, static function (string $a, string $b): int { + $rank = static fn (string $name): string => preg_match( + '/^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[\p{L}\p{N}\p{Pd}_.]+|composer(?:-(?:plugin|runtime)-api)?)$/iD', + $name, + ) === 1 ? '0-'.$name : '1-'.$name; + + return strnatcasecmp($rank($a), $rank($b)); + }); + + return $packages; + } + + /** + * @return array + */ + private function stringMap(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $map = []; + foreach ($value as $key => $item) { + if (is_string($key) && is_string($item)) { + $map[$key] = $item; + } + } + + return $map; + } +} diff --git a/packages/installer/src/Capability.php b/packages/installer/src/Capability.php new file mode 100644 index 0000000..36d2960 --- /dev/null +++ b/packages/installer/src/Capability.php @@ -0,0 +1,43 @@ + $requires capability ids this one implies — an adapter always implies its port + * @param bool $adapter true when the package binds the app to one specific piece of infrastructure + */ + public function __construct( + public string $id, + public string $package, + public string $summary, + public bool $dev = false, + public array $requires = [], + public bool $adapter = false, + ) {} + + /** + * The label the interactive picker shows. Symfony's multi-select ChoiceQuestion matches on the ARRAY + * KEY, not on this label, so the summary can be as long as it needs to be without becoming something + * the user has to retype. + */ + public function label(): string + { + return $this->id.' — '.$this->summary; + } +} diff --git a/packages/installer/src/CapabilityCatalog.php b/packages/installer/src/CapabilityCatalog.php new file mode 100644 index 0000000..d1e6ad1 --- /dev/null +++ b/packages/installer/src/CapabilityCatalog.php @@ -0,0 +1,165 @@ + firefly/* package. + * + * WHY A DECLARATIVE MAP RATHER THAN A DIRECTORY SCAN + * -------------------------------------------------- + * The obvious implementation is "list packages/* and offer every firefly package you find". It cannot work + * here, and not for a stylistic reason: firefly/installer is a GLOBAL install. `composer global require + * firefly/installer` puts this binary in ~/.composer/vendor with symfony/console + symfony/process and + * nothing else — there is no monorepo checkout on that machine, no packages/ directory to enumerate, and no + * firefly runtime package to introspect. The installer runs BEFORE the framework exists on disk. + * + * The second candidate, "read the list out of the skeleton's composer.json", fails for a different reason: + * the skeleton requires exactly `firefly/cli` + `firefly/firefly`. firefly/firefly is the runtime BOM (the + * Composer analog of a Maven BOM) and firefly/cli transitively drags most of the family behind it, so the + * skeleton's require block names TWO packages and describes seventeen. There is no capability list in it to + * read, and reading one would require resolving the dependency graph — i.e. running Composer — before we + * are allowed to ask the user anything. + * + * So the map below is owned here, and the rot it invites is handled where it can actually be caught: the + * CapabilityCatalogTest enumerates the REAL packages/* directory in the monorepo and fails the build if any + * firefly/* package there is neither a capability nor listed in self::corePackages(). Adding a package to + * the family therefore forces a deliberate decision — "is this something a user picks?" — instead of + * silently going missing from the installer for a year. The enumeration still happens; it happens at CI + * time, where the monorepo exists, rather than at install time, where it does not. + * + * WHAT IS NOT A CAPABILITY + * ------------------------ + * kernel/container/config/context/autoconfigure/web/cli are the framework itself — an app without them is + * not a LaraFly app, so offering them as opt-ins would be offering the user a way to build something + * broken. firefly/firefly is the BOM that ships them, and firefly/installer is this tool. + */ +final class CapabilityCatalog +{ + /** + * Capability id => Capability. Ordered as the interactive picker shows them: the everyday choices + * first, then the infrastructure adapters, then the dev-only test kit. + * + * @return array + */ + public static function all(): array + { + $capabilities = [ + new Capability('security', 'firefly/security', 'Authentication, method security, JWT + in-memory principals'), + new Capability('validation', 'firefly/validation', 'validate() port, financial Rule objects, #[Valid] interception'), + new Capability('data', 'firefly/data', '#[Transactional] interception and the transaction manager'), + new Capability('domain', 'firefly/domain', 'DDD building blocks: Entity, ValueObject, AggregateRoot, DomainEvent'), + new Capability('cqrs', 'firefly/cqrs', 'CommandBus/QueryBus mediator with attribute-discovered handlers'), + new Capability('eda', 'firefly/eda', 'Event-driven architecture: EventPublisher port, #[EventListener], retry + DLQ'), + new Capability('messaging', 'firefly/messaging', 'Raw-bytes MessageBrokerPort with in-memory and queue adapters'), + new Capability('scheduling', 'firefly/scheduling', '#[Scheduled] tasks behind a DistributedLock port (a ShedLock analog)'), + new Capability('resilience', 'firefly/resilience', 'Retry, CircuitBreaker, RateLimiter, Fallback, Bulkhead, TimeLimiter'), + new Capability('actuator', 'firefly/actuator', 'Health, info and introspection endpoints over HTTP'), + new Capability('observability', 'firefly/observability', 'MeterRegistry with Prometheus text exposition'), + new Capability('admin', 'firefly/admin', 'Server-rendered dashboard over the actuator (a Spring Boot Admin analog)'), + new Capability('openapi', 'firefly/openapi', 'OpenAPI 3.1 document generated from the route and constraint manifests, plus a viewer'), + + new Capability('eda-kafka', 'firefly/eda-kafka', 'Kafka publisher/consumer over ext-rdkafka', requires: ['eda'], adapter: true), + new Capability('eda-rabbitmq', 'firefly/eda-rabbitmq', 'RabbitMQ publisher/consumer over php-amqplib', requires: ['eda'], adapter: true), + new Capability('eda-postgres', 'firefly/eda-postgres', 'Postgres same-transaction outbox publisher', requires: ['eda'], adapter: true), + new Capability('scheduling-postgres', 'firefly/scheduling-postgres', 'Postgres advisory-lock DistributedLock backend', requires: ['scheduling'], adapter: true), + + new Capability('testing', 'firefly/testing', 'The first-party test kit: boot harness, sqlite fixtures, assertions', dev: true), + ]; + + $byId = []; + foreach ($capabilities as $capability) { + $byId[$capability->id] = $capability; + } + + return $byId; + } + + /** + * The firefly/* packages that are deliberately NOT capabilities, with the reason each one is excluded. + * CapabilityCatalogTest reads this to prove the catalog covers packages/* exhaustively. + * + * @return array package name => why it is not selectable + */ + public static function corePackages(): array + { + return [ + 'firefly/kernel' => 'the zero-dependency foundation — every app has it', + 'firefly/container' => 'attribute DI is the framework, not an option', + 'firefly/config' => 'profiles and #[ConfigProperties] are the framework, not an option', + 'firefly/context' => 'the boot engine', + 'firefly/autoconfigure' => 'the conditional auto-configuration engine', + 'firefly/web' => 'the HTTP layer; both the api and web archetypes route through it', + 'firefly/cli' => 'the developer console the skeleton already requires directly', + 'firefly/firefly' => 'the runtime BOM that ships the family in one line', + 'firefly/installer' => 'this tool', + ]; + } + + /** @return list */ + public static function ids(): array + { + return array_keys(self::all()); + } + + /** + * Expand a user selection into the packages to write: unknown ids are rejected loudly, and every + * capability's `requires` are pulled in transitively so `--with=eda-kafka` cannot produce a project + * with a Kafka adapter and no EventPublisher port for it to implement. + * + * @param list $ids + * @return list in catalog order, deduplicated + * + * @throws InvalidArgumentException on an unknown id + */ + public static function resolve(array $ids): array + { + $catalog = self::all(); + $selected = []; + + $queue = $ids; + while ($queue !== []) { + $id = strtolower(trim((string) array_shift($queue))); + if ($id === '' || isset($selected[$id])) { + continue; + } + if (! isset($catalog[$id])) { + throw new InvalidArgumentException(sprintf( + 'Unknown capability "%s". Available: %s.', + $id, + implode(', ', self::ids()), + )); + } + $selected[$id] = true; + foreach ($catalog[$id]->requires as $implied) { + $queue[] = $implied; + } + } + + return array_values(array_filter( + $catalog, + static fn (Capability $capability): bool => isset($selected[$capability->id]), + )); + } + + /** + * What `--full` pre-wires: every capability EXCEPT the infrastructure adapters. + * + * An adapter is a binding decision, not a capability: `--full` cannot know whether this app publishes + * over Kafka, RabbitMQ or a Postgres outbox, and picking one for the user would install a broker client + * (php-amqplib) or demand a PHP extension (ext-rdkafka) that the machine may not have. The port ships; + * the adapter is an explicit `--with=eda-kafka` away. + * + * @return list + */ + public static function full(): array + { + return array_values(array_filter( + self::all(), + static fn (Capability $capability): bool => ! $capability->adapter, + )); + } +} diff --git a/packages/installer/src/Filesystem.php b/packages/installer/src/Filesystem.php new file mode 100644 index 0000000..ef08ab3 --- /dev/null +++ b/packages/installer/src/Filesystem.php @@ -0,0 +1,130 @@ + 2; // more than '.' and '..' + } + + /** + * Delete everything INSIDE $directory, keeping the directory itself. + * + * Keeping the inode matters: the directory may be the process's own cwd (`firefly new . --force`), a + * mount point, or a path whose permissions/ownership the user set on purpose. Recreating it would + * silently change all three. + * + * Symlinks are unlinked, never followed — RecursiveDirectoryIterator::hasChildren() refuses to descend + * into a linked directory by default, and the isLink() test below keeps rmdir() away from the target. + * Without it, `--force` on a directory containing a link to $HOME would empty $HOME. + */ + public static function emptyDirectory(string $directory): bool + { + if (! is_dir($directory)) { + return true; + } + + $ok = true; + /** @var iterable $entries */ + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($entries as $entry) { + $path = $entry->getPathname(); + $removed = ! $entry->isLink() && $entry->isDir() ? @rmdir($path) : @unlink($path); + $ok = $ok && $removed; + } + + return $ok; + } + + /** + * True when emptying this path would be a catastrophe rather than a scaffold. + * + * `firefly new ~ --force` and `firefly new / --force` are both typos a shell makes easy, and both would + * be unrecoverable. The guard is cheap and the false-positive cost is a user having to pick a different + * directory name; the false-negative cost is their home directory. + */ + public static function isProtectedPath(string $directory): bool + { + $real = realpath($directory); + if ($real === false) { + return false; // nothing there to destroy + } + + if (dirname($real) === $real) { + return true; // '/' on POSIX, 'C:\' on Windows + } + + foreach (['HOME', 'USERPROFILE'] as $variable) { + $home = getenv($variable); + if (is_string($home) && $home !== '' && realpath($home) === $real) { + return true; + } + } + + return false; + } + + public static function delete(string $path): bool + { + if (is_link($path) || is_file($path)) { + return @unlink($path); + } + if (! is_dir($path)) { + return true; // already gone + } + + return self::emptyDirectory($path) && @rmdir($path); + } + + /** + * Walk up from $from towards $root removing directories that the prune left empty, so deleting + * resources/views/welcome.blade.php in the api archetype does not leave an empty resources/views/ + * behind for the user to wonder about. $root itself is never removed. + */ + public static function pruneEmptyDirectories(string $root, string $from): void + { + $root = rtrim($root, '/'); + $current = rtrim($from, '/'); + + while ($current !== $root && str_starts_with($current, $root.'/')) { + if (! is_dir($current) || self::directoryIsNotEmpty($current)) { + return; + } + if (! @rmdir($current)) { + return; + } + $current = dirname($current); + } + } + + public static function copy(string $source, string $target): bool + { + $directory = dirname($target); + if (! is_dir($directory) && ! @mkdir($directory, 0o755, true) && ! is_dir($directory)) { + return false; + } + + return @copy($source, $target); + } +} diff --git a/packages/installer/src/NewCommand.php b/packages/installer/src/NewCommand.php index 17d25aa..86464ad 100644 --- a/packages/installer/src/NewCommand.php +++ b/packages/installer/src/NewCommand.php @@ -4,6 +4,7 @@ namespace Firefly\Installer; +use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -12,9 +13,19 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +/** + * `firefly new ` — the LaraFly project generator, the Spring Initializr analog. + * + * It wraps `composer create-project firefly/skeleton`, then shapes the result into the requested archetype + * (see ArchetypeApplier) and git-inits it. Every external process goes through the ProcessRunner seam so + * the whole flow is assertable without a network. + */ #[AsCommand(name: 'new', description: 'Create a new LaraFly application')] final class NewCommand extends Command { + /** The interactive picker's explicit opt-out; never a capability id. */ + private const string NO_CAPABILITIES = 'none'; + public function __construct(private readonly ?ProcessRunner $runner = null) { parent::__construct(); @@ -25,9 +36,20 @@ protected function configure(): void $this ->addArgument('name', InputArgument::OPTIONAL, 'The name/path of the new application') ->addOption('dev', null, InputOption::VALUE_NONE, 'Install the latest dev release of the family') - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Scaffold even if the target directory is not empty') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Empty the target directory first, then scaffold into it') ->addOption('git', null, InputOption::VALUE_NONE, 'Initialise a git repository (default)') - ->addOption('no-git', null, InputOption::VALUE_NONE, 'Skip git initialisation'); + ->addOption('no-git', null, InputOption::VALUE_NONE, 'Skip git initialisation') + ->addOption('api', null, InputOption::VALUE_NONE, 'Archetype: '.Archetype::Api->summary()) + ->addOption('web', null, InputOption::VALUE_NONE, 'Archetype (default): '.Archetype::Web->summary()) + ->addOption('full', null, InputOption::VALUE_NONE, 'Archetype: '.Archetype::Full->summary()) + ->addOption( + 'with', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + // Interpolated from the catalog rather than written out, so `--help` can never drift from + // what `--with=` actually accepts. + 'Comma-separated capabilities to pre-wire: '.implode(', ', CapabilityCatalog::ids()), + ); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -53,13 +75,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int default => $cwd.'/'.$name, }; - if ($this->directoryIsNotEmpty($directory) && ! $input->getOption('force')) { - $io->error("Application directory \"{$name}\" already exists. Use --force to overwrite."); + if (($status = $this->ensureTargetIsUsable($io, $input, $directory, $name)) !== null) { + return $status; + } + + try { + $archetype = $this->resolveArchetype($io, $input); + $capabilities = $this->resolveCapabilities($io, $input, $archetype); + } catch (InvalidArgumentException $e) { + $io->error($e->getMessage()); return self::FAILURE; } $io->title('Creating a new LaraFly application'); + // Plain text rather than definitionList()/block(): SymfonyStyle wraps and pads block output to the + // terminal width, which turns a long absolute path into two half-lines. A generated project's own + // directory is the one string in this summary the user is most likely to want to copy. + $io->text([ + 'Directory '.$directory, + 'Archetype '.$archetype->value.' — '.$archetype->summary(), + 'Capabilities '.($capabilities === [] + ? 'none (add them later with composer require)' + : implode(', ', array_map(static fn (Capability $c): string => $c->id, $capabilities))), + ]); + $io->newLine(); $create = ['composer', 'create-project', 'firefly/skeleton', $directory, '--no-interaction']; if ($input->getOption('dev')) { @@ -71,6 +111,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::FAILURE; } + $notes = (new ArchetypeApplier($archetype, $capabilities))->applyTo($directory); + if ($notes !== []) { + $io->section('Applied the '.$archetype->value.' archetype'); + $io->listing($notes); + } + + if ($archetype->reshapesFiles()) { + // The skeleton's post-create-project-cmd ends in `php artisan firefly:cache`, so the compiled + // manifests describe the project as create-project left it — including the controller the + // archetype has just pruned. ArchetypeApplier already deleted those artifacts (an app with no + // manifests boots by scanning and is always correct); this puts the COMPILED path back, which + // is the one the skeleton hands the user. A failure here is not fatal: the app is scanned, and + // firefly:serve now says so and names the command that fixes it. + if ($runner->run(['php', 'artisan', 'firefly:cache'], $directory) !== 0) { + $io->warning('Could not recompile the manifests; the app will boot by scanning. Run `php artisan firefly:cache` when convenient.'); + } + } + if (! $input->getOption('no-git')) { $runner->run(['git', 'init', '-q'], $directory); $runner->run(['git', 'add', '.'], $directory); @@ -79,19 +137,158 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success("LaraFly application ready at {$directory}"); $io->writeln(" cd {$name}"); + if ($capabilities !== []) { + // The archetype only WROTE the capability requires; create-project had already resolved the + // skeleton's own by then, so vendor/ does not hold them yet. Re-resolving here would mean a + // second full Composer run inside `firefly new` for a command the user can see and skip. + $io->writeln(' composer update # install the capabilities the archetype added'); + } $io->writeln(' php artisan firefly:serve'); return self::SUCCESS; } - private function directoryIsNotEmpty(string $directory): bool + /** + * Guard — and, under --force, actually clear — the target directory. + * + * THE BUG THIS REPLACES: the old code skipped its OWN "directory not empty" error when --force was set + * and then shelled straight into `composer create-project`, which refuses a non-empty target on its own + * ("Project directory ... is not empty."). Composer has no --force for create-project, so --force could + * never do anything but turn a clear installer error into a confusing composer one. There is no flag to + * reach for; the only honest implementation is to empty the directory ourselves first, and to say so + * before doing it. + * + * @return int|null a status code to return from execute(), or null to continue + */ + private function ensureTargetIsUsable(SymfonyStyle $io, InputInterface $input, string $directory, string $name): ?int { - if (! is_dir($directory)) { - return false; + if (! Filesystem::directoryIsNotEmpty($directory)) { + return null; + } + + if (! $input->getOption('force')) { + $io->error("Application directory \"{$name}\" already exists. Use --force to overwrite."); + + return self::FAILURE; + } + + if (Filesystem::isProtectedPath($directory)) { + $io->error("Refusing to empty \"{$directory}\": it is a filesystem root or your home directory."); + + return self::FAILURE; + } + + $io->warning('--force: everything below will be permanently deleted.'); + $io->writeln(' '.$directory); + $io->newLine(); + // Defaults to yes so that --force stays meaningful under --no-interaction (Symfony returns the + // default without asking when the input is not interactive) while an interactive run still gets to + // read the path before agreeing to lose it. + if (! $io->confirm('Continue?', true)) { + $io->writeln('Aborted.'); + + return self::FAILURE; + } + + if (! Filesystem::emptyDirectory($directory)) { + $io->error("Could not empty \"{$directory}\" — check the permissions and try again."); + + return self::FAILURE; + } + + return null; + } + + /** + * @throws InvalidArgumentException when more than one archetype flag is passed + */ + private function resolveArchetype(SymfonyStyle $io, InputInterface $input): Archetype + { + $flagged = Archetype::fromFlags([ + Archetype::Api->value => (bool) $input->getOption('api'), + Archetype::Web->value => (bool) $input->getOption('web'), + Archetype::Full->value => (bool) $input->getOption('full'), + ]); + if ($flagged instanceof Archetype) { + return $flagged; + } + + if (! $input->isInteractive()) { + return Archetype::Web; + } + + $choices = []; + foreach (Archetype::cases() as $case) { + $choices[$case->value] = $case->summary(); + } + $answer = $io->choice('Which application shape?', $choices, Archetype::Web->value); + + return Archetype::from(is_string($answer) ? $answer : Archetype::Web->value); + } + + /** + * The archetype's own capabilities merged with `--with=` (or, when neither was given and the terminal + * is interactive, with whatever the picker returns). Under --no-interaction with no flags the answer is + * the archetype's default and nothing is ever asked. + * + * @return list + * + * @throws InvalidArgumentException on an unknown capability id + */ + private function resolveCapabilities(SymfonyStyle $io, InputInterface $input, Archetype $archetype): array + { + $requested = $this->parseWith($input); + $askable = $requested === [] && $archetype !== Archetype::Full && $input->isInteractive(); + + if ($askable) { + // 'none' is a real choice rather than "just press enter", because Symfony's multi-select + // ChoiceQuestion validates the DEFAULT through the same regex as any typed answer and rejects + // the empty string outright ('Value "" is invalid'). An explicit opt-out is also the clearer + // prompt: "which capabilities?" with no visible way to answer "none" reads like a trap. + $choices = [self::NO_CAPABILITIES => 'Just the framework — add capabilities later']; + foreach (CapabilityCatalog::all() as $id => $capability) { + $choices[$id] = $capability->summary; + } + $answer = $io->choice('Which capabilities?', $choices, self::NO_CAPABILITIES, multiSelect: true); + foreach (is_array($answer) ? $answer : [$answer] as $picked) { + if (is_string($picked) && $picked !== '' && $picked !== self::NO_CAPABILITIES) { + $requested[] = strtolower(trim($picked)); + } + } + } + + $ids = array_map(static fn (Capability $c): string => $c->id, $archetype->capabilities()); + + return CapabilityCatalog::resolve([...$ids, ...$requested]); + } + + /** + * `--with=security,eda` and `--with=security --with=eda` are the same thing: VALUE_IS_ARRAY collects the + * repeats, and each value is split on commas. + * + * @return list + */ + private function parseWith(InputInterface $input): array + { + $raw = $input->getOption('with'); + if (! is_array($raw)) { + $raw = $raw === null ? [] : [$raw]; + } + + $ids = []; + foreach ($raw as $value) { + if (! is_string($value)) { + continue; + } + foreach (explode(',', $value) as $id) { + $id = strtolower(trim($id)); + if ($id !== '') { + $ids[] = $id; + } + } } - $entries = scandir($directory); - return $entries !== false && count($entries) > 2; // more than '.' and '..' + return $ids; } private function isAbsolutePath(string $path): bool diff --git a/packages/installer/stubs/api/tests/Feature/ApiSmokeTest.php.stub b/packages/installer/stubs/api/tests/Feature/ApiSmokeTest.php.stub new file mode 100644 index 0000000..ba9cabe --- /dev/null +++ b/packages/installer/stubs/api/tests/Feature/ApiSmokeTest.php.stub @@ -0,0 +1,32 @@ +getJson('/greetings/Ada') + ->assertOk() + ->assertExactJson(['message' => 'Hello, Ada!']); + } + + public function test_the_actuator_reports_health(): void + { + $this->getJson('/actuator/health') + ->assertOk() + ->assertJsonPath('status', 'UP'); + } +} diff --git a/packages/installer/tests/ArchetypeTest.php b/packages/installer/tests/ArchetypeTest.php new file mode 100644 index 0000000..7abd94e --- /dev/null +++ b/packages/installer/tests/ArchetypeTest.php @@ -0,0 +1,327 @@ + $input + * @param string|null $source the directory the fake create-project materialises; the real skeleton by default + * @return array{tester: CommandTester, dir: string, runner: FakeProcessRunner} + */ +function generate(array $input, ?string $source = null): array +{ + $dir = sys_get_temp_dir().'/farch-'.bin2hex(random_bytes(6)).'/app'; + $runner = Skeleton::creatingRunner($source ?? Skeleton::path()); + $command = new NewCommand($runner); + (new Application)->addCommand($command); + $tester = new CommandTester($command); + $tester->execute(['name' => $dir, '--no-git' => true, ...$input], ['interactive' => false]); + + return ['tester' => $tester, 'dir' => $dir, 'runner' => $runner]; +} + +/** + * A private copy of the real skeleton that a test may mutate before create-project "produces" it — used to + * reproduce the two states the monorepo checkout never shows: a skeleton whose post-create-project-cmd has + * already run firefly:cache, and one whose tests/ was export-ignored out of the tarball. + * + * @param callable(string): void $mutate + */ +function skeletonWhere(callable $mutate): string +{ + $source = sys_get_temp_dir().'/fsrc-'.bin2hex(random_bytes(6)); + Skeleton::copy(Skeleton::path(), $source); + $mutate($source); + + return $source; +} + +function cleanUp(string $dir): void +{ + exec('rm -rf '.escapeshellarg(dirname($dir))); +} + +it('leaves the skeleton exactly as shipped for the default web archetype', function () { + ['tester' => $tester, 'dir' => $dir] = generate([]); + + try { + $tester->assertCommandIsSuccessful(); + + // Same file set as the skeleton, byte-for-byte except the manifest the archetype stamps. + expect(Skeleton::files($dir))->toBe(Skeleton::files(Skeleton::path())) + ->and(Skeleton::stamp($dir))->toBe(['archetype' => 'web', 'capabilities' => []]) + ->and(Skeleton::requirements($dir))->toBe(Skeleton::requirements(Skeleton::path())); + } finally { + cleanUp($dir); + } +}); + +it('strips the view layer, the welcome page and its test for --api', function () { + ['tester' => $tester, 'dir' => $dir] = generate(['--api' => true]); + + try { + $tester->assertCommandIsSuccessful(); + $files = Skeleton::files($dir); + + // Non-vacuity guard: a `not->toContain()` on a path the skeleton no longer ships would pass while + // the archetype quietly pruned nothing. Assert the skeleton HAS these first, so renaming any of + // them upstream fails here instead of in a user's generated project. + expect(Skeleton::files(Skeleton::path())) + ->toContain('app/Http/WelcomeController.php') + ->toContain('resources/views/welcome.blade.php') + ->toContain('tests/Feature/WelcomeTest.php'); + + expect($files) + ->not->toContain('app/Http/WelcomeController.php') + ->not->toContain('resources/views/welcome.blade.php') + ->not->toContain('tests/Feature/WelcomeTest.php') + // the JSON slice is the whole point of the archetype and must survive + ->toContain('app/Http/GreetingController.php') + ->toContain('app/GreetingService.php') + // ...and the welcome test is replaced, not merely deleted: a generated project whose first + // `composer test` is red is a worse first impression than one with no view layer. + ->toContain('tests/Feature/ApiSmokeTest.php'); + + // resources/ held nothing but the welcome view, so the empty shell goes with it. + expect(is_dir($dir.'/resources'))->toBeFalse(); + + expect(Skeleton::stamp($dir))->toBe(['archetype' => 'api', 'capabilities' => []]); + } finally { + cleanUp($dir); + } +}); + +it('writes an api smoke test that actually compiles against the skeleton it replaces', function () { + ['tester' => $tester, 'dir' => $dir] = generate(['--api' => true]); + + try { + $stub = (string) file_get_contents($dir.'/tests/Feature/ApiSmokeTest.php'); + $skeleton = Skeleton::path(); + + // The stub extends the skeleton's own base test case and exercises the skeleton's own sample route. + // If either is renamed, this fails here rather than in a user's freshly generated project. + expect($stub)->toContain('namespace Tests\Feature;')->toContain('extends TestCase') + ->and(file_get_contents($skeleton.'/tests/TestCase.php'))->toContain('namespace Tests;') + ->and(file_get_contents($skeleton.'/app/Http/GreetingController.php'))->toContain('/greetings/{name}') + ->and($stub)->toContain('/greetings/Ada'); + } finally { + cleanUp($dir); + } +}); + +it('adds exactly the requested capabilities, at the constraint the skeleton already uses', function () { + ['tester' => $tester, 'dir' => $dir] = generate(['--with' => ['security,eda']]); + + try { + $tester->assertCommandIsSuccessful(); + $require = Skeleton::requirements($dir); + + expect($require)->toHaveKey('firefly/security')->toHaveKey('firefly/eda') + // the constraint is copied off firefly/firefly, so it tracks the skeleton across releases + // instead of pinning a literal that goes stale the day the family reaches 1.0 + ->and($require['firefly/security'])->toBe($require['firefly/firefly']) + ->and($require['firefly/eda'])->toBe($require['firefly/firefly']) + // nothing else crept in + ->and(array_values(array_filter(array_keys($require), fn (string $p): bool => str_starts_with($p, 'firefly/')))) + ->toBe(['firefly/cli', 'firefly/eda', 'firefly/firefly', 'firefly/security']) + ->and(Skeleton::stamp($dir)['capabilities'])->toBe(['security', 'eda']); + + // composer's sort-packages ordering: platform first, then natural case-insensitive name. + expect(array_keys($require))->toBe(['php', 'firefly/cli', 'firefly/eda', 'firefly/firefly', 'firefly/security', 'laravel/framework']); + } finally { + cleanUp($dir); + } +}); + +it('pulls an adapter capability port in with it', function () { + ['tester' => $tester, 'dir' => $dir] = generate(['--with' => ['eda-kafka']]); + + try { + $tester->assertCommandIsSuccessful(); + // A Kafka publisher with no EventPublisher port to implement is not a shape worth generating. + expect(Skeleton::stamp($dir)['capabilities'])->toBe(['eda', 'eda-kafka']) + ->and(Skeleton::requirements($dir))->toHaveKey('firefly/eda')->toHaveKey('firefly/eda-kafka'); + } finally { + cleanUp($dir); + } +}); + +it('pre-wires every non-adapter capability for --full while keeping the web file set', function () { + ['tester' => $tester, 'dir' => $dir] = generate(['--full' => true]); + + try { + $tester->assertCommandIsSuccessful(); + + expect(Skeleton::files($dir))->toContain('app/Http/WelcomeController.php') + ->toContain('resources/views/welcome.blade.php'); + + // Adapters bind the app to one broker or engine; --full cannot make that choice for the user. + expect(Skeleton::requirements($dir)) + ->toHaveKey('firefly/security')->toHaveKey('firefly/eda')->toHaveKey('firefly/scheduling') + ->toHaveKey('firefly/admin')->toHaveKey('firefly/resilience') + ->not->toHaveKey('firefly/eda-kafka') + ->not->toHaveKey('firefly/eda-rabbitmq') + ->not->toHaveKey('firefly/scheduling-postgres') + // the test kit is a dev dependency and lands on the right side of the manifest + ->not->toHaveKey('firefly/testing'); + expect(Skeleton::requirements($dir, 'require-dev'))->toHaveKey('firefly/testing') + ->and(Skeleton::stamp($dir)['archetype'])->toBe('full'); + } finally { + cleanUp($dir); + } +}); + +it('combines --api with --with instead of making the user choose', function () { + ['tester' => $tester, 'dir' => $dir] = generate(['--api' => true, '--with' => ['security', 'cqrs']]); + + try { + $tester->assertCommandIsSuccessful(); + expect(Skeleton::files($dir))->not->toContain('resources/views/welcome.blade.php'); + expect(Skeleton::stamp($dir)) + ->toBe(['archetype' => 'api', 'capabilities' => ['security', 'cqrs']]); + } finally { + cleanUp($dir); + } +}); + +it('produces a composer.json composer itself can still parse', function () { + ['dir' => $dir] = generate(['--full' => true]); + + try { + $raw = (string) file_get_contents($dir.'/composer.json'); + expect(json_decode($raw, true))->toBeArray() + ->and(str_ends_with($raw, "}\n"))->toBeTrue() // trailing newline, as composer writes it + ->and($raw)->toContain(' "require": {'); // four-space indent, as composer writes it + } finally { + cleanUp($dir); + } +}); + +/** + * THE REGRESSION, verified end-to-end against a real `composer create-project` before it was fixed. + * + * The skeleton's post-create-project-cmd ends in `php artisan firefly:cache`, so create-project hands back + * a project whose compiled routes.php and component.php already name App\Http\WelcomeController — the + * class `--api` is about to delete. Left in place, the generated project answered `GET /` with a 500 + * ("Target class [App\Http\WelcomeController] does not exist") instead of a 404, because the compiled + * manifest outlived the class it pointed at. + */ +it('drops the compiled manifests that name the class it just pruned', function () { + $source = skeletonWhere(static function (string $skeleton): void { + $cache = $skeleton.'/bootstrap/cache/firefly'; + // Exactly what `php artisan firefly:cache` left behind, including a proxies/ subdirectory. + file_put_contents($cache.'/routes.php', " [App\\Http\\WelcomeController::class, 'index']];"); + file_put_contents($cache.'/component.php', ' $tester, 'dir' => $dir] = generate(['--api' => true], $source); + + try { + $tester->assertCommandIsSuccessful(); + $cache = $dir.'/bootstrap/cache/firefly'; + + expect(is_file($cache.'/routes.php'))->toBeFalse() + ->and(is_file($cache.'/component.php'))->toBeFalse() + ->and(is_dir($cache.'/proxies'))->toBeFalse() + // .gitkeep survives: skeleton/.gitignore ignores the directory's contents but negates this one + // file, so removing it would drop bootstrap/cache/firefly out of the user's first commit. + ->and(is_file($cache.'/.gitkeep'))->toBeTrue(); + } finally { + cleanUp($dir); + exec('rm -rf '.escapeshellarg($source)); + } +}); + +it('leaves the compiled manifests alone for an archetype that reshapes nothing', function () { + $source = skeletonWhere(static function (string $skeleton): void { + file_put_contents($skeleton.'/bootstrap/cache/firefly/routes.php', ' $tester, 'dir' => $dir, 'runner' => $runner] = generate([], $source); + + try { + $tester->assertCommandIsSuccessful(); + // web prunes nothing, so nothing it compiled has gone stale and there is no reason to pay for a + // second `firefly:cache` run in a command the user is watching. + expect(is_file($dir.'/bootstrap/cache/firefly/routes.php'))->toBeTrue(); + + $programs = array_map(static fn (array $c): string => implode(' ', $c['command']), $runner->calls); + expect($programs)->not->toContain('php artisan firefly:cache'); + } finally { + cleanUp($dir); + exec('rm -rf '.escapeshellarg($source)); + } +}); + +it('recompiles the manifests it invalidated, in the generated project', function () { + ['tester' => $tester, 'dir' => $dir, 'runner' => $runner] = generate(['--api' => true]); + + try { + $tester->assertCommandIsSuccessful(); + + $cache = array_values(array_filter( + $runner->calls, + static fn (array $c): bool => $c['command'] === ['php', 'artisan', 'firefly:cache'], + )); + + // ...and in the NEW project's directory, not the installer's cwd. + expect($cache)->toHaveCount(1) + ->and($cache[0]['cwd'])->toBe($dir); + } finally { + cleanUp($dir); + } +}); + +/** + * `skeleton/.gitattributes` marks `/tests export-ignore`, so a real `composer create-project` ships no + * tests/ directory — no tests/TestCase.php for `extends TestCase` to resolve. Copying the api smoke test in + * anyway turned the generated project's first `composer test` from the web baseline's "Test directory not + * found" (exit 2) into a `Class "Tests\TestCase" not found` FATAL (exit 255), which is worse than adding + * nothing at all. Both states were reproduced against a real create-project. + */ +it('skips the api smoke test when the generated project ships no base test case', function () { + $source = skeletonWhere(static function (string $skeleton): void { + exec('rm -rf '.escapeshellarg($skeleton.'/tests')); + }); + + ['tester' => $tester, 'dir' => $dir] = generate(['--api' => true], $source); + + try { + $tester->assertCommandIsSuccessful(); + + expect(is_file($dir.'/tests/Feature/ApiSmokeTest.php'))->toBeFalse() + ->and(is_dir($dir.'/tests'))->toBeFalse() + // and it says so, rather than silently doing nothing + ->and($tester->getDisplay())->toContain('tests/TestCase.php'); + } finally { + cleanUp($dir); + exec('rm -rf '.escapeshellarg($source)); + } +}); + +it('still writes the api smoke test when the base test case is there', function () { + ['dir' => $dir] = generate(['--api' => true]); + + try { + // Non-vacuity guard for the case above: the prerequisite the applier checks must be a file the + // skeleton actually ships, or the skip branch would be the only branch that ever runs. + expect(is_file(Skeleton::path().'/tests/TestCase.php'))->toBeTrue() + ->and(is_file($dir.'/tests/Feature/ApiSmokeTest.php'))->toBeTrue(); + } finally { + cleanUp($dir); + } +}); diff --git a/packages/installer/tests/CapabilityCatalogTest.php b/packages/installer/tests/CapabilityCatalogTest.php new file mode 100644 index 0000000..532a60c --- /dev/null +++ b/packages/installer/tests/CapabilityCatalogTest.php @@ -0,0 +1,107 @@ + every `name` in packages/ * /composer.json + */ +function familyPackages(): array +{ + $root = dirname(__DIR__, 3).'/packages'; // tests -> installer -> packages -> root + $names = []; + foreach ((array) glob($root.'/*/composer.json') as $manifest) { + if (! is_string($manifest)) { + continue; + } + $decoded = json_decode((string) file_get_contents($manifest), true); + if (is_array($decoded) && isset($decoded['name']) && is_string($decoded['name'])) { + $names[] = $decoded['name']; + } + } + sort($names); + + return $names; +} + +it('accounts for every firefly package in the monorepo', function () { + $family = familyPackages(); + expect($family)->not->toBeEmpty(); + + $known = array_merge( + array_map(static fn (Capability $c): string => $c->package, array_values(CapabilityCatalog::all())), + array_keys(CapabilityCatalog::corePackages()), + ); + + $unaccounted = array_values(array_diff($family, $known)); + + expect($unaccounted)->toBe([], sprintf( + 'These packages exist in packages/ but are neither a `firefly new --with=` capability nor listed in ' + .'CapabilityCatalog::corePackages(): %s. Decide which they are — a capability the picker offers, or ' + .'framework plumbing with a stated reason.', + implode(', ', $unaccounted), + )); +}); + +it('never offers a capability whose package does not exist', function () { + $family = familyPackages(); + + $missing = array_values(array_filter( + array_map(static fn (Capability $c): string => $c->package, array_values(CapabilityCatalog::all())), + static fn (string $package): bool => ! in_array($package, $family, true), + )); + + // toContain() is variadic in Pest, so a "message" argument would silently become a second needle — + // asserting the family contains a sentence. Diffing the two lists says the same thing and cannot lie. + expect($missing)->toBe([]); +}); + +it('keys every capability by its own id', function () { + foreach (CapabilityCatalog::all() as $id => $capability) { + expect($capability->id)->toBe($id); + } +}); + +it('resolves an implied port before its adapter', function () { + $ids = array_map(static fn (Capability $c): string => $c->id, CapabilityCatalog::resolve(['scheduling-postgres'])); + + expect($ids)->toBe(['scheduling', 'scheduling-postgres']); +}); + +it('deduplicates a capability requested twice, directly and transitively', function () { + $ids = array_map(static fn (Capability $c): string => $c->id, CapabilityCatalog::resolve(['eda', 'eda-kafka', 'eda'])); + + expect($ids)->toBe(['eda', 'eda-kafka']); +}); + +it('rejects an unknown id with the list of real ones', function () { + expect(fn () => CapabilityCatalog::resolve(['nope'])) + ->toThrow(InvalidArgumentException::class, 'Unknown capability "nope"'); +}); + +it('leaves infrastructure adapters out of --full', function () { + $ids = array_map(static fn (Capability $c): string => $c->id, CapabilityCatalog::full()); + + foreach (CapabilityCatalog::all() as $capability) { + expect(in_array($capability->id, $ids, true))->toBe(! $capability->adapter); + } + expect($ids)->not->toBeEmpty(); +}); + +it('marks only the test kit as a dev dependency', function () { + $dev = array_values(array_map( + static fn (Capability $c): string => $c->package, + array_filter(CapabilityCatalog::all(), static fn (Capability $c): bool => $c->dev), + )); + + expect($dev)->toBe(['firefly/testing']); +}); diff --git a/packages/installer/tests/CreateProjectInstallerTest.php b/packages/installer/tests/CreateProjectInstallerTest.php index 6ee7870..caa9d59 100644 --- a/packages/installer/tests/CreateProjectInstallerTest.php +++ b/packages/installer/tests/CreateProjectInstallerTest.php @@ -4,6 +4,7 @@ use Firefly\Installer\NewCommand; use Firefly\Installer\SymfonyProcessRunner; +use Firefly\Installer\Tests\Support\Skeleton; use Symfony\Component\Console\Application; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Tester\CommandTester; @@ -50,9 +51,13 @@ $tester = new CommandTester($command); try { - $tester->execute(['name' => $work.'/my-app', '--dev' => true, '--no-git' => true]); + // interactive:false — `new` prompts for the archetype and the capabilities when neither is + // flagged, and CommandTester is interactive by default with no input stream to answer from. + $tester->execute(['name' => $work.'/my-app', '--dev' => true, '--no-git' => true], ['interactive' => false]); expect(is_file($work.'/my-app/artisan'))->toBeTrue() - ->and(is_file($work.'/my-app/bootstrap/cache/firefly/routes.php'))->toBeTrue(); + ->and(is_file($work.'/my-app/bootstrap/cache/firefly/routes.php'))->toBeTrue() + // the default archetype shaped a real create-project result, not just a fixture + ->and(Skeleton::stamp($work.'/my-app'))->toBe(['archetype' => 'web', 'capabilities' => []]); } finally { (new Process(['rm', '-rf', $work]))->run(); putenv('COMPOSER_HOME'); diff --git a/packages/installer/tests/FilesystemTest.php b/packages/installer/tests/FilesystemTest.php new file mode 100644 index 0000000..5285355 --- /dev/null +++ b/packages/installer/tests/FilesystemTest.php @@ -0,0 +1,115 @@ +toBeFalse(); + file_put_contents($dir.'/.hidden', 'x'); + expect(Filesystem::directoryIsNotEmpty($dir))->toBeTrue(); + expect(Filesystem::directoryIsNotEmpty($dir.'/does-not-exist'))->toBeFalse(); + } finally { + exec('rm -rf '.escapeshellarg($dir)); + } +}); + +it('empties a tree without removing the directory itself', function () { + $dir = tempDir(); + mkdir($dir.'/a/b/c', 0o755, true); + file_put_contents($dir.'/a/b/c/deep.txt', 'x'); + file_put_contents($dir.'/.dotfile', 'x'); + + try { + expect(Filesystem::emptyDirectory($dir))->toBeTrue() + ->and(is_dir($dir))->toBeTrue() + ->and(scandir($dir))->toBe(['.', '..']); + } finally { + exec('rm -rf '.escapeshellarg($dir)); + } +}); + +/** + * The one that would have hurt. RecursiveDirectoryIterator reports a symlinked directory as a directory, + * so an emptyDirectory() that branched on isDir() alone would call rmdir() on the LINK — and, worse, a + * CHILD_FIRST walk that descended through it would delete the target's contents first. `firefly new . --force` + * in a directory holding a `current -> ~/work` link would have taken ~/work with it. + */ +it('unlinks a symlinked directory instead of following it', function () { + $dir = tempDir(); + $victim = tempDir(); + file_put_contents($victim.'/precious.txt', 'x'); + symlink($victim, $dir.'/link'); + + try { + expect(Filesystem::emptyDirectory($dir))->toBeTrue() + ->and(scandir($dir))->toBe(['.', '..']) + ->and(is_file($victim.'/precious.txt'))->toBeTrue(); + } finally { + exec('rm -rf '.escapeshellarg($dir).' '.escapeshellarg($victim)); + } +}); + +it('treats the filesystem root and the home directory as protected', function () { + $dir = tempDir(); + $home = getenv('HOME'); + + try { + expect(Filesystem::isProtectedPath('/'))->toBeTrue() + ->and(Filesystem::isProtectedPath($dir))->toBeFalse(); + + putenv("HOME={$dir}"); + expect(Filesystem::isProtectedPath($dir))->toBeTrue(); + + // A path that does not exist yet cannot be destroyed, so it is never "protected". + expect(Filesystem::isProtectedPath($dir.'/not-created'))->toBeFalse(); + } finally { + is_string($home) ? putenv("HOME={$home}") : putenv('HOME'); + exec('rm -rf '.escapeshellarg($dir)); + } +}); + +it('prunes directories the prune emptied, and stops at the root', function () { + $root = tempDir(); + mkdir($root.'/resources/views', 0o755, true); + file_put_contents($root.'/resources/views/welcome.blade.php', 'x'); + + try { + Filesystem::delete($root.'/resources/views/welcome.blade.php'); + Filesystem::pruneEmptyDirectories($root, $root.'/resources/views'); + + expect(is_dir($root.'/resources'))->toBeFalse() + ->and(is_dir($root))->toBeTrue(); // the project root is never a candidate + } finally { + exec('rm -rf '.escapeshellarg($root)); + } +}); + +it('stops pruning at the first directory that still holds something', function () { + $root = tempDir(); + mkdir($root.'/app/Http', 0o755, true); + file_put_contents($root.'/app/GreetingService.php', 'x'); + file_put_contents($root.'/app/Http/WelcomeController.php', 'x'); + + try { + Filesystem::delete($root.'/app/Http/WelcomeController.php'); + Filesystem::pruneEmptyDirectories($root, $root.'/app/Http'); + + expect(is_dir($root.'/app/Http'))->toBeFalse() + ->and(is_file($root.'/app/GreetingService.php'))->toBeTrue() + ->and(is_dir($root.'/app'))->toBeTrue(); + } finally { + exec('rm -rf '.escapeshellarg($root)); + } +}); diff --git a/packages/installer/tests/NewCommandTest.php b/packages/installer/tests/NewCommandTest.php index b2fd89e..16311d7 100644 --- a/packages/installer/tests/NewCommandTest.php +++ b/packages/installer/tests/NewCommandTest.php @@ -7,13 +7,22 @@ use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; -/** @param array $input */ -function runNew(FakeProcessRunner $runner, array $input): CommandTester +/** + * @param array $input + * @param list $answers keystrokes for an INTERACTIVE run; [] runs non-interactively + */ +function runNew(FakeProcessRunner $runner, array $input, array $answers = []): CommandTester { $command = new NewCommand($runner); (new Application)->addCommand($command); $tester = new CommandTester($command); - $tester->execute($input); + if ($answers !== []) { + $tester->setInputs($answers); + } + // CommandTester is interactive by default. `new` now prompts for the archetype and the capability + // list when neither is flagged, so a test that means "just run it" has to say so explicitly — + // otherwise every legacy case below would block on a question it never meant to answer. + $tester->execute($input, $answers === [] ? ['interactive' => false] : []); return $tester; } @@ -64,17 +73,26 @@ function runNew(FakeProcessRunner $runner, array $input): CommandTester exec('rm -rf '.escapeshellarg($dir)); }); -it('proceeds with scaffolding a non-empty directory when --force is passed', function () { +/** + * THE REGRESSION. `--force` used to skip the installer's own "directory is not empty" error and then hand + * the still-non-empty directory to `composer create-project`, which refuses it too ("Project directory ... + * is not empty.") and has no flag that says otherwise. The promise could never be kept; the user got a + * confusing composer error instead of a clear installer one. --force now empties the directory itself. + */ +it('empties the target directory under --force before create-project runs', function () { $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); - mkdir($dir, 0o755, true); + mkdir($dir.'/nested/deeper', 0o755, true); file_put_contents($dir.'/keep.txt', 'x'); + file_put_contents($dir.'/.hidden', 'x'); + file_put_contents($dir.'/nested/deeper/buried.txt', 'x'); $runner = new FakeProcessRunner; try { $tester = runNew($runner, ['name' => $dir, '--force' => true, '--no-git' => true]); $tester->assertCommandIsSuccessful(); - expect($runner->calls)->not->toBeEmpty() + expect(is_dir($dir))->toBeTrue() // the directory itself survives + ->and(scandir($dir))->toBe(['.', '..']) // ...but nothing inside it does ->and($runner->calls[0]['command'])->toBe( ['composer', 'create-project', 'firefly/skeleton', $dir, '--no-interaction'] ); @@ -83,6 +101,58 @@ function runNew(FakeProcessRunner $runner, array $input): CommandTester } }); +it('warns exactly what --force is about to delete', function () { + $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); + mkdir($dir, 0o755, true); + file_put_contents($dir.'/keep.txt', 'x'); + + try { + $tester = runNew(new FakeProcessRunner, ['name' => $dir, '--force' => true, '--no-git' => true]); + + expect($tester->getDisplay())->toContain('permanently deleted')->toContain($dir); + } finally { + exec('rm -rf '.escapeshellarg($dir)); + } +}); + +it('deletes nothing when the interactive --force confirmation is declined', function () { + $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); + mkdir($dir, 0o755, true); + file_put_contents($dir.'/keep.txt', 'x'); + $runner = new FakeProcessRunner; + + try { + $tester = runNew($runner, ['name' => $dir, '--force' => true, '--no-git' => true], answers: ['no']); + + expect($tester->getStatusCode())->toBe(1) + ->and(is_file($dir.'/keep.txt'))->toBeTrue() + ->and($runner->calls)->toBeEmpty(); + } finally { + exec('rm -rf '.escapeshellarg($dir)); + } +}); + +it('refuses to empty the home directory even with --force', function () { + $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); + mkdir($dir, 0o755, true); + file_put_contents($dir.'/precious.txt', 'x'); + $home = getenv('HOME'); + putenv("HOME={$dir}"); + $runner = new FakeProcessRunner; + + try { + $tester = runNew($runner, ['name' => $dir, '--force' => true, '--no-git' => true]); + + expect($tester->getStatusCode())->toBe(1) + ->and($tester->getDisplay())->toContain('Refusing to empty') + ->and(is_file($dir.'/precious.txt'))->toBeTrue() + ->and($runner->calls)->toBeEmpty(); + } finally { + is_string($home) ? putenv("HOME={$home}") : putenv('HOME'); + exec('rm -rf '.escapeshellarg($dir)); + } +}); + it('never shells a git command when --no-git is passed', function () { $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); $runner = new FakeProcessRunner; @@ -94,3 +164,51 @@ function runNew(FakeProcessRunner $runner, array $input): CommandTester expect($runner->calls)->toHaveCount(1) ->and($programs)->not->toContain('git'); }); + +it('rejects two archetype flags at once instead of silently picking one', function () { + $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); + $runner = new FakeProcessRunner; + + $tester = runNew($runner, ['name' => $dir, '--api' => true, '--full' => true, '--no-git' => true]); + + expect($tester->getStatusCode())->toBe(1) + ->and($tester->getDisplay())->toContain('mutually exclusive') + ->and($runner->calls)->toBeEmpty(); +}); + +it('rejects an unknown capability and names the ones that exist', function () { + $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); + $runner = new FakeProcessRunner; + + $tester = runNew($runner, ['name' => $dir, '--with' => ['security,teleportation'], '--no-git' => true]); + + expect($tester->getStatusCode())->toBe(1) + ->and($tester->getDisplay())->toContain('Unknown capability') + ->and($tester->getDisplay())->toContain('scheduling') + ->and($runner->calls)->toBeEmpty(); +}); + +it('stays fully non-interactive with no archetype flags, defaulting to web', function () { + $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); + $runner = new FakeProcessRunner; + + $tester = runNew($runner, ['name' => $dir, '--no-git' => true]); + + $tester->assertCommandIsSuccessful(); + expect($tester->getDisplay())->toContain('web') + ->and($tester->getDisplay())->not->toContain('Which application shape?'); +}); + +it('prompts for the archetype and the capabilities when the terminal is interactive', function () { + $dir = sys_get_temp_dir().'/fnew-'.bin2hex(random_bytes(5)); + $runner = new FakeProcessRunner; + + $tester = runNew($runner, ['name' => $dir, '--no-git' => true], answers: ['api', 'security,eda']); + + $tester->assertCommandIsSuccessful(); + expect($tester->getDisplay()) + ->toContain('Which application shape?') + ->toContain('Which capabilities?') + ->toContain('api') + ->toContain('security, eda'); +}); diff --git a/packages/installer/tests/Support/FakeProcessRunner.php b/packages/installer/tests/Support/FakeProcessRunner.php index f82c6a4..03ca397 100644 --- a/packages/installer/tests/Support/FakeProcessRunner.php +++ b/packages/installer/tests/Support/FakeProcessRunner.php @@ -4,6 +4,7 @@ namespace Firefly\Installer\Tests\Support; +use Closure; use Firefly\Installer\ProcessRunner; final class FakeProcessRunner implements ProcessRunner @@ -11,12 +12,28 @@ final class FakeProcessRunner implements ProcessRunner /** @var list, cwd: ?string}> */ public array $calls = []; - public function __construct(private readonly int $exitCode = 0) {} + /** + * @param int $exitCode the code every simulated process returns + * @param (Closure(list, ?string): void)|null $onRun a side effect to perform per invocation + * + * The $onRun hook exists so a test can make the fake `composer create-project` actually PRODUCE a + * project (see Skeleton::creatingRunner()). Without it the archetype shaping — which edits the + * generated composer.json and prunes generated files — has nothing to act on, and the only thing a test + * could assert about `firefly new --api` is the argv, which is precisely the half that was never broken. + */ + public function __construct( + private readonly int $exitCode = 0, + private readonly ?Closure $onRun = null, + ) {} public function run(array $command, ?string $cwd = null): int { $this->calls[] = ['command' => $command, 'cwd' => $cwd]; + if ($this->onRun !== null) { + ($this->onRun)($command, $cwd); + } + return $this->exitCode; } } diff --git a/packages/installer/tests/Support/Skeleton.php b/packages/installer/tests/Support/Skeleton.php new file mode 100644 index 0000000..2e77c39 --- /dev/null +++ b/packages/installer/tests/Support/Skeleton.php @@ -0,0 +1,177 @@ + tests -> installer -> packages -> root + if (! is_file($path.'/composer.json')) { + throw new RuntimeException("The monorepo skeleton is missing at {$path}."); + } + + return $path; + } + + /** + * A fake runner that behaves like `composer create-project`: on that argv, and only that argv, it + * materialises the skeleton at the target directory the command asked for. + */ + public static function creatingRunner(string $source): FakeProcessRunner + { + /** @param list $command */ + $materialise = static function (array $command) use ($source): void { + $target = $command[3] ?? null; + if (($command[1] ?? null) !== 'create-project' || ! is_string($target)) { + return; + } + self::copy($source, $target); + }; + + return new FakeProcessRunner(0, $materialise); + } + + public static function copy(string $source, string $target): void + { + self::directory($target); + + /** @var iterable $entries */ + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($entries as $entry) { + $destination = $target.'/'.substr($entry->getPathname(), strlen($source) + 1); + if ($entry->isDir()) { + self::directory($destination); + + continue; + } + self::directory(dirname($destination)); + copy($entry->getPathname(), $destination); + } + } + + /** + * mkdir() only when it is actually missing. `@mkdir()` would do — except that PHPUnit's error handler + * records diagnostics regardless of the suppression operator, so every already-existing parent turned + * a green test into a warned one. + */ + private static function directory(string $path): void + { + if (! is_dir($path)) { + mkdir($path, 0o755, true); + } + } + + /** + * Every file under $directory as a sorted list of project-relative paths — the "file set" an archetype + * assertion compares. + * + * @return list + */ + public static function files(string $directory): array + { + if (! is_dir($directory)) { + return []; + } + + $files = []; + /** @var iterable $entries */ + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($entries as $entry) { + if ($entry->isFile()) { + $files[] = substr($entry->getPathname(), strlen($directory) + 1); + } + } + sort($files); + + return $files; + } + + /** @return array */ + public static function manifest(string $directory): array + { + $raw = file_get_contents($directory.'/composer.json'); + $decoded = $raw === false ? null : json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * One `require` / `require-dev` block, narrowed once here so the assertions above stay readable — + * json_decode() hands back mixed all the way down, and repeating that guard per expectation buries the + * thing actually being asserted. + * + * @return array + */ + public static function requirements(string $directory, string $section = 'require'): array + { + $value = self::manifest($directory)[$section] ?? null; + if (! is_array($value)) { + return []; + } + + $map = []; + foreach ($value as $package => $constraint) { + if (is_string($package) && is_string($constraint)) { + $map[$package] = $constraint; + } + } + + return $map; + } + + /** + * The `extra.firefly` block the archetype stamps on the generated manifest. + * + * @return array{archetype: string, capabilities: list} + */ + public static function stamp(string $directory): array + { + $extra = self::manifest($directory)['extra'] ?? null; + $firefly = is_array($extra) ? ($extra['firefly'] ?? null) : null; + $firefly = is_array($firefly) ? $firefly : []; + + $archetype = $firefly['archetype'] ?? null; + $capabilities = []; + foreach (is_array($firefly['capabilities'] ?? null) ? $firefly['capabilities'] : [] as $id) { + if (is_string($id)) { + $capabilities[] = $id; + } + } + + return [ + 'archetype' => is_string($archetype) ? $archetype : '', + 'capabilities' => $capabilities, + ]; + } +} diff --git a/packages/openapi/.gitattributes b/packages/openapi/.gitattributes new file mode 100644 index 0000000..538b69a --- /dev/null +++ b/packages/openapi/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.gitattributes export-ignore diff --git a/packages/openapi/LICENSE b/packages/openapi/LICENSE new file mode 100644 index 0000000..2240005 --- /dev/null +++ b/packages/openapi/LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Copyright 2026 Firefly Software Solutions Inc. diff --git a/packages/openapi/README.md b/packages/openapi/README.md new file mode 100644 index 0000000..e452146 --- /dev/null +++ b/packages/openapi/README.md @@ -0,0 +1,147 @@ +# firefly/openapi + +OpenAPI 3.1 generation for LaraFly, from the manifests the framework already holds in memory. There is no +annotation dialect to learn and nothing to keep in sync by hand: `RouteManifest` supplies the paths, verbs, +statuses, route names and parameter bindings, `ConstraintManifest` supplies the request-body schemas and their +`required` lists, and `packages/kernel`'s `ErrorResponse` supplies the RFC 9457 error component. Install the +package and a LaraFly app has a spec — and therefore typed clients — for free. + +Because every fact in the document is read from the same compiled artifacts the dispatcher reads, the spec +cannot drift from the server. + +## What you get + +| Surface | Default | Purpose | +| --- | --- | --- | +| `GET /openapi.json` | on | The generated OpenAPI 3.1 document. | +| `GET /openapi` | on | A dependency-free API reference console. | +| `php artisan firefly:openapi` | — | Writes the document to a file (`--output=`) or to stdout. | + +Both routes are mounted natively on the Illuminate router from a `BootPass`, at a **configurable** path. That +is deliberate: an attribute route (`#[GetMapping('/openapi.json')]`) bakes its literal into a compiled +`RouteDescriptor`, so it could never be moved or taken off a public surface by configuration. It also means +this package's own two routes never enter the `RouteManifest`, so the generator never documents itself. + +## What the generator maps + +**Operations** come from each `RouteDescriptor`: the verb and path (Laravel's optional `{id?}` is normalised +to `{id}`, since a path parameter is required in OpenAPI), the `#[Mapping]`'s declared status, and the route +name as the `operationId` when one is set. Operations are tagged by controller short name, and paths, +verbs and components are all sorted so a regenerated document diffs cleanly. + +**Parameters** come from the binding plan — the same `kind` discriminator `ArgumentResolver` dispatches on at +request time. `#[PathVariable]`, `#[QueryParam]` and `#[RequestHeader]` become Parameter Objects; +`#[UploadedFile]` becomes a `multipart/form-data` part; a container-injected service is not part of the HTTP +contract and never appears. + +**Request bodies** come from the `#[RequestBody]` DTO, as a `$ref` into `components/schemas` — one component +per DTO, reused everywhere, with nested `#[Valid]` DTOs given their own component rather than being inlined +(a self-referential DTO therefore terminates, as a `$ref` cycle). + +**Property schemas** merge the DTO's declared types with its compiled constraints, because neither alone is +enough: types-only documents `#[NotBlank] string $name` as an unbounded string, constraints-only documents +`int $quantity` as a string. Backed enums, `DateTimeInterface` and nullability come from the type; everything +else comes from the manifest: + +| Constraint | JSON Schema | +| --- | --- | +| `#[NotNull]`, `#[NotBlank]`, `#[NotEmpty]` | member added to the parent's `required` | +| `#[NotBlank]` | `type: string` + `pattern: \S` | +| `#[Size(min, max)]` | `minLength`/`maxLength`, or `minItems`/`maxItems` on an array | +| `#[Min]` / `#[Max]` | `minimum` / `maximum` | +| `#[Positive]`, `#[Negative]`, `…OrZero` | `exclusiveMinimum` / `minimum` / … | +| `#[Email]` | `format: email` | +| `#[Pattern]` | `pattern` (PCRE delimiters and no-op flags stripped) | +| `#[UuidValue]`, `#[Phone]`, `#[Iban]`, `#[Bic]`, `#[Isin]`, … | `format` + a `pattern` where the rule matches the raw value | +| `#[Percentage]` | `type: number`, `minimum: 0`, `maximum: 100` | +| `#[DecimalScale(n)]`, `#[Money]` | `multipleOf` | +| `#[AssertTrue]` / `#[AssertFalse]` | `type: boolean` + `const` | + +A nullable member is spelled the 3.1 way — a `type` union with `"null"`, not 3.0's `nullable` keyword — +following the Jakarta null contract `ConstraintScanner` already applies. + +**Nothing is dropped silently.** Constraints JSON Schema cannot express (`#[Future]`'s "after now", a Luhn +checksum, a third-party `ValidationRule`) and ones it can only approximate (a PCRE pattern carrying flags +ECMA-262 has no syntax for) are recorded under the `x-firefly-constraints` specification extension. +Conforming tools ignore it; a human or a custom generator can read it. + +**Responses.** Every operation carries the shared `#/components/responses/Problem` as its `default`, plus a +`400` when `ArgumentResolver` has something it can reject before the controller runs, and a `422` when a +binding carries `#[Valid]`. The problem schema describes what LaraFly actually returns — RFC 9457's members +*plus* Firefly's `code`, `category`, `severity` and `errors`, with the category and severity enumerations read +straight off the kernel enums. + +## The viewer, and the CDN flag + +The default console at `/openapi` is a single self-contained HTML page: **no npm build at install time and no +network access at request time.** It groups operations by tag and resolves `$ref` pointers client-side so a +reader sees a DTO's members rather than a pointer. + +Every off-the-shelf viewer (Swagger UI, Redoc, Elements) is a bundled JavaScript application, which leaves +only two options: vendor a multi-megabyte bundle into a PHP package, or fetch it from a CDN on every page +view. The second is a supply-chain dependency and a data-protection question, and it does not render at all in +the air-gapped and strict-CSP environments where an internal API console is most wanted. + +Swagger UI is available for teams that want the full feature set: + +```php +'firefly' => ['openapi' => ['viewer' => ['cdn' => true]]], +``` + +**This flag defaults to `false`, and turning it on means the browser fetches code from `cdn.jsdelivr.net` on +every page view.** The version is pinned exactly; no Subresource Integrity hash is claimed, because a hash the +framework cannot verify at release time is security theatre. + +## Configuration + +```php +// config/firefly.php +'openapi' => [ + 'enabled' => true, // master gate: off means both routes are genuinely unrouted + 'path' => '/openapi.json', // spec route + 'viewer' => [ + 'enabled' => true, + 'path' => '/openapi', + 'cdn' => false, // opt in to Swagger UI over a CDN — see above + ], + 'title' => 'API', + 'version' => '0.0.0', + 'description' => '', + 'servers' => ['https://api.example.test'], // bare URLs or OpenAPI Server Objects + 'exclude' => '/internal,/admin', // CSV of path prefixes to leave out +], +``` + +Secure a public deployment the way you secure any other route — `firefly/security`'s `HttpSecurity` config +covers `/openapi*` with no code edge — or set `enabled` to `false` and generate the document in CI with +`firefly:openapi` instead. + +## Overriding a piece of the pipeline + +Every collaborator is a `#[Bean]` behind `#[ConditionalOnMissingBean]`, so replacing one is a short +`#[Configuration]` in the application and never a fork: + +```php +#[Configuration] +final class ApiDocsConfiguration +{ + #[Bean] + public function constraintSchemaMapper(): ConstraintSchemaMapper + { + return new HouseConstraintSchemaMapper; // teaches the generator your own ValidationRules + } +} +``` + +`OpenApiProperties`, `ConstraintSchemaMapper`, `DtoSchemaFactory`, `OperationFactory`, `OpenApiGenerator` and +`ViewerPage` are all overridable this way. + +## A note on reflection + +LaraFly's rule is that nothing on the cached **request** path reflects. This package honours it: the DTO +constructor reflection that supplies property types runs when `firefly:openapi` generates a file, or on a hit +to the spec route — whose result the generator memoises for the life of the process — and never while +dispatching an application request. It is the same category of work as `RouteScanner` and `ConstraintScanner`, +both of which reflect at compile time only. + +Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/openapi/cache/firefly-openapi-components.php b/packages/openapi/cache/firefly-openapi-components.php new file mode 100644 index 0000000..9fb4990 --- /dev/null +++ b/packages/openapi/cache/firefly-openapi-components.php @@ -0,0 +1,76 @@ + [ + 'class' => 'Firefly\\OpenApi\\OpenApiAutoConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 1000, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'openApiProperties', + 'returns' => 'Firefly\\OpenApi\\OpenApiProperties', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + ], + 1 => [ + 'method' => 'constraintSchemaMapper', + 'returns' => 'Firefly\\OpenApi\\Schema\\ConstraintSchemaMapper', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + ], + 2 => [ + 'method' => 'dtoSchemaFactory', + 'returns' => 'Firefly\\OpenApi\\Schema\\DtoSchemaFactory', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + ], + 3 => [ + 'method' => 'operationFactory', + 'returns' => 'Firefly\\OpenApi\\Generator\\OperationFactory', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + ], + 4 => [ + 'method' => 'openApiGenerator', + 'returns' => 'Firefly\\OpenApi\\Generator\\OpenApiGenerator', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + ], + 5 => [ + 'method' => 'viewerPage', + 'returns' => 'Firefly\\OpenApi\\Web\\ViewerPage', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + ], + ], + 'lazy' => false, + ], +]; diff --git a/packages/openapi/cache/firefly-openapi-context.php b/packages/openapi/cache/firefly-openapi-context.php new file mode 100644 index 0000000..1365d18 --- /dev/null +++ b/packages/openapi/cache/firefly-openapi-context.php @@ -0,0 +1,87 @@ + [ + 'class' => 'Firefly\\OpenApi\\OpenApiAutoConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'openApiProperties', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\OpenApi\\OpenApiProperties', + ], + ], + ], + ], + 1 => [ + 'method' => 'constraintSchemaMapper', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\OpenApi\\Schema\\ConstraintSchemaMapper', + ], + ], + ], + ], + 2 => [ + 'method' => 'dtoSchemaFactory', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\OpenApi\\Schema\\DtoSchemaFactory', + ], + ], + ], + ], + 3 => [ + 'method' => 'operationFactory', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\OpenApi\\Generator\\OperationFactory', + ], + ], + ], + ], + 4 => [ + 'method' => 'openApiGenerator', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\OpenApi\\Generator\\OpenApiGenerator', + ], + ], + ], + ], + 5 => [ + 'method' => 'viewerPage', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\OpenApi\\Web\\ViewerPage', + ], + ], + ], + ], + ], + ], +]; diff --git a/packages/openapi/composer.json b/packages/openapi/composer.json new file mode 100644 index 0000000..2e7718a --- /dev/null +++ b/packages/openapi/composer.json @@ -0,0 +1,44 @@ +{ + "name": "firefly/openapi", + "description": "LaraFly OpenAPI: generates a valid OpenAPI 3.1 document from the manifests the framework already holds in memory — RouteManifest for paths/operations/parameters, ConstraintManifest for request-body schemas — plus a firefly:openapi artisan command, a spec route, and a zero-dependency, zero-network viewer mounted at a configurable base path.", + "type": "library", + "license": "Apache-2.0", + "homepage": "https://github.com/fireflyframework/fireflyframework-php", + "authors": [ + { "name": "Firefly Software Solutions Inc.", "homepage": "https://github.com/fireflyframework" } + ], + "keywords": ["firefly", "laravel", "openapi", "swagger"], + "support": { + "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", + "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/openapi" + }, + "require": { + "php": "^8.3", + "firefly/autoconfigure": "*@dev", + "firefly/config": "*@dev", + "firefly/container": "*@dev", + "firefly/context": "*@dev", + "firefly/kernel": "*@dev", + "firefly/validation": "*@dev", + "firefly/web": "*@dev", + "illuminate/console": "^13.0", + "illuminate/container": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/http": "^13.0", + "illuminate/routing": "^13.0", + "illuminate/support": "^13.0" + }, + "extra": { + "laravel": { + "providers": [ + "Firefly\\OpenApi\\OpenApiServiceProvider", + "Firefly\\OpenApi\\OpenApiWiringProvider" + ] + }, + "branch-alias": { "dev-main": "26.x-dev" } + }, + "autoload": { "psr-4": { "Firefly\\OpenApi\\": "src/" } }, + "autoload-dev": { "psr-4": { "Firefly\\OpenApi\\Tests\\": "tests/" } }, + "minimum-stability": "stable", + "config": { "sort-packages": true } +} diff --git a/packages/openapi/src/Boot/OpenApiRouteRegistrar.php b/packages/openapi/src/Boot/OpenApiRouteRegistrar.php new file mode 100644 index 0000000..712b524 --- /dev/null +++ b/packages/openapi/src/Boot/OpenApiRouteRegistrar.php @@ -0,0 +1,76 @@ +make(...)` inside the closure is what ActuatorRouteRegistrar does, and for the same reason. + * + * MASTER GATE. `firefly.openapi.enabled` (default true) is enforced HERE rather than on the beans, matching + * `firefly.management.enabled` in the actuator: the generator and its collaborators are inert without routes, + * so gating the ROUTES is the whole of the switch. Turning it off leaves the two paths genuinely unrouted, so + * they 404 through the router's own NotFoundHttpException — which ProblemDetailsRenderer then renders as a + * proper 404 problem+json for a JSON client, not as a 500. + */ +final class OpenApiRouteRegistrar implements BootPass +{ + public function phase(): BootPhase + { + return BootPhase::WiringPasses; + } + + public function order(): int + { + return 60; + } + + public function run(BootContext $context): void + { + $container = $context->container; + + /** @var OpenApiProperties $properties */ + $properties = $container->make(OpenApiProperties::class); + + if (! $properties->enabled) { + return; + } + + /** @var Router $router */ + $router = $container->make('router'); + + $router->get($properties->specPath, static fn (): mixed => $container->make(OpenApiSpecAction::class)()) + ->name('firefly.openapi.spec'); + + if (! $properties->viewerEnabled) { + return; + } + + $router->get($properties->viewerPath, static fn (): mixed => $container->make(OpenApiViewerAction::class)()) + ->name('firefly.openapi.viewer'); + } +} diff --git a/packages/openapi/src/Command/OpenApiCommand.php b/packages/openapi/src/Command/OpenApiCommand.php new file mode 100644 index 0000000..ed9c40a --- /dev/null +++ b/packages/openapi/src/Command/OpenApiCommand.php @@ -0,0 +1,72 @@ +` as markup — a `description` mentioning a generic type, or any angle bracket that reaches the + * document from a docblock or config value, would either be swallowed or would throw on an unknown tag. The + * whole point of stdout mode is `php artisan firefly:openapi > openapi.json` and piping straight into a client + * generator, so the bytes must be exactly the bytes of the document; OUTPUT_RAW is what guarantees that, and + * it is also why the confirmation line is printed ONLY in --output mode, where stdout is not the document. + */ +final class OpenApiCommand extends Command +{ + /** @var string */ + protected $signature = 'firefly:openapi + {--output= : Write the document to this file instead of stdout (parent directories are created).}'; + + /** @var string */ + protected $description = 'Generate the OpenAPI 3.1 document from the compiled route + constraint manifests.'; + + public function handle(OpenApiGenerator $generator): int + { + $json = $generator->toJson(); + $target = $this->option('output'); + + if (! is_string($target) || $target === '') { + $this->output->writeln($json, OutputInterface::OUTPUT_RAW); + + return self::SUCCESS; + } + + $directory = dirname($target); + if (! is_dir($directory) && ! mkdir($directory, 0o755, true) && ! is_dir($directory)) { + $this->components->error("Could not create directory [{$directory}]."); + + return self::FAILURE; + } + + if (file_put_contents($target, $json.PHP_EOL) === false) { + $this->components->error("Could not write [{$target}]."); + + return self::FAILURE; + } + + $document = $generator->generate(); + $paths = is_array($document['paths'] ?? null) ? $document['paths'] : []; + + $this->components->info(sprintf( + 'OpenAPI 3.1 document written to %s (%d path%s).', + $target, + count($paths), + count($paths) === 1 ? '' : 's', + )); + + return self::SUCCESS; + } +} diff --git a/packages/openapi/src/Generator/OpenApiGenerator.php b/packages/openapi/src/Generator/OpenApiGenerator.php new file mode 100644 index 0000000..674ba40 --- /dev/null +++ b/packages/openapi/src/Generator/OpenApiGenerator.php @@ -0,0 +1,240 @@ +|null */ + private ?array $document = null; + + public function __construct( + private readonly RouteManifest $routes, + private readonly OpenApiProperties $properties, + private readonly OperationFactory $operations, + ) {} + + /** + * @return array + */ + public function generate(): array + { + return $this->document ??= $this->build(); + } + + /** + * The canonical serialisation, and the one that must be used to produce a FILE or an HTTP body. + * + * `generate()` returns plain PHP arrays because that is what is pleasant to assert against, but PHP + * cannot tell an empty map from an empty list: `json_encode([])` is `[]`, so an app with no routes would + * serialise `"paths": []` and an unconstrained property would serialise as `[]` — both of which are type + * errors against the OpenAPI 3.1 meta-schema, and both of which make a strict validator reject an + * otherwise perfect document. Every empty array is therefore re-encoded as `{}` here. That rewrite is + * unconditionally safe in THIS document because nothing in it ever emits an empty LIST: `required`, + * `tags`, `parameters`, `servers`, `allOf` and the constraint extension are each omitted entirely rather + * than emitted empty (see DtoSchemaFactory and OperationFactory, which say so at each site). + */ + public function toJson(): string + { + return json_encode( + $this->objectify($this->generate()), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ); + } + + /** + * @return array + */ + private function build(): array + { + $registry = new SchemaRegistry; + $registry->put(ProblemSchema::NAME, ProblemSchema::schema()); + + $paths = []; + $operationIds = []; + + foreach ($this->routes->all() as $route) { + if ($this->excluded($route)) { + continue; + } + + $path = $this->template($route->path); + $verb = strtolower($route->httpMethod); + + $paths[$path][$verb] = $this->operations->create($route, $this->operationId($route, $operationIds), $registry); + } + + ksort($paths); + foreach ($paths as $path => $item) { + $paths[$path] = $this->sortVerbs($item); + } + + $document = [ + 'openapi' => '3.1.0', + 'info' => $this->info(), + ]; + + if ($this->properties->servers !== []) { + $document['servers'] = $this->properties->servers; + } + + $document['paths'] = $paths; + $document['components'] = [ + 'schemas' => $registry->all(), + 'responses' => [ProblemSchema::RESPONSE_NAME => ProblemSchema::response()], + ]; + + return $document; + } + + /** + * @return array + */ + private function info(): array + { + $info = ['title' => $this->properties->title, 'version' => $this->properties->version]; + + if ($this->properties->description !== '') { + $info['description'] = $this->properties->description; + } + + return $info; + } + + /** + * A route is left out of the document when its path is excluded by configuration, or when it was + * declared by the HTML stereotype. + * + * #[Controller] routes render web pages. They are part of the application's HTTP surface, but they are + * not JSON API operations, and describing one as `application/json` would have a generator emit a typed + * client for a response that is a page — the welcome page was in the spec exactly that way. Set + * `firefly.openapi.include-html` to document them anyway; the operation is then produced with + * `text/html` content rather than a JSON schema. + */ + private function excluded(RouteDescriptor $route): bool + { + if ($route->html && ! $this->properties->includeHtml) { + return true; + } + + foreach ($this->properties->excludePathPrefixes as $prefix) { + if (str_starts_with($route->path, $prefix)) { + return true; + } + } + + return false; + } + + /** + * Laravel's optional-parameter spelling `{id?}` has no OpenAPI equivalent — a path parameter is required + * there, full stop — so the marker is stripped and the parameter stays required. The alternative (two + * Path Items, one with and one without the segment) would describe an API surface the router does not + * actually expose as two routes, and would double every such operation in a generated client. + */ + private function template(string $path): string + { + return str_replace('?}', '}', $path); + } + + /** + * @param array $used operationId => how many times it has been claimed + */ + private function operationId(RouteDescriptor $route, array &$used): string + { + $candidate = $route->name ?? $this->derivedId($route); + + // operationId is REQUIRED to be unique across the whole document, and a duplicate is the one flaw + // that makes most client generators abort rather than degrade. Two routes can legitimately collide + // (the same method name on two controllers whose short names differ only by namespace, or a route + // `name` reused by mistake), so a repeat claim is suffixed rather than allowed to overwrite. + $used[$candidate] = ($used[$candidate] ?? 0) + 1; + + return $used[$candidate] === 1 ? $candidate : $candidate.'_'.$used[$candidate]; + } + + private function derivedId(RouteDescriptor $route): string + { + $class = $route->controllerClass; + $short = str_contains($class, '\\') ? substr($class, (int) strrpos($class, '\\') + 1) : $class; + $short = str_ends_with($short, 'Controller') && $short !== 'Controller' + ? substr($short, 0, -strlen('Controller')) + : $short; + + return lcfirst($short).ucfirst($route->methodName); + } + + /** + * @param array $item + * @return array + */ + private function sortVerbs(array $item): array + { + $sorted = []; + + foreach (self::VERB_ORDER as $verb) { + if (array_key_exists($verb, $item)) { + $sorted[$verb] = $item[$verb]; + unset($item[$verb]); + } + } + + ksort($item); + + return [...$sorted, ...$item]; + } + + /** + * Recursively re-encodes empty arrays as empty JSON OBJECTS — see toJson() for why this is both + * necessary and safe here. + */ + private function objectify(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + if ($value === []) { + return new stdClass; + } + + return array_map(fn (mixed $item): mixed => $this->objectify($item), $value); + } +} diff --git a/packages/openapi/src/Generator/OperationFactory.php b/packages/openapi/src/Generator/OperationFactory.php new file mode 100644 index 0000000..1021c16 --- /dev/null +++ b/packages/openapi/src/Generator/OperationFactory.php @@ -0,0 +1,308 @@ + + */ + public function create(RouteDescriptor $route, string $operationId, SchemaRegistry $registry): array + { + $parameters = []; + $body = null; + $files = []; + $validated = false; + $rejectable = false; + + foreach ($route->bindings as $binding) { + $validated = $validated || $binding['valid']; + + switch ($binding['kind']) { + case 'path': + $parameters[] = $this->parameter($binding, 'path', true); + $rejectable = $rejectable || $this->coercible($binding); + break; + case 'query': + $parameters[] = $this->parameter($binding, 'query', $binding['required']); + $rejectable = $rejectable || $binding['required'] || $this->coercible($binding); + break; + case 'header': + $parameters[] = $this->parameter($binding, 'header', $binding['required']); + $rejectable = $rejectable || $binding['required'] || $this->coercible($binding); + break; + case 'file': + $files[] = $binding; + $rejectable = true; + break; + case 'body': + $body = $binding; + $rejectable = true; + break; + } + } + + $operation = [ + 'operationId' => $operationId, + 'summary' => $this->summary($route), + 'description' => 'Handled by '.$route->controllerClass.'::'.$route->methodName.'().', + 'tags' => [$this->tag($route)], + ]; + + if ($parameters !== []) { + $operation['parameters'] = $parameters; + } + + if ($body !== null) { + $operation['requestBody'] = $this->requestBody($body, $registry); + } elseif ($files !== []) { + $operation['requestBody'] = $this->multipartBody($files); + } + + $operation['responses'] = $this->responses($route, $rejectable, $validated); + + return $operation; + } + + /** + * A path/query/header value always arrives as a string on the wire; ArgumentResolver::coerce() is what + * turns "42" into an int. The DECLARED type is still the right thing to publish — it is what the endpoint + * accepts after coercion, and it is what a generated client should send — but a class type here has no + * scalar spelling at all, so it degrades to `string` rather than becoming a dangling `$ref` to a + * component that the request-body machinery never registered. + * + * `required` is passed in rather than read off the binding because a PATH parameter is required by the + * OpenAPI specification itself (`required: false` is invalid there), independently of what the binding + * plan happens to say — RouteScanner always plans one as required, and the caller reasserts it so the + * document is valid by construction rather than by that coincidence. + * + * @param Binding $binding + * @return array + */ + private function parameter(array $binding, string $in, bool $required): array + { + $schema = TypeSchema::for($binding['type']) ?? ['type' => 'string']; + + if ($binding['default'] !== null && is_scalar($binding['default'])) { + $schema['default'] = $binding['default']; + } + + return [ + 'name' => $binding['key'], + 'in' => $in, + 'required' => $required, + 'schema' => $schema, + ]; + } + + /** + * @param Binding $binding + * @return array + */ + private function requestBody(array $binding, SchemaRegistry $registry): array + { + $type = $binding['type']; + + $schema = $type !== null && TypeSchema::isDto($type) + ? ['$ref' => $this->schemas->ref($type, $registry, $binding['properties'])] + : ['type' => 'object']; + + return [ + 'required' => true, + 'content' => ['application/json' => ['schema' => $schema]], + ]; + } + + /** + * A #[UploadedFile] parameter is not a JSON member — ArgumentResolver pulls it off the multipart request, + * so the operation's body media type changes with it. Modelled as `format: binary`, which is how + * OpenAPI 3.1 spells "raw bytes in a multipart part". + * + * @param list $files + * @return array + */ + private function multipartBody(array $files): array + { + $properties = []; + $required = []; + + foreach ($files as $file) { + $properties[$file['key']] = ['type' => 'string', 'format' => 'binary']; + if ($file['required']) { + $required[] = $file['key']; + } + } + + $schema = ['type' => 'object', 'properties' => $properties]; + if ($required !== []) { + $schema['required'] = $required; + } + + return [ + 'required' => $required !== [], + 'content' => ['multipart/form-data' => ['schema' => $schema]], + ]; + } + + /** + * The success response plus every error status this operation can ACTUALLY produce, all pointing at the + * one shared problem component. + * + * The error set is derived, not guessed. `400` appears exactly when the operation has something + * ArgumentResolver can reject BEFORE the controller runs — a body to decode and bind (MALFORMED_BODY / + * UNBINDABLE_BODY), an upload to validate (INVALID_UPLOAD), a required query/header the client may omit + * (MISSING_PARAMETER), or a non-string parameter that has to be coerced out of the wire's string + * (TYPE_CONVERSION_ERROR) — every one of which InvalidRequestException raises as 400. It is deliberately + * NOT emitted for an operation whose only parameter is an optional `string` path variable: nothing about + * such a request can fail binding (a missing path segment does not match the route at all), and a + * documented 400 that the endpoint cannot produce is noise a generated client turns into a dead error + * branch. `422` appears exactly when some binding carries #[Valid], because that is the only way + * BeanValidator runs and so the only way kernel's ValidationException (fixed at 422, with its `errors` + * array populated) can be thrown. + * `default` covers everything the handler itself may raise — a 404 from a ResourceNotFoundException, a + * 409 from a ConflictException, a 403 from a denied #[PreAuthorize] — which cannot be enumerated from the + * route manifest without reading the controller's body, and which all render through the same + * ProblemDetailsRenderer anyway. + * + * Keyed by `array-key` rather than `string` because PHP coerces a numeric string key to an INTEGER the + * moment it is written — '201' becomes 201 — so the honest type for a status map is the mixed one. The + * document is unaffected: a map keyed 201/400/'default' is not a PHP list, so json_encode still writes a + * JSON object. + * + * @return array + */ + private function responses(RouteDescriptor $route, bool $rejectable, bool $validated): array + { + $responses = [(string) $route->status => $this->successResponse($route)]; + + if ($rejectable) { + $responses['400'] = ['$ref' => ProblemSchema::RESPONSE_REF]; + } + + if ($validated) { + $responses['422'] = ['$ref' => ProblemSchema::RESPONSE_REF]; + } + + $responses['default'] = ['$ref' => ProblemSchema::RESPONSE_REF]; + + return $responses; + } + + /** + * The success body, from the controller method's declared RETURN type — the only place the shape of a + * successful response is stated anywhere in the framework, since RouteDescriptor records the status but + * not the payload. A `204` (or a `void`/`never` return) gets no content at all, because emitting a + * content map for a status that carries no body is exactly the sort of thing a strict client generator + * turns into a phantom return type. + * + * `array` is the common LaraFly return and deliberately degrades to `type: object` rather than being + * expanded from the method's `@return array{...}` docblock: parsing a PHPDoc array shape here would make + * the generated document depend on comment text that nothing else in the framework treats as binding. + * + * @return array + */ + private function successResponse(RouteDescriptor $route): array + { + $type = $this->returnType($route); + + if ($route->status === 204 || $type === 'void' || $type === 'never') { + return ['description' => 'No content.']; + } + + // A #[Controller] route renders a page. It reaches this factory only when + // firefly.openapi.include-html is on, and describing its response as a JSON schema would be a lie + // that a client generator would faithfully act on. + if ($route->html) { + return [ + 'description' => 'An HTML page.', + 'content' => ['text/html' => ['schema' => ['type' => 'string']]], + ]; + } + + $schema = match (true) { + $type === null => [], + $type === 'array', $type === 'iterable' => ['type' => 'object'], + default => TypeSchema::for($type) ?? ['type' => 'object'], + }; + + return [ + 'description' => 'Successful response.', + 'content' => ['application/json' => ['schema' => $schema]], + ]; + } + + /** + * Whether this binding's value has to be CONVERTED out of the string the wire always carries — the + * TYPE_CONVERSION_ERROR half of the 400 above. A `string` parameter needs no conversion and so cannot + * fail one; anything else (int, float, bool, an enum) can. + * + * @param Binding $binding + */ + private function coercible(array $binding): bool + { + return $binding['type'] !== null && $binding['type'] !== 'string'; + } + + private function returnType(RouteDescriptor $route): ?string + { + if (! class_exists($route->controllerClass) || ! method_exists($route->controllerClass, $route->methodName)) { + return null; + } + + $type = (new ReflectionMethod($route->controllerClass, $route->methodName))->getReturnType(); + + return $type instanceof ReflectionNamedType ? $type->getName() : null; + } + + /** + * The tag a viewer groups this operation under: the controller's short name with a trailing "Controller" + * removed, so `Lumen\Web\WalletController` reads as "Wallet". + */ + private function tag(RouteDescriptor $route): string + { + $class = $route->controllerClass; + $short = str_contains($class, '\\') ? substr($class, (int) strrpos($class, '\\') + 1) : $class; + + return str_ends_with($short, 'Controller') && $short !== 'Controller' + ? substr($short, 0, -strlen('Controller')) + : $short; + } + + /** + * `getBalance` reads as "Get balance". A method name is the only human-authored label a route carries + * (a #[Mapping]'s name is a Laravel route name, not prose), so it is the honest source for a summary. + */ + private function summary(RouteDescriptor $route): string + { + $words = preg_split('/(?=[A-Z])/', $route->methodName); + $sentence = strtolower(trim(implode(' ', $words === false ? [$route->methodName] : $words))); + + return $sentence === '' ? $route->methodName : ucfirst($sentence); + } +} diff --git a/packages/openapi/src/OpenApiAutoConfiguration.php b/packages/openapi/src/OpenApiAutoConfiguration.php new file mode 100644 index 0000000..f69e3fc --- /dev/null +++ b/packages/openapi/src/OpenApiAutoConfiguration.php @@ -0,0 +1,86 @@ +title); + } +} diff --git a/packages/openapi/src/OpenApiProperties.php b/packages/openapi/src/OpenApiProperties.php new file mode 100644 index 0000000..7e56c17 --- /dev/null +++ b/packages/openapi/src/OpenApiProperties.php @@ -0,0 +1,120 @@ +set()` on + * those keys could not move an already-mounted route anyway. Anything that MUST be live per request (there is + * nothing here today) would have to read Config at request time instead, exactly as ActuatorDispatchAction + * does for its per-endpoint enable flag. + * + * Attribute routes cannot carry a configurable path — `#[GetMapping('/openapi.json')]` bakes the literal into + * a compiled RouteDescriptor — which is precisely why this package mounts its two routes natively on the + * illuminate Router from a BootPass, the ActuatorRouteRegistrar precedent, rather than shipping a + * #[RestController] of its own. A framework package whose own endpoints appeared in the app's RouteManifest + * would also end up documenting ITSELF in the spec it generates. + */ +final readonly class OpenApiProperties +{ + /** + * @param list $servers + * @param list $excludePathPrefixes + */ + public function __construct( + public bool $enabled, + public string $specPath, + public bool $viewerEnabled, + public string $viewerPath, + public bool $viewerCdn, + public string $title, + public string $version, + public string $description, + public array $servers, + public array $excludePathPrefixes, + public bool $includeHtml = false, + ) {} + + public static function fromConfig(Config $config): self + { + return new self( + enabled: $config->bool('firefly.openapi.enabled', true), + specPath: self::path($config->string('firefly.openapi.path', '/openapi.json'), 'openapi.json'), + viewerEnabled: $config->bool('firefly.openapi.viewer.enabled', true), + viewerPath: self::path($config->string('firefly.openapi.viewer.path', '/openapi'), 'openapi'), + viewerCdn: $config->bool('firefly.openapi.viewer.cdn', false), + title: $config->string('firefly.openapi.title', 'API'), + version: $config->string('firefly.openapi.version', '0.0.0'), + description: $config->string('firefly.openapi.description', ''), + servers: self::servers($config), + excludePathPrefixes: self::csv($config->string('firefly.openapi.exclude', '')), + includeHtml: $config->bool('firefly.openapi.include-html', false), + ); + } + + /** + * Both routes are registered with the leading slash stripped, because Illuminate's Router does that + * itself (Route::__construct -> uri = trim($uri, '/')) and a `/`-prefixed literal would otherwise make + * every generated link in the viewer disagree with the route it points at by one character. + */ + private static function path(string $configured, string $fallback): string + { + $trimmed = trim($configured, '/'); + + return $trimmed === '' ? $fallback : $trimmed; + } + + /** + * `firefly.openapi.servers` accepts the two spellings a real config file uses: a CSV/array of bare URL + * strings (`['https://api.example.test']`) and OpenAPI's own object form + * (`[['url' => '...', 'description' => '...']]`). Anything else in the list is dropped rather than + * emitted, because a Server Object with no `url` is invalid per the 3.1 schema and would poison an + * otherwise-good document. + * + * @return list + */ + private static function servers(Config $config): array + { + $raw = $config->array('firefly.openapi.servers', []); + + $servers = []; + foreach ($raw as $entry) { + if (is_string($entry) && $entry !== '') { + $servers[] = ['url' => $entry]; + + continue; + } + + if (! is_array($entry) || ! isset($entry['url']) || ! is_string($entry['url']) || $entry['url'] === '') { + continue; + } + + $server = ['url' => $entry['url']]; + if (isset($entry['description']) && is_string($entry['description'])) { + $server['description'] = $entry['description']; + } + + $servers[] = $server; + } + + return $servers; + } + + /** + * @return list + */ + private static function csv(string $value): array + { + return array_values(array_filter( + array_map('trim', explode(',', $value)), + static fn (string $segment): bool => $segment !== '', + )); + } +} diff --git a/packages/openapi/src/OpenApiServiceProvider.php b/packages/openapi/src/OpenApiServiceProvider.php new file mode 100644 index 0000000..0b4f636 --- /dev/null +++ b/packages/openapi/src/OpenApiServiceProvider.php @@ -0,0 +1,27 @@ + + */ + public function passes(): array + { + return [new OpenApiRouteRegistrar]; + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([OpenApiCommand::class]); + } + } +} diff --git a/packages/openapi/src/Schema/ConstraintSchemaMapper.php b/packages/openapi/src/Schema/ConstraintSchemaMapper.php new file mode 100644 index 0000000..ae9a322 --- /dev/null +++ b/packages/openapi/src/Schema/ConstraintSchemaMapper.php @@ -0,0 +1,331 @@ +` that + * ConstraintManifest::rulesFor() hands the BeanValidator at request time — into JSON Schema keywords. + * + * WHY THE MANIFEST, AND NOT THE #[Constraint] ATTRIBUTES. Reading the attributes back off the DTO would be + * the obvious route to "#[Email] => format: email", and it would document a validator that does not exist. + * The manifest is what actually runs: it has already applied ConstraintScanner's Jakarta null contract + * (a `nullable` prepended to every property whose declared type admits null and which carries no NullAware + * rule), already expanded #[Size] into a first-party rule OBJECT rather than Laravel's polymorphic + * `min:`/`max:` strings, and already flattened one #[Valid] level into dotted keys. Generating from the + * attributes would re-derive all of that by hand and drift from it the first time packages/validation + * changes a toRules() body. Generating from the manifest cannot drift, because the manifest IS the contract. + * + * WHAT `required` MEANS HERE. Jakarta and JSON Schema agree that presence and nullability are different + * questions, and this mapper keeps them apart: `required`/`present` (emitted by #[NotBlank]/#[NotEmpty]/ + * #[NotNull]) put the member in the parent's `required` list, while the `nullable` flag decides whether + * `null` joins the member's own `type`. #[NotNull] does both at once — it is required AND not nullable — + * which is why the NullAware NotNull rule object clears nullability rather than merely adding requiredness. + * + * LOSSLESSNESS. Several rules have no JSON Schema equivalent at all (`after:now` — JSON Schema cannot say + * "in the future"; a bare Luhn checksum; a third-party ValidationRule the generator has never heard of), and + * a few map only approximately (a PCRE pattern carrying flags ECMA-262 has no syntax for). Rather than drop + * those silently — which would produce a spec that quietly promises less validation than the server + * performs — each one is recorded under the `x-firefly-constraints` extension. Specification extensions are + * explicitly permitted by OpenAPI 3.1 and ignored by every conforming tool, so the document stays valid + * while the full truth survives for a human or a custom generator to read. + * + * FORMATS. `format` in JSON Schema 2020-12 is an open vocabulary: unknown values are annotations, not + * errors. IBAN/BIC/ISIN/CUSIP/E.164 have no registered format name, so they are emitted as self-describing + * ones (`iban`, `bic`, ...). Where a rule matches its PCRE against the RAW value, the pattern is emitted + * too; where the rule NORMALISES first (Iban strips spaces and upper-cases; Bic/Swift/Cusip/Isin upper-case; + * Luhn/RoutingNumber strip separators), the pattern is deliberately withheld — publishing the post- + * normalisation pattern would reject payloads the server accepts, which is worse than under-specifying. + */ +final class ConstraintSchemaMapper +{ + public const string EXTENSION = 'x-firefly-constraints'; + + /** + * @param array $base the declared-type fragment from TypeSchema (may be empty) + * @param list $rules the compiled rule list for this property + * @param bool $declaredNullable whether the PHP declaration admits null + * @param bool $declaredRequired whether omitting the member would break DTO construction outright + */ + public function apply(array $base, array $rules, bool $declaredNullable = false, bool $declaredRequired = false): PropertySchema + { + $state = new MapperState($base, $declaredNullable, $declaredRequired); + + foreach ($rules as $rule) { + if (is_string($rule)) { + $this->applyString($state, $rule); + + continue; + } + + $this->applyObject($state, $rule); + } + + return $state->finish(); + } + + private function applyString(MapperState $state, string $rule): void + { + [$name, $argument] = str_contains($rule, ':') ? explode(':', $rule, 2) : [$rule, '']; + + switch ($name) { + case 'nullable': + $state->nullable = true; + + return; + case 'required': + case 'present': + case 'filled': + $state->required = true; + + return; + case 'string': + $state->type('string'); + + return; + case 'numeric': + $state->type('number'); + + return; + case 'integer': + case 'int': + $state->type('integer'); + + return; + case 'boolean': + $state->type('boolean'); + + return; + case 'array': + $state->type('array'); + + return; + case 'email': + $state->type('string'); + $state->keyword('format', 'email'); + + return; + case 'url': + case 'active_url': + $state->type('string'); + $state->keyword('format', 'uri'); + + return; + case 'uuid': + $state->type('string'); + $state->keyword('format', 'uuid'); + + return; + case 'ip': + $state->type('string'); + $state->keyword('format', 'ipv4'); + + return; + case 'date': + case 'date_format': + $state->type('string'); + $state->keyword('format', 'date-time'); + + return; + case 'regex': + $state->pattern($argument); + + return; + case 'gte': + $state->number('minimum', $argument); + + return; + case 'lte': + $state->number('maximum', $argument); + + return; + case 'gt': + $state->number('exclusiveMinimum', $argument); + + return; + case 'lt': + $state->number('exclusiveMaximum', $argument); + + return; + case 'min': + $state->bound($argument, min: true); + + return; + case 'max': + $state->bound($argument, min: false); + + return; + case 'between': + $bounds = explode(',', $argument); + $state->bound(trim($bounds[0]), min: true); + $state->bound(trim($bounds[1] ?? ''), min: false); + + return; + case 'size': + $state->bound($argument, min: true); + $state->bound($argument, min: false); + + return; + case 'in': + $state->keyword('enum', array_map('trim', explode(',', $argument))); + + return; + case 'accepted': + $state->type('boolean'); + $state->keyword('const', true); + + return; + case 'declined': + $state->type('boolean'); + $state->keyword('const', false); + + return; + default: + // `after:now`, `before:now`, `exists:`, `unique:` and every unrecognised Laravel rule reach + // here: real, enforced constraints that JSON Schema simply cannot state. + $state->unmapped($rule); + } + } + + private function applyObject(MapperState $state, ValidationRule $rule): void + { + switch (true) { + case $rule instanceof NotNull: + // Required AND non-nullable — the one rule that answers both questions (see class docblock). + $state->required = true; + $state->nullable = false; + + return; + case $rule instanceof Size: + $state->size($rule->min(), $rule->max()); + + return; + case $rule instanceof Uuid: + $state->type('string'); + $state->keyword('format', 'uuid'); + $state->pattern('/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/'); + + return; + case $rule instanceof E164: + $state->type('string'); + $state->keyword('format', 'phone'); + $state->pattern('/^\+[1-9]\d{1,14}$/'); + + return; + case $rule instanceof Currency: + $state->type('string'); + $state->keyword('format', 'currency'); + $state->pattern('/^[A-Z]{3}$/'); + + return; + case $rule instanceof CountryCode: + $state->type('string'); + $state->keyword('format', 'country-code'); + $state->pattern('/^[A-Z]{2}$/'); + + return; + case $rule instanceof LanguageTag: + $state->type('string'); + $state->keyword('format', 'bcp47'); + $state->pattern('/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/'); + + return; + case $rule instanceof PostalCode: + $state->type('string'); + $state->keyword('format', 'postal-code'); + $state->pattern('/^[A-Za-z0-9][A-Za-z0-9 -]{1,9}$/'); + + return; + case $rule instanceof Iban: + $state->type('string'); + $state->keyword('format', 'iban'); + $state->unmapped('iban:checksum'); + + return; + case $rule instanceof Swift: + $state->type('string'); + $state->keyword('format', 'swift'); + + return; + case $rule instanceof Bic: + $state->type('string'); + $state->keyword('format', 'bic'); + + return; + case $rule instanceof Cusip: + $state->type('string'); + $state->keyword('format', 'cusip'); + $state->unmapped('cusip:check-digit'); + + return; + case $rule instanceof Isin: + $state->type('string'); + $state->keyword('format', 'isin'); + $state->unmapped('isin:check-digit'); + + return; + case $rule instanceof Luhn: + $state->keyword('format', 'luhn'); + $state->unmapped('luhn:check-digit'); + + return; + case $rule instanceof RoutingNumber: + $state->type('string'); + $state->keyword('format', 'aba-routing-number'); + $state->unmapped('routing-number:check-digit'); + + return; + case $rule instanceof Percentage: + $state->type('number'); + $state->keyword('minimum', 0); + $state->keyword('maximum', 100); + + return; + case $rule instanceof PositiveMoney: + $state->type('number'); + $state->keyword('exclusiveMinimum', 0); + $state->keyword('multipleOf', 0.01); + + return; + case $rule instanceof DecimalScale: + $state->keyword('multipleOf', $this->scaleStep($rule->scale())); + + return; + default: + // A third-party ValidationRule. Its class name is the only thing about it that is knowable + // without executing it, so that is what the extension records. + $state->unmapped($rule::class); + } + } + + /** + * `multipleOf` for a decimal scale: 2 fractional digits => 0.01, 0 => 1. Computed as a division rather + * than 10 ** -$scale so the value round-trips through json_encode as 0.01 instead of 1.0E-2 — both are + * legal JSON numbers, but only the former reads as money in a rendered spec. + */ + private function scaleStep(int $scale): float|int + { + return $scale <= 0 ? 1 : 1 / (10 ** $scale); + } +} diff --git a/packages/openapi/src/Schema/DtoSchemaFactory.php b/packages/openapi/src/Schema/DtoSchemaFactory.php new file mode 100644 index 0000000..e6ce359 --- /dev/null +++ b/packages/openapi/src/Schema/DtoSchemaFactory.php @@ -0,0 +1,214 @@ +getParameters()`, + * captured at scan time) out of the decoded body and splats them as named arguments. Keys outside that list + * are silently ignored rather than rejected, which is why NO `additionalProperties: false` is emitted — the + * server genuinely accepts extra members, and a spec that said otherwise would make conforming clients fail + * requests the server would have served. Rules keyed to a member with no constructor parameter are still + * documented: BeanValidator validates the RAW decoded array, so such a member is enforced on input even + * though it is never hydrated. + * + * NESTED DTOs become their own component and a `$ref`, never an inlined object — see SchemaRegistry. When the + * nested class has its own manifest entry (the normal case: ConstraintManifestCompiler compiles every class + * under the app's scan roots, not just body DTOs) its own rules are used. When it does not, the parent's + * dotted `#[Valid]`-cascaded keys (`beneficiary.postcode`) are unflattened back into it, so a nested schema + * is still constrained rather than a bare `type: object`. + */ +final class DtoSchemaFactory +{ + public function __construct( + private readonly ConstraintManifest $constraints, + private readonly ConstraintSchemaMapper $mapper, + ) {} + + /** + * @param list $properties the compiled binding's accepted key list, used when $class cannot be + * autoloaded in this process and reflection is therefore unavailable + * @param array> $fallbackRules a nested class's rules recovered + * from the parent's dotted keys + */ + public function ref(string $class, SchemaRegistry $registry, array $properties = [], array $fallbackRules = []): string + { + return $registry->ref($class, fn (): array => $this->build($class, $registry, $properties, $fallbackRules)); + } + + /** + * @param list $properties + * @param array> $fallbackRules + * @return array + */ + private function build(string $class, SchemaRegistry $registry, array $properties, array $fallbackRules): array + { + $rules = $this->constraints->rulesFor($class); + if ($rules === []) { + $rules = $fallbackRules; + } + + [$own, $nested] = $this->partition($rules); + + $fields = []; + $required = []; + + foreach ($this->members($class, $properties, $own) as $name => $member) { + $property = $this->property($member, $own[$name] ?? [], $nested[$name] ?? [], $registry); + + $fields[$name] = $property->schema; + if ($property->required) { + $required[] = $name; + } + } + + $schema = [ + 'type' => 'object', + 'title' => $this->title($class), + 'description' => 'Request payload bound from '.$class.'.', + 'properties' => $fields, + ]; + + // An empty `required` array is invalid under the OpenAPI 3.1 meta-schema (minItems: 1), so the key is + // omitted rather than emitted empty — a distinction a strict validator does enforce. + if ($required !== []) { + $schema['required'] = $required; + } + + return $schema; + } + + /** + * @param list $rules this member's own compiled rule list + * @param array> $nestedRules rules cascaded from a parent #[Valid] + */ + private function property(MemberType $member, array $rules, array $nestedRules, SchemaRegistry $registry): PropertySchema + { + $type = $member->type; + + if ($type !== null && TypeSchema::isDto($type)) { + $ref = $this->ref($type, $registry, [], $nestedRules); + + // A `$ref` cannot usefully be widened with a sibling `type` in 2020-12 (the reference's own + // keywords win), so a nullable nested DTO is spelled as the union it actually is. + $schema = $member->nullable + ? ['anyOf' => [['$ref' => $ref], ['type' => 'null']]] + : ['$ref' => $ref]; + + return new PropertySchema($schema, $this->mapper->apply([], $rules, false, $member->required())->required); + } + + $base = TypeSchema::for($type) ?? []; + $property = $this->mapper->apply($base, $rules, $member->nullable, $member->required()); + + if ($member->hasDefault && $member->default !== null && ! array_key_exists('default', $property->schema)) { + return new PropertySchema([...$property->schema, 'default' => $member->default], $property->required); + } + + return $property; + } + + /** + * The members to document, in the order a reader expects: constructor parameters first (declaration + * order, the order the class itself states), then any rule-only member the constructor does not take. + * + * @param list $properties + * @param array> $own + * @return array + */ + private function members(string $class, array $properties, array $own): array + { + $members = []; + + foreach ($this->parameters($class) as $parameter) { + $members[$parameter->getName()] = MemberType::fromParameter($parameter); + } + + if ($members === []) { + // No constructor to reflect (the class is not autoloadable here, or takes no arguments): fall + // back to the compiled binding's key list, which RouteScanner captured from the same source. + foreach ($properties as $name) { + $members[$name] = MemberType::unknown(); + } + } + + foreach (array_keys($own) as $name) { + $members[$name] ??= MemberType::unknown(); + } + + return $members; + } + + /** + * @return list + */ + private function parameters(string $class): array + { + if (! class_exists($class)) { + return []; + } + + $reflection = new ReflectionClass($class); + if ($reflection->isAbstract() || $reflection->isInterface()) { + return []; + } + + return $reflection->getConstructor()?->getParameters() ?? []; + } + + /** + * Splits a class's compiled rules into its OWN members and the dotted keys #[Valid] cascaded down from + * it, re-nesting the latter under their first segment so a nested schema can be built from them when the + * nested class has no manifest entry of its own. + * + * @param array> $rules + * @return array{array>, array>>} + */ + private function partition(array $rules): array + { + $own = []; + $nested = []; + + foreach ($rules as $key => $list) { + if (! str_contains($key, '.')) { + $own[$key] = $list; + + continue; + } + + [$parent, $child] = explode('.', $key, 2); + $nested[$parent][$child] = $list; + } + + return [$own, $nested]; + } + + private function title(string $class): string + { + return str_contains($class, '\\') ? substr($class, strrpos($class, '\\') + 1) : $class; + } +} diff --git a/packages/openapi/src/Schema/MapperState.php b/packages/openapi/src/Schema/MapperState.php new file mode 100644 index 0000000..7f78806 --- /dev/null +++ b/packages/openapi/src/Schema/MapperState.php @@ -0,0 +1,238 @@ + */ + private array $schema; + + /** @var list */ + private array $patterns = []; + + /** @var list */ + private array $unmapped = []; + + /** + * @param array $base + */ + public function __construct(array $base, bool $nullable, bool $required) + { + $this->schema = $base; + $this->nullable = $nullable; + $this->required = $required; + } + + public function type(string $type): void + { + $this->keyword('type', $type); + } + + public function keyword(string $keyword, mixed $value): void + { + if (! array_key_exists($keyword, $this->schema)) { + $this->schema[$keyword] = $value; + } + } + + /** + * A numeric rule argument (`gte:2.5`) as the JSON number it denotes. A non-numeric argument is not a + * bound at all — it is a field reference (`gte:other_field`), which JSON Schema cannot express — so it is + * recorded as unmapped rather than coerced to 0. + */ + public function number(string $keyword, string $argument): void + { + if (! is_numeric($argument)) { + $this->unmapped($keyword.':'.$argument); + + return; + } + + $this->keyword($keyword, $this->numeric($argument)); + } + + /** + * Laravel's `min:`/`max:`/`between:`/`size:` are deliberately polymorphic — Validator::getSize() reads + * the VALUE for a numeric attribute and the LENGTH/COUNT otherwise — so the keyword they translate to + * depends on the type already resolved for this property. Firefly's own #[Size] no longer emits these + * (it compiles to a Size rule OBJECT precisely because the polymorphism was a defect: see that rule's + * docblock), but #[Rules('min:3')] passes raw Laravel strings straight through, so the ambiguity is + * still reachable and is resolved here the same way the validator resolves it. + */ + public function bound(string $argument, bool $min): void + { + if (! is_numeric($argument)) { + $this->unmapped(($min ? 'min:' : 'max:').$argument); + + return; + } + + $value = $this->numeric($argument); + + if ($this->isNumericType()) { + $this->keyword($min ? 'minimum' : 'maximum', $value); + + return; + } + + $this->keyword($this->lengthKeyword($min), (int) $value); + } + + /** + * Firefly's #[Size] rule object, which always MEASURES (never compares magnitudes), so it needs no + * numeric branch — only the string-vs-array choice of which measurement keyword names the same idea. + */ + public function size(?int $min, ?int $max): void + { + if ($min !== null) { + $this->keyword($this->lengthKeyword(true), $min); + } + + if ($max !== null) { + $this->keyword($this->lengthKeyword(false), $max); + } + } + + /** + * Translates a PCRE pattern (delimiters + flags, the form #[Pattern] and every `regex:` rule carry) into + * the bare ECMA-262 pattern JSON Schema's `pattern` keyword expects. + * + * `D` and `u` are dropped as genuine no-ops: ECMA `$` without `m` already anchors at end-of-input (which + * is all `D` buys over PCRE's default), and JSON Schema patterns are already Unicode. Any OTHER flag — + * `i` above all, which ECMA-262 has no inline syntax for inside a pattern string — cannot be carried + * across, so the pattern is still emitted (it is the closest true statement available) AND the original + * rule is recorded as unmapped, so a reader can see that the published pattern is stricter than the + * server's. An unparseable pattern is recorded and otherwise ignored: a malformed `pattern` keyword + * would break every consumer of the document, which is a far worse outcome than an absent one. + */ + public function pattern(string $pcre): void + { + $delimiters = ['(' => ')', '[' => ']', '{' => '}', '<' => '>']; + $open = substr($pcre, 0, 1); + + if ($open === '' || ctype_alnum($open) || $open === '\\') { + $this->unmapped('regex:'.$pcre); + + return; + } + + $close = $delimiters[$open] ?? $open; + $end = strrpos($pcre, $close); + + if ($end === false || $end === 0) { + $this->unmapped('regex:'.$pcre); + + return; + } + + $body = substr($pcre, 1, $end - 1); + $flags = str_replace(['D', 'u'], '', substr($pcre, $end + 1)); + + if ($flags !== '') { + $this->unmapped('regex:'.$pcre); + } + + if (! in_array($body, $this->patterns, true)) { + $this->patterns[] = $body; + } + } + + public function unmapped(string $descriptor): void + { + if (! in_array($descriptor, $this->unmapped, true)) { + $this->unmapped[] = $descriptor; + } + } + + public function finish(): PropertySchema + { + $schema = $this->schema; + + // One pattern is the `pattern` keyword; several are an allOf of single-pattern subschemas, because + // JSON Schema has exactly one `pattern` slot per schema object and #[NotBlank] + #[Pattern] on the + // same property genuinely produces two (`\S` and the developer's own). Collapsing them by keeping + // only the last would silently drop the non-blank guarantee. + if (count($this->patterns) === 1) { + $schema['pattern'] = $this->patterns[0]; + } elseif (count($this->patterns) > 1) { + $schema['allOf'] = array_map( + static fn (string $pattern): array => ['pattern' => $pattern], + $this->patterns, + ); + } + + if ($this->nullable) { + $schema = $this->nullify($schema); + } + + if ($this->unmapped !== []) { + $schema[ConstraintSchemaMapper::EXTENSION] = $this->unmapped; + } + + return new PropertySchema($schema, $this->required); + } + + /** + * OpenAPI 3.1 is JSON Schema 2020-12, which dropped 3.0's `nullable: true` keyword in favour of a type + * UNION — `type: [string, 'null']`. A schema with no `type` at all already admits null, so it is left + * alone; an `enum` must additionally gain the null member, because `type` widening alone would leave + * null failing the enumeration and the property would be undocumentable-as-null in practice. + * + * @param array $schema + * @return array + */ + private function nullify(array $schema): array + { + if (isset($schema['enum']) && is_array($schema['enum']) && ! in_array(null, $schema['enum'], true)) { + $schema['enum'] = [...array_values($schema['enum']), null]; + } + + if (! isset($schema['type']) || ! is_string($schema['type'])) { + return $schema; + } + + $schema['type'] = [$schema['type'], 'null']; + + return $schema; + } + + private function isNumericType(): bool + { + return in_array($this->schema['type'] ?? null, ['integer', 'number'], true); + } + + private function lengthKeyword(bool $min): string + { + $array = ($this->schema['type'] ?? null) === 'array'; + + return match (true) { + $array && $min => 'minItems', + $array => 'maxItems', + $min => 'minLength', + default => 'maxLength', + }; + } + + private function numeric(string $argument): int|float + { + return str_contains($argument, '.') ? (float) $argument : (int) $argument; + } +} diff --git a/packages/openapi/src/Schema/MemberType.php b/packages/openapi/src/Schema/MemberType.php new file mode 100644 index 0000000..0710e40 --- /dev/null +++ b/packages/openapi/src/Schema/MemberType.php @@ -0,0 +1,66 @@ +getType(); + + return new self( + type: $type instanceof ReflectionNamedType ? $type->getName() : null, + nullable: $type?->allowsNull() ?? true, + hasDefault: $parameter->isDefaultValueAvailable(), + default: $parameter->isDefaultValueAvailable() ? self::scalar($parameter->getDefaultValue()) : null, + ); + } + + /** A member the constructor does not declare: validated on input, but untyped as far as this generator knows. */ + public static function unknown(): self + { + return new self(null, true, false, null); + } + + public function required(): bool + { + return ! $this->hasDefault && ! $this->nullable && $this->type !== null; + } + + /** + * Defaults are copied into the document only when they are JSON values. An object/enum default (a + * `new Money(0)` promoted default, say) has no JSON spelling that a client could send back, and emitting + * a serialised approximation of one would be a `default` the server never actually applies. + */ + private static function scalar(mixed $value): mixed + { + return match (true) { + is_scalar($value), $value === null => $value, + is_array($value) && array_is_list($value) && array_filter($value, 'is_scalar') === $value => $value, + default => null, + }; + } +} diff --git a/packages/openapi/src/Schema/ProblemSchema.php b/packages/openapi/src/Schema/ProblemSchema.php new file mode 100644 index 0000000..babda18 --- /dev/null +++ b/packages/openapi/src/Schema/ProblemSchema.php @@ -0,0 +1,102 @@ + + */ + public static function schema(): array + { + return [ + 'type' => 'object', + 'title' => 'Problem Details', + 'description' => 'RFC 9457 problem details as rendered by '.ErrorResponse::class.'::toArray(), served as '.self::MEDIA_TYPE.'.', + 'required' => ['status', 'title', 'code', 'category', 'severity'], + 'properties' => [ + 'status' => ['type' => 'integer', 'description' => 'The HTTP status code, repeated in the body.', 'minimum' => 100, 'maximum' => 599], + 'title' => ['type' => 'string', 'description' => 'A short, human-readable summary of the status.'], + 'code' => ['type' => 'string', 'description' => 'The stable, machine-readable Firefly error code (e.g. RESOURCE_NOT_FOUND).'], + 'category' => ['type' => 'string', 'description' => 'Firefly error category.', 'enum' => self::cases(ErrorCategory::cases())], + 'severity' => ['type' => 'string', 'description' => 'Firefly error severity.', 'enum' => self::cases(ErrorSeverity::cases())], + 'detail' => ['type' => 'string', 'description' => 'A human-readable explanation of this occurrence.'], + 'type' => ['type' => 'string', 'format' => 'uri-reference', 'description' => 'A URI reference identifying the problem type.'], + 'instance' => ['type' => 'string', 'description' => 'The request path this occurrence relates to.'], + 'traceId' => ['type' => 'string', 'description' => 'Correlation id for this occurrence, when tracing is active.'], + 'timestamp' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the error was rendered (ISO-8601).'], + 'errors' => [ + 'type' => 'array', + 'description' => 'Field-level errors; present only on a validation failure.', + 'items' => [ + 'type' => 'object', + 'required' => ['field', 'message'], + 'properties' => [ + 'field' => ['type' => 'string'], + 'message' => ['type' => 'string'], + 'code' => ['type' => 'string'], + 'rejectedValue' => ['description' => 'The value that was rejected, as received.'], + ], + ], + ], + ], + ]; + } + + /** + * @return array + */ + public static function response(): array + { + return [ + 'description' => 'Error response in RFC 9457 problem+json form.', + 'content' => [self::MEDIA_TYPE => ['schema' => ['$ref' => self::REF]]], + ]; + } + + /** + * @param list|list $cases + * @return list + */ + private static function cases(array $cases): array + { + return array_map(static fn (ErrorCategory|ErrorSeverity $case): string => $case->value, $cases); + } +} diff --git a/packages/openapi/src/Schema/PropertySchema.php b/packages/openapi/src/Schema/PropertySchema.php new file mode 100644 index 0000000..86e6437 --- /dev/null +++ b/packages/openapi/src/Schema/PropertySchema.php @@ -0,0 +1,21 @@ + $schema + */ + public function __construct( + public array $schema, + public bool $required, + ) {} +} diff --git a/packages/openapi/src/Schema/SchemaRegistry.php b/packages/openapi/src/Schema/SchemaRegistry.php new file mode 100644 index 0000000..19df2d5 --- /dev/null +++ b/packages/openapi/src/Schema/SchemaRegistry.php @@ -0,0 +1,94 @@ +> */ + private array $schemas = []; + + /** @var array component name => the class that claimed it */ + private array $owners = []; + + /** + * @param Closure(): array $build + * @return string the `$ref` pointer to this class's component schema + */ + public function ref(string $class, Closure $build): string + { + $name = $this->nameFor($class); + + if (! array_key_exists($name, $this->schemas)) { + // Reserve BEFORE building — see the class docblock's recursion note. The placeholder is only + // ever observable from inside $build()'s own re-entrant call, which reads the NAME, not the body. + $this->schemas[$name] = []; + $this->schemas[$name] = $build(); + } + + return '#/components/schemas/'.$name; + } + + /** + * Register a schema under a fixed, framework-owned name (ProblemDetails). Distinct from ref() because + * there is no class to derive a name from and no cycle to guard against. + * + * @param array $schema + */ + public function put(string $name, array $schema): void + { + $this->schemas[$name] = $schema; + } + + /** + * @return array> sorted by component name, so a regenerated document is + * byte-identical to the previous one and diffs usefully in + * review + */ + public function all(): array + { + $schemas = $this->schemas; + ksort($schemas); + + return $schemas; + } + + private function nameFor(string $class): string + { + $short = str_contains($class, '\\') ? substr($class, strrpos($class, '\\') + 1) : $class; + + if (($this->owners[$short] ?? $class) === $class) { + $this->owners[$short] = $class; + + return $short; + } + + return str_replace('\\', '.', $class); + } +} diff --git a/packages/openapi/src/Schema/TypeSchema.php b/packages/openapi/src/Schema/TypeSchema.php new file mode 100644 index 0000000..fd594df --- /dev/null +++ b/packages/openapi/src/Schema/TypeSchema.php @@ -0,0 +1,104 @@ +|null + */ + public static function for(?string $type): ?array + { + return match ($type) { + 'string' => ['type' => 'string'], + 'int' => ['type' => 'integer'], + 'float' => ['type' => 'number'], + 'bool' => ['type' => 'boolean'], + 'array', 'iterable' => ['type' => 'array'], + null, 'mixed', 'object', 'null' => [], + default => self::forClass($type), + }; + } + + /** + * Whether this type name denotes a DTO that should become its own reusable component schema — i.e. a + * real class that self() does not already resolve to an inline fragment. + */ + public static function isDto(?string $type): bool + { + return $type !== null && class_exists($type) && self::forClass($type) === null; + } + + /** + * @return array|null + */ + private static function forClass(string $type): ?array + { + if (! class_exists($type) && ! interface_exists($type)) { + // A type this process cannot autoload (a `never`/`static` return, a class from a package the + // app does not install). Documenting it as an opaque JSON value is honest; guessing is not. + return []; + } + + if (is_a($type, DateTimeInterface::class, true)) { + return ['type' => 'string', 'format' => 'date-time']; + } + + if (is_subclass_of($type, BackedEnum::class)) { + return self::backedEnum($type); + } + + if (! class_exists($type)) { + // An interface or a non-backed enum: nothing to reflect a property list out of, so it cannot + // become a component schema either. + return []; + } + + return null; + } + + /** + * @param class-string $enum + * @return array + */ + private static function backedEnum(string $enum): array + { + $values = array_map(static fn (BackedEnum $case): int|string => $case->value, $enum::cases()); + $integers = $values !== [] && array_filter($values, 'is_int') === $values; + + return ['type' => $integers ? 'integer' : 'string', 'enum' => $values]; + } +} diff --git a/packages/openapi/src/Web/OpenApiSpecAction.php b/packages/openapi/src/Web/OpenApiSpecAction.php new file mode 100644 index 0000000..265d9c5 --- /dev/null +++ b/packages/openapi/src/Web/OpenApiSpecAction.php @@ -0,0 +1,34 @@ +generator->toJson(), + 200, + ['Content-Type' => 'application/json'], + ); + } +} diff --git a/packages/openapi/src/Web/OpenApiViewerAction.php b/packages/openapi/src/Web/OpenApiViewerAction.php new file mode 100644 index 0000000..b850edc --- /dev/null +++ b/packages/openapi/src/Web/OpenApiViewerAction.php @@ -0,0 +1,37 @@ +page->render($this->urls->to($this->properties->specPath), $this->properties->viewerCdn), + 200, + ['Content-Type' => 'text/html; charset=UTF-8'], + ); + } +} diff --git a/packages/openapi/src/Web/ViewerPage.php b/packages/openapi/src/Web/ViewerPage.php new file mode 100644 index 0000000..ebfafbd --- /dev/null +++ b/packages/openapi/src/Web/ViewerPage.php @@ -0,0 +1,286 @@ +swaggerUi($specUrl) : $this->builtIn($specUrl); + } + + private function builtIn(string $specUrl): string + { + $title = $this->escape($this->title); + $url = $this->json($specUrl); + + return << + + + + + {$title} — API reference + + + +

Loading the specification…

+ + + + HTML; + } + + private function swaggerUi(string $specUrl): string + { + $title = $this->escape($this->title); + $url = $this->json($specUrl); + $version = self::SWAGGER_UI_VERSION; + + return << + + + + + {$title} — API reference + + + +
+ + + + + HTML; + } + + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * The spec URL reaches JavaScript as a JSON literal, and with HEX_TAG/HEX_AMP/HEX_APOS/HEX_QUOT set so a + * configured path containing `` (or a quote) cannot break out of the script element. The path + * comes from application config rather than a request, so this is defence in depth rather than a fix for + * a known injection — but a viewer that renders a config value into inline script has no business + * relying on that distinction. + */ + private function json(string $value): string + { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES); + } +} diff --git a/packages/openapi/tests/BeanOverrideTest.php b/packages/openapi/tests/BeanOverrideTest.php new file mode 100644 index 0000000..f2b4b09 --- /dev/null +++ b/packages/openapi/tests/BeanOverrideTest.php @@ -0,0 +1,30 @@ + [ + 'openapi' => [], + 'scan' => ['paths' => ['Firefly\\OpenApi\\Tests\\Override\\' => __DIR__.'/Override']], + ]], + providers: [OpenApiServiceProvider::class, OpenApiWiringProvider::class], + bindings: [ + RouteManifest::class => new RouteManifest([]), + ConstraintManifest::class => new ConstraintManifest([]), + ], + ); + + /** @var ViewerPage $page */ + $page = $app->make(ViewerPage::class); + + // The whole override contract: every collaborator is a #[Bean] behind #[ConditionalOnMissingBean], so + // replacing one is a five-line #[Configuration] in the app and needs no fork of the package. + expect($page->render('/openapi.json', cdn: false))->toContain('Corporate Console'); +}); diff --git a/packages/openapi/tests/Command/OpenApiCommandTest.php b/packages/openapi/tests/Command/OpenApiCommandTest.php new file mode 100644 index 0000000..232494e --- /dev/null +++ b/packages/openapi/tests/Command/OpenApiCommandTest.php @@ -0,0 +1,60 @@ +artisan(): the PendingCommand helper substitutes a MOCK OutputStyle and + // asserts against expectations set on it, so the bytes this command writes never reach a real buffer + // there. Capturing stdout is the entire point of this test, so it goes through the kernel for real. + expect(Artisan::call('firefly:openapi'))->toBe(0); + + // `php artisan firefly:openapi > openapi.json` has to produce a byte-exact document, so stdout must + // carry the JSON and nothing else — no banner, no summary line, and no Symfony formatter rewriting of + // any `<...>` sequence that reaches the document from a docblock or a config value. + /** @var array $document */ + $document = json_decode(trim(Artisan::output()), true, flags: JSON_THROW_ON_ERROR); + + expect($document['openapi'])->toBe('3.1.0') + ->and($document['paths'])->toHaveKey('/api/orders'); +}); + +it('writes the document to a file and creates the parent directory', function () { + /** @var OpenApiCapstoneTestCase $this */ + $target = sys_get_temp_dir().'/firefly-openapi-'.bin2hex(random_bytes(6)).'/api/openapi.json'; + + try { + expect(Artisan::call('firefly:openapi', ['--output' => $target]))->toBe(0) + ->and(is_file($target))->toBeTrue(); + + /** @var array $document */ + $document = json_decode((string) file_get_contents($target), true, flags: JSON_THROW_ON_ERROR); + + expect($document['openapi'])->toBe('3.1.0') + ->and($document['components'])->toHaveKey('schemas'); + } finally { + @unlink($target); + @rmdir(dirname($target)); + @rmdir(dirname($target, 2)); + } +}); + +it('produces the same bytes on the file path as on stdout, plus a trailing newline', function () { + /** @var OpenApiCapstoneTestCase $this */ + // A committed spec file that differs from the served one — even by whitespace — turns every CI diff into + // noise, so the two serialisations must be the same document. + $target = sys_get_temp_dir().'/firefly-openapi-'.bin2hex(random_bytes(6)).'.json'; + + try { + expect(Artisan::call('firefly:openapi', ['--output' => $target]))->toBe(0) + ->and(Artisan::call('firefly:openapi'))->toBe(0) + ->and((string) file_get_contents($target))->toBe(trim(Artisan::output()).PHP_EOL); + } finally { + @unlink($target); + } +}); diff --git a/packages/openapi/tests/CompiledManifestFreshnessTest.php b/packages/openapi/tests/CompiledManifestFreshnessTest.php new file mode 100644 index 0000000..aae6ead --- /dev/null +++ b/packages/openapi/tests/CompiledManifestFreshnessTest.php @@ -0,0 +1,30 @@ + dirname(__DIR__).'/src']; + $cache = dirname(__DIR__).'/cache'; + + $freshComponents = sys_get_temp_dir().'/firefly-openapi-fresh-c-'.bin2hex(random_bytes(6)).'.php'; + $freshContext = sys_get_temp_dir().'/firefly-openapi-fresh-x-'.bin2hex(random_bytes(6)).'.php'; + + try { + (new AutoConfigManifestCompiler)->write($src, $freshComponents, $freshContext); + + expect(ComponentManifest::load($freshComponents))->toEqual(ComponentManifest::load($cache.'/firefly-openapi-components.php')) + ->and(ContextManifest::load($freshContext))->toEqual(ContextManifest::load($cache.'/firefly-openapi-context.php')); + } finally { + @unlink($freshComponents); + @unlink($freshContext); + } +}); diff --git a/packages/openapi/tests/EdgeFixture/Alpha/ReportController.php b/packages/openapi/tests/EdgeFixture/Alpha/ReportController.php new file mode 100644 index 0000000..d4082aa --- /dev/null +++ b/packages/openapi/tests/EdgeFixture/Alpha/ReportController.php @@ -0,0 +1,30 @@ + */ + #[GetMapping('/{slug?}')] + public function index(#[PathVariable] ?string $slug = null): array + { + return ['slug' => $slug]; + } +} diff --git a/packages/openapi/tests/EdgeFixture/Beta/ReportController.php b/packages/openapi/tests/EdgeFixture/Beta/ReportController.php new file mode 100644 index 0000000..eee097e --- /dev/null +++ b/packages/openapi/tests/EdgeFixture/Beta/ReportController.php @@ -0,0 +1,25 @@ + */ + #[GetMapping] + public function index(): array + { + return []; + } +} diff --git a/packages/openapi/tests/Fixture/AddressPayload.php b/packages/openapi/tests/Fixture/AddressPayload.php new file mode 100644 index 0000000..7a32741 --- /dev/null +++ b/packages/openapi/tests/Fixture/AddressPayload.php @@ -0,0 +1,19 @@ + Rule\Uuid). + */ +final class CreateOrderRequest +{ + public function __construct( + #[NotBlank] #[Size(max: 64)] public readonly string $reference, + #[NotNull] #[Email] public readonly string $email, + #[Min(1)] #[Max(999)] public readonly int $quantity, + #[Positive] #[DecimalScale(2)] public readonly float $amount, + public readonly Currency $currency, + #[Valid] public readonly AddressPayload $shipTo, + #[Pattern('/^[A-Z]{3}-\d{4}$/D')] public readonly ?string $coupon = null, + #[UuidValue] public readonly ?string $idempotencyKey = null, + ) {} +} diff --git a/packages/openapi/tests/Fixture/Currency.php b/packages/openapi/tests/Fixture/Currency.php new file mode 100644 index 0000000..a052c8c --- /dev/null +++ b/packages/openapi/tests/Fixture/Currency.php @@ -0,0 +1,12 @@ + */ + #[GetMapping('/{id}')] + public function show( + #[PathVariable] string $id, + #[QueryParam(name: 'expand', default: false)] bool $expand = false, + #[RequestHeader(name: 'X-Tenant')] ?string $tenant = null, + ): array { + return ['id' => $id, 'expand' => $expand, 'tenant' => $tenant]; + } + + /** @return array */ + #[PostMapping(status: 201, name: 'orders.create')] + public function create(#[Valid] #[RequestBody] CreateOrderRequest $body): array + { + return ['reference' => $body->reference]; + } + + #[DeleteMapping('/{id}', status: 204)] + public function cancel(#[PathVariable] string $id): void {} +} diff --git a/packages/openapi/tests/Fixture/SelfReferential.php b/packages/openapi/tests/Fixture/SelfReferential.php new file mode 100644 index 0000000..5700ee9 --- /dev/null +++ b/packages/openapi/tests/Fixture/SelfReferential.php @@ -0,0 +1,22 @@ + $document + * @return array + */ +function responseContent(array $document, string $path, string $verb, string $status): array +{ + /** @var mixed $node */ + $node = $document['paths'] ?? []; + + foreach ([$path, $verb, 'responses', $status, 'content'] as $segment) { + if (! is_array($node) || ! array_key_exists($segment, $node)) { + return []; + } + /** @var mixed $node */ + $node = $node[$segment]; + } + + /** @var array $content */ + $content = is_array($node) ? $node : []; + + return $content; +} + +/** + * A #[Controller] renders a web page. It is part of the application's HTTP surface, but it is not a JSON API + * operation — and because #[Controller] extends #[RestController] it lands in the same RouteManifest as + * every JSON route. Left alone, the generator documented the welcome page as `application/json`, which a + * client generator would faithfully turn into a typed call expecting a deserialisable body. + */ +it('leaves HTML routes out of the document by default', function () { + /** @var array $paths */ + $paths = FixtureDocument::generator()->generate()['paths']; + + expect(array_keys($paths))->not->toContain('/welcome'); +}); + +it('documents an HTML route as text/html when asked to include it', function () { + $properties = FixtureDocument::properties(includeHtml: true); + + /** @var array>> $paths */ + $paths = FixtureDocument::generator($properties)->generate()['paths']; + + expect($paths)->toHaveKey('/welcome'); + + $content = responseContent(FixtureDocument::generator($properties)->generate(), '/welcome', 'get', '200'); + + expect($content)->toHaveKey('text/html') + ->and($content)->not->toHaveKey('application/json'); +}); + +// The flag must not disturb the JSON operations that were always there. +it('still documents JSON routes as application/json either way', function () { + foreach ([FixtureDocument::properties(), FixtureDocument::properties(includeHtml: true)] as $properties) { + $content = responseContent(FixtureDocument::generator($properties)->generate(), '/api/orders', 'post', '201'); + + expect($content)->toHaveKey('application/json'); + } +}); diff --git a/packages/openapi/tests/Generator/OpenApiGeneratorTest.php b/packages/openapi/tests/Generator/OpenApiGeneratorTest.php new file mode 100644 index 0000000..6eec059 --- /dev/null +++ b/packages/openapi/tests/Generator/OpenApiGeneratorTest.php @@ -0,0 +1,220 @@ +generate(); + + expect($document['openapi'])->toBe('3.1.0') + ->and($document)->toHaveKeys(['openapi', 'info', 'paths', 'components']) + ->and($document['info'])->toBe(['title' => 'Orders API', 'version' => '1.2.3', 'description' => 'The fixture API.']); +}); + +it('maps every scanned route onto a path item keyed by its lowercased verb', function () { + /** @var array> $paths */ + $paths = FixtureDocument::generator()->generate()['paths']; + + expect(array_keys($paths))->toBe(['/api/orders', '/api/orders/{id}']) + ->and(array_keys($paths['/api/orders']))->toBe(['post']) + ->and(array_keys($paths['/api/orders/{id}']))->toBe(['get', 'delete']); +}); + +it('prefers the route name as the operationId and derives a unique one otherwise', function () { + /** @var array>> $paths */ + $paths = FixtureDocument::generator()->generate()['paths']; + + expect($paths['/api/orders']['post']['operationId'])->toBe('orders.create') + ->and($paths['/api/orders/{id}']['get']['operationId'])->toBe('orderShow') + ->and($paths['/api/orders/{id}']['delete']['operationId'])->toBe('orderCancel'); +}); + +it('turns path, query and header bindings into parameters and leaves service bindings out', function () { + /** @var array>> $paths */ + $paths = FixtureDocument::generator()->generate()['paths']; + /** @var list> $parameters */ + $parameters = $paths['/api/orders/{id}']['get']['parameters']; + + $byName = []; + foreach ($parameters as $parameter) { + /** @var string $name */ + $name = $parameter['name']; + $byName[$name] = $parameter; + } + + expect(array_keys($byName))->toBe(['id', 'expand', 'X-Tenant']) + ->and($byName['id']['in'])->toBe('path') + ->and($byName['id']['required'])->toBeTrue() + ->and($byName['expand']['in'])->toBe('query') + ->and($byName['expand']['required'])->toBeFalse() + ->and($byName['expand']['schema'])->toBe(['type' => 'boolean', 'default' => false]) + ->and($byName['X-Tenant']['in'])->toBe('header'); +}); + +it('references the body DTO by $ref rather than inlining it', function () { + /** @var array>> $paths */ + $paths = FixtureDocument::generator()->generate()['paths']; + /** @var array $body */ + $body = $paths['/api/orders']['post']['requestBody']; + + expect($body['required'])->toBeTrue() + ->and($body['content'])->toBe([ + 'application/json' => ['schema' => ['$ref' => '#/components/schemas/CreateOrderRequest']], + ]); +}); + +it('derives the body schema properties and required list from the compiled constraints', function () { + /** @var array>> $components */ + $components = FixtureDocument::generator()->generate()['components']; + /** @var array $schema */ + $schema = $components['schemas']['CreateOrderRequest']; + + /** @var array> $properties */ + $properties = $schema['properties']; + + expect($schema['type'])->toBe('object') + // #[NotBlank] and #[NotNull] make a member required; a nullable member with a default does not. + ->and($schema['required'])->toBe(['reference', 'email', 'quantity', 'amount', 'currency', 'shipTo']) + // #[Size(max: 64)] compiles to a Size rule OBJECT and must measure LENGTH, never magnitude. + ->and($properties['reference'])->toMatchArray(['type' => 'string', 'maxLength' => 64]) + ->and($properties['email'])->toMatchArray(['type' => 'string', 'format' => 'email']) + // #[Min]/#[Max] emit `numeric` + gte/lte; the DECLARED int must survive that widening. + ->and($properties['quantity'])->toBe(['type' => 'integer', 'minimum' => 1, 'maximum' => 999]) + ->and($properties['amount'])->toBe(['type' => 'number', 'exclusiveMinimum' => 0, 'multipleOf' => 0.01]) + // A backed enum is documented from the TYPE — no constraint states this set anywhere. + ->and($properties['currency'])->toBe(['type' => 'string', 'enum' => ['EUR', 'USD']]) + // Jakarta's null contract: a nullable member is a type UNION in 3.1, not a `nullable` keyword. + ->and($properties['coupon']['type'])->toBe(['string', 'null']) + ->and($properties['coupon']['pattern'])->toBe('^[A-Z]{3}-\d{4}$'); +}); + +it('gives a nested #[Valid] DTO its own component and reaches it by $ref', function () { + $document = FixtureDocument::generator()->generate(); + /** @var array>> $components */ + $components = $document['components']; + + /** @var array> $properties */ + $properties = $components['schemas']['CreateOrderRequest']['properties']; + + expect($properties['shipTo'])->toBe(['$ref' => '#/components/schemas/AddressPayload']) + ->and($components['schemas'])->toHaveKey('AddressPayload') + ->and($components['schemas']['AddressPayload']['required'])->toBe(['line1', 'postcode']); +}); + +it('attaches the shared problem response to every operation', function () { + $document = FixtureDocument::generator()->generate(); + /** @var array>> $paths */ + $paths = $document['paths']; + + foreach ($paths as $item) { + foreach ($item as $operation) { + /** @var array $responses */ + $responses = $operation['responses']; + expect($responses['default'])->toBe(['$ref' => ProblemSchema::RESPONSE_REF]); + } + } + + /** @var array> $components */ + $components = $document['components']; + + expect($components['responses'])->toHaveKey(ProblemSchema::RESPONSE_NAME) + ->and($components['schemas'])->toHaveKey(ProblemSchema::NAME); +}); + +it('documents 422 only where a binding carries #[Valid], and 400 only where binding can fail', function () { + /** @var array>> $paths */ + $paths = FixtureDocument::generator()->generate()['paths']; + + /** @var array $create */ + $create = $paths['/api/orders']['post']['responses']; + /** @var array $show */ + $show = $paths['/api/orders/{id}']['get']['responses']; + /** @var array $cancel */ + $cancel = $paths['/api/orders/{id}']['delete']['responses']; + + // Status keys are compared as strings because PHP silently coerces the numeric ones to INTEGER array + // keys — '201' becomes 201 the moment it is written. That coercion is harmless in the document itself + // (a map whose keys are 201/400/'default' is not a PHP list, so json_encode still writes an object), but + // it is a real trap for anyone asserting against generate()'s raw array, so the tests normalise rather + // than quietly expecting ints. + $statuses = static fn (array $responses): array => array_map(strval(...), array_keys($responses)); + + expect($statuses($create))->toBe(['201', '400', '422', 'default']) + // A bool query parameter must be coerced out of the wire's string, so 400 is reachable. + ->and($statuses($show))->toBe(['200', '400', 'default']) + // A single `string` path variable cannot fail binding at all — no phantom 400. + ->and($statuses($cancel))->toBe(['204', 'default']) + ->and($cancel[204])->toBe(['description' => 'No content.']); +}); + +it('resolves every local $ref it emits', function () { + $document = FixtureDocument::generator()->generate(); + $refs = FixtureDocument::refs($document); + + expect($refs)->not->toBeEmpty(); + + foreach (array_unique($refs) as $ref) { + expect(FixtureDocument::resolve($document, $ref))->not->toBeNull("dangling \$ref {$ref}"); + } +}); + +it('never emits an empty required array', function () { + $document = FixtureDocument::generator()->generate(); + + $walk = function (mixed $node) use (&$walk): void { + if (! is_array($node)) { + return; + } + if (array_key_exists('required', $node) && $node['required'] === []) { + throw new RuntimeException('an empty `required` array is invalid under the OpenAPI 3.1 meta-schema'); + } + foreach ($node as $child) { + $walk($child); + } + }; + + expect(fn () => $walk($document))->not->toThrow(RuntimeException::class); +}); + +it('serialises an empty map as a JSON object, never as an empty array', function () { + // An app with no documented routes is the case that catches this: PHP spells an empty map [], and + // `"paths": []` is a type error against the OpenAPI 3.1 meta-schema that a strict validator rejects + // outright. toJson() is the only serialisation that guarantees the fix, which is why it exists. + $json = FixtureDocument::generator(FixtureDocument::properties(exclude: ['/api']))->toJson(); + + expect($json)->toContain('"paths": {}') + ->and($json)->not->toContain('"paths": []'); + + /** @var stdClass $decoded */ + $decoded = json_decode($json, false, flags: JSON_THROW_ON_ERROR); + + expect($decoded)->toBeInstanceOf(stdClass::class) + ->and($decoded->paths)->toBeInstanceOf(stdClass::class); +}); + +it('omits servers when none are configured and emits them when they are', function () { + expect(FixtureDocument::generator()->generate())->not->toHaveKey('servers'); + + $document = FixtureDocument::generator(FixtureDocument::properties(servers: [['url' => 'https://api.test', 'description' => 'prod']]))->generate(); + + expect($document['servers'])->toBe([['url' => 'https://api.test', 'description' => 'prod']]); +}); + +it('drops routes under a configured exclude prefix', function () { + $document = FixtureDocument::generator(FixtureDocument::properties(exclude: ['/api/orders']))->generate(); + + expect($document['paths'])->toBe([]); +}); + +it('is deterministic across generations', function () { + expect(FixtureDocument::generator()->toJson())->toBe(FixtureDocument::generator()->toJson()); +}); diff --git a/packages/openapi/tests/Generator/OperationIdAndPathTemplateTest.php b/packages/openapi/tests/Generator/OperationIdAndPathTemplateTest.php new file mode 100644 index 0000000..7506411 --- /dev/null +++ b/packages/openapi/tests/Generator/OperationIdAndPathTemplateTest.php @@ -0,0 +1,105 @@ + + */ +function edgeDocument(): array +{ + $routes = new RouteManifest((new RouteScanner)->scan([ + 'Firefly\\OpenApi\\Tests\\EdgeFixture\\' => dirname(__DIR__).'/EdgeFixture', + ])); + + return (new OpenApiGenerator( + $routes, + FixtureDocument::properties(), + new OperationFactory(new DtoSchemaFactory(ConstraintManifest::fromArray([]), new ConstraintSchemaMapper)), + ))->generate(); +} + +/** + * @param array $document + * @return list + */ +function edgeOperationIds(array $document): array +{ + $ids = []; + + /** @var array>> $paths */ + $paths = $document['paths']; + foreach ($paths as $item) { + foreach ($item as $operation) { + expect($operation)->toHaveKey('operationId'); + + $id = $operation['operationId']; + expect($id)->toBeString(); + + /** @var string $id */ + $ids[] = $id; + } + } + + return $ids; +} + +it('suffixes a duplicate operationId instead of letting one operation overwrite the other', function () { + $document = edgeDocument(); + + // Both controllers are named ReportController::index, so derivedId() offers `reportIndex` twice. Which + // route claims the bare id depends on filesystem scan order, so the assertion is on the SET, not on the + // assignment: two operations survive, both are present, and the ids are distinct. + expect($document['paths'])->toHaveCount(2); + + $ids = edgeOperationIds($document); + + expect($ids)->toHaveCount(2) + ->and(array_unique($ids))->toHaveCount(2) + ->and(array_values(array_unique($ids)))->toEqualCanonicalizing(['reportIndex', 'reportIndex_2']); +}); + +it('templates a Laravel optional path variable into a legal, required OpenAPI parameter', function () { + $document = edgeDocument(); + + /** @var array>> $paths */ + $paths = $document['paths']; + + // The RouteScanner really does emit `/alpha/reports/{slug?}` (that is Laravel's optional spelling); the + // generator must publish it without the marker, because OpenAPI has no optional path parameter. + expect($paths)->toHaveKey('/alpha/reports/{slug}') + ->and($paths)->not->toHaveKey('/alpha/reports/{slug?}'); + + foreach (array_keys($paths) as $path) { + expect($path)->not->toContain('?'); + } + + /** @var list> $parameters */ + $parameters = $paths['/alpha/reports/{slug}']['get']['parameters']; + $slug = array_values(array_filter($parameters, static fn (array $p): bool => $p['name'] === 'slug')); + + expect($slug)->toHaveCount(1) + // A path parameter is required in OpenAPI, full stop — even when the PHP signature defaults it to + // null. Publishing `required: false` here produces a document a strict validator rejects. + ->and($slug[0]['in'])->toBe('path') + ->and($slug[0]['required'])->toBeTrue(); +}); diff --git a/packages/openapi/tests/OpenApiPropertiesTest.php b/packages/openapi/tests/OpenApiPropertiesTest.php new file mode 100644 index 0000000..0ff463b --- /dev/null +++ b/packages/openapi/tests/OpenApiPropertiesTest.php @@ -0,0 +1,72 @@ + $firefly + */ +function openApiPropertiesFrom(array $firefly): OpenApiProperties +{ + return OpenApiProperties::fromConfig(new Config(new Repository(['firefly' => $firefly]))); +} + +it('defaults to an enabled spec and viewer with the CDN opt-in OFF', function () { + $properties = openApiPropertiesFrom([]); + + expect($properties->enabled)->toBeTrue() + ->and($properties->specPath)->toBe('openapi.json') + ->and($properties->viewerEnabled)->toBeTrue() + ->and($properties->viewerPath)->toBe('openapi') + // The default viewer must never reach the network. Flipping this default is a supply-chain change, + // not a cosmetic one — see ViewerPage. + ->and($properties->viewerCdn)->toBeFalse() + ->and($properties->servers)->toBe([]) + ->and($properties->excludePathPrefixes)->toBe([]); +}); + +it('strips the leading slash the Router strips anyway, so links and routes agree', function () { + $properties = openApiPropertiesFrom(['openapi' => ['path' => '/docs/api.json', 'viewer' => ['path' => '/docs/']]]); + + expect($properties->specPath)->toBe('docs/api.json') + ->and($properties->viewerPath)->toBe('docs'); +}); + +it('falls back to the default path when the configured one trims to nothing', function () { + $properties = openApiPropertiesFrom(['openapi' => ['path' => '/', 'viewer' => ['path' => '///']]]); + + expect($properties->specPath)->toBe('openapi.json') + ->and($properties->viewerPath)->toBe('openapi'); +}); + +it('accepts servers as bare URL strings or as OpenAPI server objects', function () { + $properties = openApiPropertiesFrom(['openapi' => ['servers' => [ + 'https://api.test', + ['url' => 'https://staging.test', 'description' => 'staging'], + ]]]); + + expect($properties->servers)->toBe([ + ['url' => 'https://api.test'], + ['url' => 'https://staging.test', 'description' => 'staging'], + ]); +}); + +it('drops a server entry with no url rather than emitting an invalid Server Object', function () { + $properties = openApiPropertiesFrom(['openapi' => ['servers' => [ + ['description' => 'no url here'], + '', + 42, + ['url' => 'https://api.test'], + ]]]); + + expect($properties->servers)->toBe([['url' => 'https://api.test']]); +}); + +it('reads the exclude list as a CSV of path prefixes', function () { + $properties = openApiPropertiesFrom(['openapi' => ['exclude' => '/internal, /admin ,']]); + + expect($properties->excludePathPrefixes)->toBe(['/internal', '/admin']); +}); diff --git a/packages/openapi/tests/Override/AppOpenApiConfiguration.php b/packages/openapi/tests/Override/AppOpenApiConfiguration.php new file mode 100644 index 0000000..cb56ea1 --- /dev/null +++ b/packages/openapi/tests/Override/AppOpenApiConfiguration.php @@ -0,0 +1,29 @@ + $openapi + */ +function bootOpenApiApp(array $openapi = []): Application +{ + return fireflyApplication( + config: ['firefly' => ['openapi' => $openapi]], + providers: [OpenApiServiceProvider::class, OpenApiWiringProvider::class], + bindings: [ + RouteManifest::class => new RouteManifest([]), + ConstraintManifest::class => new ConstraintManifest([]), + ], + ); +} + +it('boots a bare skeleton with the openapi providers registered', function () { + expect(bootOpenApiApp()->make(ApplicationContext::class))->toBeInstanceOf(ApplicationContext::class); +}); + +it('binds every pipeline bean the compiled manifest describes', function () { + $app = bootOpenApiApp(); + + expect($app->make(OpenApiProperties::class))->toBeInstanceOf(OpenApiProperties::class) + ->and($app->make(ConstraintSchemaMapper::class))->toBeInstanceOf(ConstraintSchemaMapper::class) + ->and($app->make(OpenApiGenerator::class))->toBeInstanceOf(OpenApiGenerator::class) + ->and($app->make(ViewerPage::class))->toBeInstanceOf(ViewerPage::class); +}); + +it('generates a valid, empty-but-well-formed document from empty manifests', function () { + /** @var OpenApiGenerator $generator */ + $generator = bootOpenApiApp()->make(OpenApiGenerator::class); + + $json = $generator->toJson(); + + // An app with no routes must still produce a document a validator accepts — `"paths": {}`, never `[]`. + expect($json)->toContain('"paths": {}') + ->and($generator->generate()['openapi'])->toBe('3.1.0'); +}); + +it('mounts both routes on the router by default', function () { + /** @var Router $router */ + $router = bootOpenApiApp()->make('router'); + + $names = []; + foreach ($router->getRoutes()->getRoutes() as $route) { + $names[] = $route->getName(); + } + + expect($names)->toContain('firefly.openapi.spec') + ->and($names)->toContain('firefly.openapi.viewer'); +}); + +it('mounts nothing when the master gate is off', function () { + /** @var Router $router */ + $router = bootOpenApiApp(['enabled' => false])->make('router'); + + expect($router->getRoutes()->getRoutes())->toBe([]); +}); diff --git a/packages/openapi/tests/Schema/ConstraintSchemaMapperTest.php b/packages/openapi/tests/Schema/ConstraintSchemaMapperTest.php new file mode 100644 index 0000000..714d734 --- /dev/null +++ b/packages/openapi/tests/Schema/ConstraintSchemaMapperTest.php @@ -0,0 +1,183 @@ + $base the declared-type fragment TypeSchema would have produced + * @param list $rules + */ +function openApiMapConstraints(array $base, array $rules, bool $nullable = false, bool $required = false): PropertySchema +{ + return (new ConstraintSchemaMapper)->apply($base, $rules, $nullable, $required); +} + +it('makes #[NotBlank] a required, non-blank string', function () { + $property = openApiMapConstraints(['type' => 'string'], (new NotBlank)->toRules()); + + expect($property->required)->toBeTrue() + ->and($property->schema)->toBe(['type' => 'string', 'pattern' => '\S']); +}); + +it('makes #[NotNull] required AND clears nullability, even on a nullable declaration', function () { + // #[NotNull] is the one constraint that answers both questions Jakarta keeps separate. Its NullAware + // rule object is also why ConstraintScanner withholds the `nullable` flag from the property entirely. + $property = openApiMapConstraints(['type' => 'string'], (new NotNull)->toRules(), nullable: true); + + expect($property->required)->toBeTrue() + ->and($property->schema)->toBe(['type' => 'string']); +}); + +it('spells a nullable member as a 3.1 type union and widens an enum with null', function () { + $string = openApiMapConstraints(['type' => 'string'], ['nullable', ...(new Email)->toRules()]); + $enum = openApiMapConstraints(['type' => 'string', 'enum' => ['EUR']], ['nullable']); + + expect($string->schema)->toBe(['type' => ['string', 'null'], 'format' => 'email']) + ->and($string->required)->toBeFalse() + ->and($enum->schema)->toBe(['type' => ['string', 'null'], 'enum' => ['EUR', null]]); +}); + +it('measures #[Size] as a length on a string and as a count on an array', function () { + $rules = (new Size(min: 2, max: 10))->toRules(); + + expect(openApiMapConstraints(['type' => 'string'], $rules)->schema)->toBe(['type' => 'string', 'minLength' => 2, 'maxLength' => 10]) + ->and(openApiMapConstraints(['type' => 'array'], $rules)->schema)->toBe(['type' => 'array', 'minItems' => 2, 'maxItems' => 10]); +}); + +it('keeps the declared integer type through #[Min]/#[Max], which only say `numeric`', function () { + // The rule list here is ['numeric', 'gte:1', 'numeric', 'lte:9']. `number` would be a SUPERSET of the + // declared int and would document 1.5 as acceptable, so first-writer-wins must leave `integer` standing. + $property = openApiMapConstraints(['type' => 'integer'], [...(new Min(1))->toRules(), ...(new Max(9))->toRules()]); + + expect($property->schema)->toBe(['type' => 'integer', 'minimum' => 1, 'maximum' => 9]); +}); + +it('translates a PCRE pattern to a bare ECMA-262 pattern, dropping only the no-op flags', function () { + $property = openApiMapConstraints(['type' => 'string'], (new Pattern('/^[A-Z]{3}$/D'))->toRules()); + + expect($property->schema)->toBe(['type' => 'string', 'pattern' => '^[A-Z]{3}$']); +}); + +it('still emits a flagged pattern but records the loss, because ECMA-262 has no inline flags', function () { + $property = openApiMapConstraints(['type' => 'string'], (new Pattern('/^abc$/i'))->toRules()); + + expect($property->schema['pattern'])->toBe('^abc$') + ->and($property->schema[ConstraintSchemaMapper::EXTENSION])->toBe(['regex:/^abc$/i']); +}); + +it('collects two patterns into an allOf rather than letting one overwrite the other', function () { + // #[NotBlank] contributes `\S` and #[Pattern] contributes the developer's own; JSON Schema has exactly + // one `pattern` slot, so keeping only the last would silently drop the non-blank guarantee. + $property = openApiMapConstraints(['type' => 'string'], [...(new NotBlank)->toRules(), ...(new Pattern('/^[a-z]+$/D'))->toRules()]); + + expect($property->schema['allOf'])->toBe([['pattern' => '\S'], ['pattern' => '^[a-z]+$']]) + ->and($property->schema)->not->toHaveKey('pattern'); +}); + +it('records a constraint JSON Schema cannot state instead of dropping it', function () { + // JSON Schema has no way to say "in the future"; the server enforces it regardless, so the document + // must not imply otherwise. + $property = openApiMapConstraints(['type' => 'string'], (new Future)->toRules()); + + expect($property->schema)->toMatchArray(['type' => 'string', 'format' => 'date-time']) + ->and($property->schema[ConstraintSchemaMapper::EXTENSION])->toBe(['after:now']); +}); + +it('records an unrecognised third-party rule by class name', function () { + $rule = new class implements ValidationRule + { + public function validate(string $attribute, mixed $value, Closure $fail): void {} + }; + + $property = openApiMapConstraints([], [$rule]); + + expect($property->schema[ConstraintSchemaMapper::EXTENSION])->toBe([$rule::class]); +}); + +it('maps the first-party rule objects onto formats and bounds', function () { + $uuid = openApiMapConstraints(['type' => 'string'], (new UuidValue)->toRules()); + $percentage = openApiMapConstraints([], (new Percentage)->toRules()); + $accepted = openApiMapConstraints([], (new AssertTrue)->toRules()); + + expect($uuid->schema['format'])->toBe('uuid') + ->and($percentage->schema)->toBe(['type' => 'number', 'minimum' => 0, 'maximum' => 100]) + ->and($accepted->schema)->toBe(['type' => 'boolean', 'const' => true]); +}); + +it('resolves Laravel\'s polymorphic min:/max: the way the validator resolves them', function () { + // #[Rules] passes raw Laravel strings straight through, and `min:3` means LENGTH on a string but + // MAGNITUDE on a number — Validator::getSize()'s own rule, applied here from the resolved type. + $string = openApiMapConstraints(['type' => 'string'], (new Rules('min:3'))->toRules()); + $number = openApiMapConstraints(['type' => 'integer'], (new Rules('min:3'))->toRules()); + + expect($string->schema)->toBe(['type' => 'string', 'minLength' => 3]) + ->and($number->schema)->toBe(['type' => 'integer', 'minimum' => 3]); +}); + +it('records a bound that names another field rather than coercing it to zero', function () { + $property = openApiMapConstraints(['type' => 'integer'], (new Rules('gte:other_field'))->toRules()); + + expect($property->schema)->toBe(['type' => 'integer', ConstraintSchemaMapper::EXTENSION => ['minimum:other_field']]); +}); + +it('treats a declaration with no default and no null as required even with no constraint', function () { + // ArgumentResolver splats only the keys the body carried, so omitting such a member raises + // ArgumentCountError inside `new $dto(...)` — a 500 AFTER validation passed. Documenting it as optional + // would hand a client a legal-looking request the server cannot serve. + expect(openApiMapConstraints(['type' => 'string'], [], nullable: false, required: true)->required)->toBeTrue() + ->and(openApiMapConstraints(['type' => 'string'], [], nullable: false, required: false)->required)->toBeFalse(); +}); + +it('withholds a pattern for every rule that NORMALISES the value before matching', function (Constraint $constraint, string $format, ?string $unmappable) { + $property = openApiMapConstraints(['type' => 'string'], $constraint->toRules()); + + // Iban strips whitespace and upper-cases; Bic/Isin/Cusip upper-case; Luhn/RoutingNumber strip every + // non-digit. Each rule's own preg_match therefore runs against a string the CLIENT never sent, so + // publishing that post-normalisation pattern would tell a generated client to reject `de89 3704 ...` + // — a payload this server accepts. Under-specifying is the only honest option, so `format` is emitted + // and `pattern` deliberately is not. + expect($property->schema)->not->toHaveKey('pattern') + ->and($property->schema)->not->toHaveKey('allOf') + ->and($property->schema['format'])->toBe($format); + + // The check digit is arithmetic, which JSON Schema cannot state at all, so it is RECORDED rather than + // dropped silently — the losslessness rule this package is built on. + if ($unmappable !== null) { + expect($property->schema[ConstraintSchemaMapper::EXTENSION])->toContain($unmappable); + } +})->with([ + 'iban' => [new Iban, 'iban', 'iban:checksum'], + 'bic' => [new Bic, 'bic', null], + 'isin' => [new Isin, 'isin', 'isin:check-digit'], + 'cusip' => [new Cusip, 'cusip', 'cusip:check-digit'], + 'luhn' => [new Luhn, 'luhn', 'luhn:check-digit'], + 'routing number' => [new RoutingNumber, 'aba-routing-number', 'routing-number:check-digit'], +]); diff --git a/packages/openapi/tests/Schema/DtoSchemaFactoryTest.php b/packages/openapi/tests/Schema/DtoSchemaFactoryTest.php new file mode 100644 index 0000000..5fecc70 --- /dev/null +++ b/packages/openapi/tests/Schema/DtoSchemaFactoryTest.php @@ -0,0 +1,120 @@ +ref(AddressPayload::class, $registry); + $second = $factory->ref(AddressPayload::class, $registry); + + expect($first)->toBe('#/components/schemas/AddressPayload') + ->and($second)->toBe($first) + ->and(array_keys($registry->all()))->toBe(['AddressPayload']); +}); + +it('terminates on a self-referential DTO by referring back to the component being built', function () { + $registry = new SchemaRegistry; + + $ref = FixtureDocument::schemas()->ref(SelfReferential::class, $registry); + /** @var array> $schemas */ + $schemas = $registry->all(); + /** @var array> $properties */ + $properties = $schemas['SelfReferential']['properties']; + + expect($ref)->toBe('#/components/schemas/SelfReferential') + ->and(array_keys($schemas))->toBe(['SelfReferential']) + // A nullable nested DTO is a union, because a $ref cannot be widened by a sibling `type` in 2020-12. + ->and($properties['parent'])->toBe([ + 'anyOf' => [['$ref' => '#/components/schemas/SelfReferential'], ['type' => 'null']], + ]) + ->and($schemas['SelfReferential']['required'])->toBe(['label']); +}); + +it('gives the second claimant of a short name its dotted fully-qualified name', function () { + $registry = new SchemaRegistry; + $factory = FixtureDocument::schemas(); + + $first = $factory->ref(BillingAddress::class, $registry); + $second = $factory->ref(ShippingAddress::class, $registry); + + expect($first)->toBe('#/components/schemas/Address') + ->and($second)->toBe('#/components/schemas/'.str_replace('\\', '.', ShippingAddress::class)) + // Neither schema may be lost to the collision: the whole point is that both survive. + ->and($registry->all())->toHaveCount(2); +}); + +it('lists constructor parameters in declaration order', function () { + $registry = new SchemaRegistry; + FixtureDocument::schemas()->ref(CreateOrderRequest::class, $registry); + + /** @var array> $schemas */ + $schemas = $registry->all(); + /** @var array $properties */ + $properties = $schemas['CreateOrderRequest']['properties']; + + expect(array_keys($properties))->toBe([ + 'reference', 'email', 'quantity', 'amount', 'currency', 'shipTo', 'coupon', 'idempotencyKey', + ]); +}); + +it('documents a member the constructor does not take but the validator still enforces', function () { + // ArgumentResolver hydrates only constructor parameters, but BeanValidator validates the RAW decoded + // body, so a rule keyed to a plain (non-promoted) property is part of the request contract even though it + // is never assigned. Dropping it would under-document what the server rejects. + $registry = new SchemaRegistry; + FixtureDocument::schemas()->ref(LegacyPayload::class, $registry); + + /** @var array> $schemas */ + $schemas = $registry->all(); + /** @var array> $properties */ + $properties = $schemas['LegacyPayload']['properties']; + + expect(array_keys($properties))->toBe(['name', 'legacyContact']) + ->and($properties['legacyContact'])->toMatchArray(['format' => 'email']) + ->and($schemas['LegacyPayload']['required'])->toBe(['name', 'legacyContact']); +}); + +it('sorts components by name so a regenerated document diffs cleanly', function () { + $registry = new SchemaRegistry; + $factory = FixtureDocument::schemas(); + + $factory->ref(SelfReferential::class, $registry); + $factory->ref(AddressPayload::class, $registry); + + expect(array_keys($registry->all()))->toBe(['AddressPayload', 'SelfReferential']); +}); + +it('publishes only the defaults a client could actually send back', function () { + $registry = new SchemaRegistry; + FixtureDocument::schemas()->ref(DefaultsPayload::class, $registry); + + /** @var array> $schemas */ + $schemas = $registry->all(); + /** @var array> $properties */ + $properties = $schemas['DefaultsPayload']['properties']; + + // A scalar default is a promise the client can rely on: omit the member and the server applies this. + expect($properties['label']['default'])->toBe('draft') + ->and($properties['retries']['default'])->toBe(3) + // An enum case and an object have no JSON literal a client could send back, so emitting an + // approximation ("EUR", {}) would state a `default` the server never actually applies. Both are + // dropped instead — the member is still documented, just without the false promise. + ->and($properties['currency'])->not->toHaveKey('default') + ->and($properties['address'])->not->toHaveKey('default'); + + // Every member has a default, so none of them can be omitted-and-fail: `required` is absent entirely + // rather than emitted empty (an empty `required` is invalid against the 3.1 meta-schema). + expect($schemas['DefaultsPayload'])->not->toHaveKey('required'); +}); diff --git a/packages/openapi/tests/Schema/ProblemSchemaTest.php b/packages/openapi/tests/Schema/ProblemSchemaTest.php new file mode 100644 index 0000000..997928e --- /dev/null +++ b/packages/openapi/tests/Schema/ProblemSchemaTest.php @@ -0,0 +1,82 @@ +toArray(); + + /** @var list $required */ + $required = ProblemSchema::schema()['required']; + + expect(array_keys($payload))->toBe($required); +}); + +it('describes every optional member ErrorResponse can add', function () { + $payload = new ErrorResponse( + status: 422, + title: 'Unprocessable Entity', + code: 'VALIDATION_FAILED', + category: ErrorCategory::Validation, + severity: ErrorSeverity::Warning, + detail: 'The reference must not be blank.', + type: 'https://example.test/problems/validation', + instance: 'api/orders', + traceId: 'abc123', + errors: [new FieldError('reference', 'must not be blank', 'NotBlank', '')], + timestamp: '2026-09-03T00:00:00+00:00', + )->toArray(); + + /** @var array $properties */ + $properties = ProblemSchema::schema()['properties']; + + $undocumented = array_values(array_diff(array_keys($payload), array_keys($properties))); + + expect($undocumented)->toBe([], 'ErrorResponse emits members the problem schema does not describe'); +}); + +it('mirrors FieldError::toArray() in the errors item schema', function () { + $field = new FieldError('reference', 'must not be blank', 'NotBlank', 'x')->toArray(); + + /** @var array> $properties */ + $properties = ProblemSchema::schema()['properties']; + /** @var array $items */ + $items = $properties['errors']['items']; + /** @var array $itemProperties */ + $itemProperties = $items['properties']; + + expect(array_keys($itemProperties))->toBe(array_keys($field)) + ->and($items['required'])->toBe(['field', 'message']); +}); + +it('enumerates the category and severity cases straight off the kernel enums', function () { + /** @var array> $properties */ + $properties = ProblemSchema::schema()['properties']; + + expect($properties['category']['enum'])->toBe(array_map(static fn (ErrorCategory $c): string => $c->value, ErrorCategory::cases())) + ->and($properties['severity']['enum'])->toBe(array_map(static fn (ErrorSeverity $s): string => $s->value, ErrorSeverity::cases())); +}); + +it('serves the shared response as problem+json pointing at the shared schema', function () { + expect(ProblemSchema::response()['content'])->toBe([ + 'application/problem+json' => ['schema' => ['$ref' => '#/components/schemas/ProblemDetails']], + ]) + ->and(ProblemSchema::REF)->toBe('#/components/schemas/ProblemDetails') + ->and(ProblemSchema::RESPONSE_REF)->toBe('#/components/responses/Problem'); +}); diff --git a/packages/openapi/tests/Support/CustomPathCapstoneTestCase.php b/packages/openapi/tests/Support/CustomPathCapstoneTestCase.php new file mode 100644 index 0000000..848e380 --- /dev/null +++ b/packages/openapi/tests/Support/CustomPathCapstoneTestCase.php @@ -0,0 +1,22 @@ + '/docs/api.json', + 'firefly.openapi.viewer.path' => '/docs', + ]; + } +} diff --git a/packages/openapi/tests/Support/FixtureDocument.php b/packages/openapi/tests/Support/FixtureDocument.php new file mode 100644 index 0000000..e7ad11d --- /dev/null +++ b/packages/openapi/tests/Support/FixtureDocument.php @@ -0,0 +1,142 @@ + + */ + public static function psr4(): array + { + return ['Firefly\\OpenApi\\Tests\\Fixture\\' => dirname(__DIR__).'/Fixture']; + } + + public static function routes(): RouteManifest + { + return new RouteManifest((new RouteScanner)->scan(self::psr4())); + } + + public static function constraints(): ConstraintManifest + { + return ConstraintManifest::fromArray((new ConstraintManifestCompiler)->toArray(AppScan::classes(self::psr4()))); + } + + public static function schemas(): DtoSchemaFactory + { + return new DtoSchemaFactory(self::constraints(), new ConstraintSchemaMapper); + } + + public static function generator(?OpenApiProperties $properties = null): OpenApiGenerator + { + return new OpenApiGenerator( + self::routes(), + $properties ?? self::properties(), + new OperationFactory(self::schemas()), + ); + } + + /** + * @param list $servers + * @param list $exclude + */ + public static function properties(array $servers = [], array $exclude = [], bool $includeHtml = false): OpenApiProperties + { + return new OpenApiProperties( + enabled: true, + specPath: 'openapi.json', + viewerEnabled: true, + viewerPath: 'openapi', + viewerCdn: false, + title: 'Orders API', + version: '1.2.3', + description: 'The fixture API.', + servers: $servers, + excludePathPrefixes: $exclude, + includeHtml: $includeHtml, + ); + } + + /** + * Walks the whole document collecting every local `$ref` pointer, so a test can prove each one RESOLVES + * — the single most valuable structural check available, because a dangling pointer is exactly the flaw + * that makes a client generator abort and is exactly the flaw a snapshot test cannot see. + * + * @return list + */ + public static function refs(mixed $document): array + { + if (! is_array($document)) { + return []; + } + + $refs = []; + foreach ($document as $key => $value) { + if ($key === '$ref' && is_string($value)) { + $refs[] = $value; + + continue; + } + + foreach (self::refs($value) as $nested) { + $refs[] = $nested; + } + } + + return $refs; + } + + /** + * Resolves one local JSON Pointer against the document, or null when it dangles. + * + * @param array $document + * @return array|null + */ + public static function resolve(array $document, string $ref): ?array + { + if (! str_starts_with($ref, '#/')) { + return null; + } + + $node = $document; + foreach (explode('/', substr($ref, 2)) as $segment) { + $segment = str_replace(['~1', '~0'], ['/', '~'], $segment); + if (! array_key_exists($segment, $node)) { + return null; + } + + $next = $node[$segment]; + if (! is_array($next)) { + return null; + } + + /** @var array $next */ + $node = $next; + } + + return $node; + } +} diff --git a/packages/openapi/tests/Support/OpenApiCapstoneTestCase.php b/packages/openapi/tests/Support/OpenApiCapstoneTestCase.php new file mode 100644 index 0000000..41aec3c --- /dev/null +++ b/packages/openapi/tests/Support/OpenApiCapstoneTestCase.php @@ -0,0 +1,58 @@ +set()` calls for the reason + * ActuatorCapstoneTestCase documents at length: OpenApiProperties is a singleton #[Bean] resolved at + * FlushDefinitions (650), and the routes are mounted from it at WiringPasses (1000). Both happen once, at + * boot, so changing the config inside a test body can never move a route that is already mounted — a + * different gate means a different boot, which means a different test case class. + */ +abstract class OpenApiCapstoneTestCase extends FireflyTestCase +{ + protected function fireflyProviders(): array + { + return [ + ValidationServiceProvider::class, + WebServiceProvider::class, + OpenApiServiceProvider::class, + OpenApiWiringProvider::class, + ]; + } + + protected function configOverrides(): array + { + return [ + 'firefly.scan.paths' => FixtureDocument::psr4(), + 'firefly.openapi.enabled' => $this->openApiEnabled(), + 'firefly.openapi.viewer.enabled' => $this->viewerEnabled(), + 'firefly.openapi.title' => 'Orders API', + 'firefly.openapi.version' => '1.2.3', + ]; + } + + protected function openApiEnabled(): bool + { + return true; + } + + protected function viewerEnabled(): bool + { + return true; + } +} diff --git a/packages/openapi/tests/Support/OpenApiDisabledCapstoneTestCase.php b/packages/openapi/tests/Support/OpenApiDisabledCapstoneTestCase.php new file mode 100644 index 0000000..4a10b1a --- /dev/null +++ b/packages/openapi/tests/Support/OpenApiDisabledCapstoneTestCase.php @@ -0,0 +1,17 @@ +set(), because `firefly.openapi.enabled` is read by + * OpenApiRouteRegistrar at BOOT time — see the parent's docblock. + */ +abstract class OpenApiDisabledCapstoneTestCase extends OpenApiCapstoneTestCase +{ + protected function openApiEnabled(): bool + { + return false; + } +} diff --git a/packages/openapi/tests/Support/ViewerDisabledCapstoneTestCase.php b/packages/openapi/tests/Support/ViewerDisabledCapstoneTestCase.php new file mode 100644 index 0000000..84c4557 --- /dev/null +++ b/packages/openapi/tests/Support/ViewerDisabledCapstoneTestCase.php @@ -0,0 +1,17 @@ +get('/docs/api.json')->assertStatus(200); + $this->get('/docs')->assertStatus(200); + + $this->getJson('/openapi.json')->assertStatus(404); + $this->getJson('/openapi')->assertStatus(404); +}); + +it('points the relocated viewer at the relocated spec', function () { + /** @var CustomPathCapstoneTestCase $this */ + expect($this->responseBody($this->get('/docs')))->toContain('/docs/api.json'); +}); diff --git a/packages/openapi/tests/Web/CapstoneOpenApiDisabledTest.php b/packages/openapi/tests/Web/CapstoneOpenApiDisabledTest.php new file mode 100644 index 0000000..ad83b23 --- /dev/null +++ b/packages/openapi/tests/Web/CapstoneOpenApiDisabledTest.php @@ -0,0 +1,33 @@ +getJson('/openapi.json')->assertStatus(404); + $this->getJson('/openapi')->assertStatus(404); +}); + +it('leaves the application routes untouched', function () { + /** @var OpenApiDisabledCapstoneTestCase $this */ + $this->getJson('/api/orders/abc')->assertStatus(200); +}); + +it('still generates on demand with the master gate off', function () { + /** @var OpenApiDisabledCapstoneTestCase $this */ + // The gate turns off the HTTP SURFACE, not the generator: a deployment that keeps the spec off its + // public routes still has to be able to produce the document as a build artifact. That is why the gate + // lives in OpenApiRouteRegistrar and not on the beans. + expect($this->app()->make(OpenApiGenerator::class)->generate()['paths'])->toHaveKey('/api/orders') + ->and(Artisan::call('firefly:openapi'))->toBe(0) + ->and(trim(Artisan::output()))->toStartWith('{'); +}); diff --git a/packages/openapi/tests/Web/CapstoneOpenApiHttpTest.php b/packages/openapi/tests/Web/CapstoneOpenApiHttpTest.php new file mode 100644 index 0000000..bd41f62 --- /dev/null +++ b/packages/openapi/tests/Web/CapstoneOpenApiHttpTest.php @@ -0,0 +1,76 @@ +get('/openapi.json'); + + $response->assertStatus(200); + expect($response->headers->get('Content-Type'))->toBe('application/json'); + + /** @var array $document */ + $document = json_decode($this->responseBody($response), true, flags: JSON_THROW_ON_ERROR); + + expect($document['openapi'])->toBe('3.1.0') + ->and($document['info'])->toMatchArray(['title' => 'Orders API', 'version' => '1.2.3']) + // The manifests were populated by AppScan running the real scanners over firefly.scan.paths — the + // uncached development path — so this proves the package works without `firefly:cache` having run. + ->and($document['paths'])->toHaveKeys(['/api/orders', '/api/orders/{id}']); +}); + +it('serves a document whose every $ref resolves inside itself', function () { + /** @var OpenApiCapstoneTestCase $this */ + /** @var array $document */ + $document = json_decode($this->responseBody($this->get('/openapi.json')), true, flags: JSON_THROW_ON_ERROR); + + $refs = array_unique(FixtureDocument::refs($document)); + + expect($refs)->not->toBeEmpty(); + foreach ($refs as $ref) { + expect(FixtureDocument::resolve($document, $ref))->not->toBeNull("dangling \$ref {$ref}"); + } +}); + +it('serves the viewer as HTML that points back at the spec route', function () { + /** @var OpenApiCapstoneTestCase $this */ + $response = $this->get('/openapi'); + + $response->assertStatus(200); + expect($response->headers->get('Content-Type'))->toBe('text/html; charset=UTF-8'); + + $html = $this->responseBody($response); + + expect($html)->toStartWith('') + ->and($html)->toContain('/openapi.json') + ->and($html)->toContain('Orders API'); +}); + +it('does not document its own two routes', function () { + /** @var OpenApiCapstoneTestCase $this */ + /** @var array $document */ + $document = json_decode($this->responseBody($this->get('/openapi.json')), true, flags: JSON_THROW_ON_ERROR); + + /** @var array $paths */ + $paths = $document['paths']; + + // Both routes are mounted natively on the Router from a BootPass, so they never enter the RouteManifest + // the generator reads. That is a consequence of the configurable-path design, not an extra filter — and + // it is the reason this package ships no #[RestController] of its own. + expect($paths)->not->toHaveKey('/openapi.json') + ->and($paths)->not->toHaveKey('/openapi'); +}); + +it('still dispatches the application routes it documents', function () { + /** @var OpenApiCapstoneTestCase $this */ + // Mounting the framework's own two routes must not disturb M6's route wiring for the app's controllers. + $this->getJson('/api/orders/abc?expand=1') + ->assertStatus(200) + ->assertJsonPath('id', 'abc') + ->assertJsonPath('expand', true); +}); diff --git a/packages/openapi/tests/Web/CapstoneViewerDisabledTest.php b/packages/openapi/tests/Web/CapstoneViewerDisabledTest.php new file mode 100644 index 0000000..2dc7694 --- /dev/null +++ b/packages/openapi/tests/Web/CapstoneViewerDisabledTest.php @@ -0,0 +1,13 @@ +get('/openapi.json')->assertStatus(200); + $this->getJson('/openapi')->assertStatus(404); +}); diff --git a/packages/openapi/tests/Web/ViewerPageTest.php b/packages/openapi/tests/Web/ViewerPageTest.php new file mode 100644 index 0000000..55036d0 --- /dev/null +++ b/packages/openapi/tests/Web/ViewerPageTest.php @@ -0,0 +1,54 @@ +render('/openapi.json', cdn: false); + + expect($html)->toStartWith('') + ->and($html)->toContain('Orders API') + // The ONE network call the default viewer makes is to the spec route it was handed. Any other + // absolute URL in this page would be an undeclared third-party dependency at request time. + ->and($html)->not->toContain('https://') + ->and($html)->not->toContain('http://') + ->and($html)->not->toContain('//cdn.') + ->and(substr_count($html, 'toBe(1); +}); + +it('resolves $ref pointers client-side so a reader sees members, not pointers', function () { + $html = new ViewerPage('Orders API')->render('/openapi.json', cdn: false); + + expect($html)->toContain('function deref') + // The JSON Pointer walk itself: a local "#/a/b" pointer split and followed into the loaded document. + ->and($html)->toContain("ref.slice(2).split('/')") + ->and($html)->toContain('schema.$ref'); +}); + +it('only reaches a CDN when the opt-in flag is explicitly turned on', function () { + $off = new ViewerPage('Orders API')->render('/openapi.json', cdn: false); + $on = new ViewerPage('Orders API')->render('/openapi.json', cdn: true); + + expect($off)->not->toContain('swagger-ui') + ->and($on)->toContain('swagger-ui-bundle.js') + // Pinned by exact version: an unpinned CDN reference is a remote-code-execution channel that + // updates itself. + ->and($on)->toMatch('#swagger-ui-dist@\d+\.\d+\.\d+/#'); +}); + +it('escapes the configured spec path into the inline script', function () { + // The path comes from application config, not from a request, so this is defence in depth — but a page + // that renders a config value into inline script has no business relying on that distinction. + $html = new ViewerPage('Orders API')->render('/openapi.json"', cdn: false); + + expect($html)->not->toContain('') + ->and(substr_count($html, 'toBe(1); +}); + +it('escapes the document title into the page markup', function () { + $html = new ViewerPage('')->render('/openapi.json', cdn: false); + + expect($html)->not->toContain('and($html)->toContain('<img src=x'); +}); diff --git a/packages/web/src/Dispatch/ArgumentResolver.php b/packages/web/src/Dispatch/ArgumentResolver.php index 924731c..40ee8ee 100644 --- a/packages/web/src/Dispatch/ArgumentResolver.php +++ b/packages/web/src/Dispatch/ArgumentResolver.php @@ -4,11 +4,11 @@ namespace Firefly\Web\Dispatch; +use Error; use Firefly\Validation\Constraint\BeanValidator; use Firefly\Web\Exception\InvalidRequestException; use Firefly\Web\Http\MessageConverterRegistry; use Firefly\Web\Http\UploadedFile; -use Firefly\Web\Route\RouteDescriptor; use Illuminate\Container\Container; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile as IlluminateUploadedFile; @@ -16,10 +16,68 @@ /** * Binds a controller method's arguments from the Illuminate Request + route params using a descriptor's * pure-array binding plan. A #[Valid] body is validated via BeanValidator BEFORE the DTO is hydrated - * (reflection-free, via named-argument unpacking of the compiled `properties` list). Missing required - * inputs and uncoercible scalars raise InvalidRequestException (400). + * (reflection-free, via named-argument unpacking of the compiled property plan). Missing required inputs and + * uncoercible values raise InvalidRequestException (400). * - * @phpstan-import-type Binding from RouteDescriptor + * THE DEFECT THIS CLASS WAS REWRITTEN FOR — NESTED DTOs. Hydration used to be one line, + * `new $type(...$named)`, fed from a flat list of the constructor's parameter NAMES. That is exactly right + * while every parameter is a scalar and catastrophically wrong the moment a request DTO contains another + * one. The #[Valid] cascade already handled the nested case properly (ConstraintScanner compiles + * `beneficiary.postcode` as a dot-key and validates it), so the payload passed validation, arrived at the + * constructor as a raw sub-ARRAY where an AddressPayload was declared, and died with a TypeError — rendered + * to the client as a 500 whose `detail` read "...must be of type ...AddressPayload, array given, called in + * /Users//.../src/Dispatch/ArgumentResolver.php on line 144". A perfectly valid request produced a + * server error, and the server error quoted an absolute filesystem path back over the wire. + * + * WHY THE FIX IS A PLAN AND NOT A REFLECTION CALL. Building `new AddressPayload(...)` needs to know that + * $beneficiary is an AddressPayload, and PHP has exactly one way to read a parameter's type: reflection. + * Reflecting HERE would put reflection on the per-request hot path and break the invariant + * packages/web/tests/ReflectionFreeWebTest.php guards — RouteScanner is the one sanctioned reflection site + * in this package, and it runs at cache time only. So the type information is COMPILED instead, into an + * optional `dtos` key on the body binding: a table keyed by class, each row mapping a constructor parameter + * to the class it is built from (null for a builtin) and whether the payload holds a LIST of that class. + * + * Keying by CLASS rather than nesting the plan inline is what makes arbitrary depth work. An inline tree has + * to stop somewhere — ConstraintScanner's #[Valid] cascade stops after one level, guarded by an ancestor set, + * because a self-referential DTO would otherwise expand forever. A class-keyed table has one row per class no + * matter how the graph is shaped, so a DTO that points at itself is a single row and the descent is bounded + * only by the depth of the payload the client actually sent (itself bounded by json_decode's depth limit). + * + * WHAT THE RESOLVER DOES WHEN THE PLAN CANNOT SAY. `dtos` is optional, and a binding compiled before the + * scanner learned to emit it still carries only the flat name list — as does a plan for a type the scanner + * could not describe (an interface, a union, an `array` with no element type in its docblock). In every one + * of those cases the value reaches the constructor untouched and the constructor rejects it. That rejection + * is caught and re-thrown as the framework's own 400: a request the framework cannot bind is a bad request, + * and answering it with a 500 misattributes the fault and leaks internals while doing so. + * + * That catch is Error, not TypeError, and the width is deliberate rather than lazy. TypeError alone covers + * the headline case (a sub-array against a class-typed parameter) and ArgumentCountError, but three sibling + * failures raise a plain Error and would have kept right on 500ing: "Cannot instantiate enum" (class_exists() + * answers TRUE for an enum, so an enum-typed property reaches `new` like any other class), "Cannot instantiate + * abstract class", and "Unknown named parameter" from a plan that has drifted from the constructor it + * describes. Every one of those is a request the framework cannot bind, which is the definition of the 400 it + * now gets. The price is that a genuine fault raised from INSIDE a DTO constructor's body is relabelled as a + * client error — accepted because the lexical scope of the try is a single `new`, LaraFly DTOs are promoted + * properties with no body, and the original throwable is attached as `previous` so the log still carries the + * whole trace. + * + * None of these messages quote the caught exception. A PHP TypeError names the declaring file and the + * calling file, so echoing it is how the path leak above happened; the client is told the dotted property + * path it sent instead, matching the dot-keys #[Valid] already reports field errors under, and the original + * throwable rides along as `previous` for the logs. + * + * #[RequestHeader] and #[UploadedFile] were the same defect wearing different clothes: neither honoured the + * plan's `required` flag and neither ran the coercion every other binding runs, so a missing required header + * or a missing required upload reached the handler as null and blew up in the handler's own signature — a + * 500, again, for what is plainly a malformed request. Both now go through the same required/coerce path as + * a path or query binding. + * + * Error codes raised here: MISSING_PARAMETER, TYPE_CONVERSION_ERROR, MALFORMED_BODY, INVALID_REQUEST, + * INVALID_UPLOAD and UNBINDABLE_BODY — all 400, all category Validation (see InvalidRequestException). + * + * @phpstan-type PropertyPlan array{class: string|null, list: bool} + * @phpstan-type DtoShape array + * @phpstan-type BodyBinding array{name: string, kind: string, key: string, type: string|null, required: bool, default: mixed, valid: bool, properties: list, dtos?: array} */ final class ArgumentResolver { @@ -29,7 +87,12 @@ public function __construct( ) {} /** - * @param list $bindings + * BodyBinding is RouteDescriptor's Binding plus the optional `dtos` shape table. It is spelled out here + * rather than imported-and-extended because PHPStan has no syntax for widening an imported array shape; + * the two stay honest because ControllerDispatcher passes a list straight into this parameter, + * so any drift in the descriptor's shape fails at that call site. + * + * @param list $bindings * @return list */ public function resolve(array $bindings, Request $request, Container $container): array @@ -43,14 +106,14 @@ public function resolve(array $bindings, Request $request, Container $container) } /** - * @param Binding $binding + * @param BodyBinding $binding */ private function resolveOne(array $binding, Request $request, Container $container): mixed { return match ($binding['kind']) { 'path' => $this->coerce($this->pathValue($binding, $request), $binding), 'query' => $this->coerce($this->queryValue($binding, $request), $binding), - 'header' => $request->header($binding['key']) ?? $binding['default'], + 'header' => $this->coerce($this->headerValue($binding, $request), $binding), 'file' => $this->fileValue($binding, $request), 'body' => $this->bodyValue($binding, $request), 'service' => $container->make($binding['type'] ?? ''), @@ -59,7 +122,7 @@ private function resolveOne(array $binding, Request $request, Container $contain } /** - * @param Binding $binding + * @param BodyBinding $binding */ private function pathValue(array $binding, Request $request): mixed { @@ -72,7 +135,7 @@ private function pathValue(array $binding, Request $request): mixed } /** - * @param Binding $binding + * @param BodyBinding $binding */ private function queryValue(array $binding, Request $request): mixed { @@ -88,17 +151,68 @@ private function queryValue(array $binding, Request $request): mixed } /** - * @param Binding $binding + * A header is a request parameter like any other: absent + required is a 400, absent + optional falls + * back to the attribute's default, and whatever is found is coerced to the handler parameter's type. It + * previously did none of that — `header() ?? default` handed a raw string (or a null) straight to the + * handler, so `#[RequestHeader] int $version` failed in the handler's signature rather than here. + * + * @param BodyBinding $binding + */ + private function headerValue(array $binding, Request $request): mixed + { + $value = $request->header($binding['key']); + if ($value === null) { + if ($binding['required']) { + throw new InvalidRequestException("Missing request header {$binding['key']}.", 'MISSING_PARAMETER'); + } + + return $binding['default']; + } + + return $value; + } + + /** + * Uploads have three client-caused failure modes and all three used to end at the handler's signature or + * deep inside UploadedFile::fromIlluminate() as a 500: the field was not sent at all, the field was sent + * as a multi-file array against a single-file parameter, and the upload did not complete (an oversized + * body, an interrupted POST). The last one matters most because it is the one a well-behaved client hits + * by accident: getRealPath() on a failed upload is false, which fromIlluminate() reports as an + * InfrastructureException — a 500 for a file that was simply too big. + * + * @param BodyBinding $binding */ private function fileValue(array $binding, Request $request): ?UploadedFile { $file = $request->file($binding['key']); - return $file instanceof IlluminateUploadedFile ? UploadedFile::fromIlluminate($file) : null; + if (is_array($file)) { + throw new InvalidRequestException( + "Expected a single uploaded file for {$binding['key']}.", + 'TYPE_CONVERSION_ERROR', + ); + } + + if (! $file instanceof IlluminateUploadedFile) { + if ($binding['required']) { + throw new InvalidRequestException("Missing uploaded file {$binding['key']}.", 'MISSING_PARAMETER'); + } + + return null; + } + + if (! $file->isValid()) { + throw new InvalidRequestException( + "The upload for {$binding['key']} did not complete.", + 'INVALID_UPLOAD', + ); + } + + return UploadedFile::fromIlluminate($file); } /** - * @param Binding $binding + * @param BodyBinding $binding */ private function bodyValue(array $binding, Request $request): mixed { @@ -125,7 +239,8 @@ private function bodyValue(array $binding, Request $request): mixed if ($binding['valid'] && $binding['type'] !== null) { // Validation is the GATE ONLY: it throws on failure, and its validated() subset (fields that // carried a rule) is DISCARDED. The DTO is hydrated from the RAW body below so an unconstrained - // property is not silently dropped to its constructor default. + // property is not silently dropped to its constructor default. It runs BEFORE hydration so a + // 422 with per-field errors always beats the 400 a structurally-unbindable body would raise. $this->beanValidator->validate($data, $binding['type']); } @@ -134,18 +249,128 @@ private function bodyValue(array $binding, Request $request): mixed return $data; } + return $this->hydrate($type, $data, $binding['dtos'] ?? [], $binding['properties'], ''); + } + + /** + * Builds one DTO by named-argument unpacking. The compiled shape row, when there is one, is the + * authority on BOTH which parameters exist and what each is built from; the flat name list is the + * fallback for a plan compiled before shapes existed, and it can only pass values through untouched. + * + * @param class-string $class + * @param array $data + * @param array $shapes + * @param list|null $fallbackProperties null for a nested DTO, whose only source is $shapes + */ + private function hydrate(string $class, array $data, array $shapes, ?array $fallbackProperties, string $path): object + { + $shape = $shapes[$class] ?? null; + if ($shape === null && $fallbackProperties === null) { + // A nested class the shape table does not describe. Guessing the constructor from the payload's + // own keys would build a different object from the one the developer declared, so the request is + // refused rather than half-bound. + throw $this->unbindable($path); + } + $named = []; - foreach ($binding['properties'] as $property) { - if (array_key_exists($property, $data)) { - $named[$property] = $data[$property]; + if ($shape !== null) { + foreach ($shape as $property => $plan) { + if (array_key_exists($property, $data)) { + $named[$property] = $this->hydrateProperty($data[$property], $plan, $shapes, $this->join($path, $property)); + } + } + } else { + foreach ($fallbackProperties as $property) { + if (array_key_exists($property, $data)) { + $named[$property] = $data[$property]; + } } } - return new $type(...$named); + try { + return new $class(...$named); + } catch (Error $e) { + throw $this->unbindable($path, $e); + } + } + + /** + * @param PropertyPlan $plan + * @param array $shapes + */ + private function hydrateProperty(mixed $value, array $plan, array $shapes, string $path): mixed + { + $class = $plan['class']; + + // A builtin-typed property, or an explicit null on a nullable one, is handed over untouched: the + // constructor's own declared type is the arbiter, and hydrate()'s catch turns its refusal into a 400. + if ($class === null || $value === null) { + return $value; + } + + if (! class_exists($class)) { + // An interface, an enum, a union the scanner reduced to a name, a class that no longer exists. + throw $this->unbindable($path); + } + + if (! $plan['list']) { + return $this->hydrateElement($value, $class, $shapes, $path); + } + + if (! is_array($value)) { + throw $this->unbindable($path); + } + + $items = []; + /** @var mixed $element */ + foreach ($value as $key => $element) { + $items[] = $this->hydrateElement($element, $class, $shapes, $path.'['.$key.']'); + } + + return $items; + } + + /** + * @param class-string $class + * @param array $shapes + */ + private function hydrateElement(mixed $value, string $class, array $shapes, string $path): object + { + if (! is_array($value)) { + throw $this->unbindable($path); + } + + /** @var array $value */ + return $this->hydrate($class, $value, $shapes, null, $path); + } + + /** + * The client hears the dotted path it sent and nothing else — never the caught throwable, whose message + * carries the declaring and calling FILE PATHS of the failure. The original rides along as `previous` so + * the log keeps the full story. + */ + private function unbindable(string $path, ?\Throwable $previous = null): InvalidRequestException + { + return new InvalidRequestException( + $path === '' + ? 'Could not bind the request body.' + : "Could not bind the request body at {$path}.", + 'UNBINDABLE_BODY', + $previous, + ); + } + + /** + * Dot-joins a property onto its parent path, matching the dot-keys ConstraintScanner compiles a #[Valid] + * cascade under — so a 400 from hydration names a field the same way a 422 from validation does. + */ + private function join(string $parent, string $property): string + { + return $parent === '' ? $property : $parent.'.'.$property; } /** - * @param Binding $binding + * @param BodyBinding $binding */ private function coerce(mixed $value, array $binding): mixed { @@ -162,7 +387,7 @@ private function coerce(mixed $value, array $binding): mixed } /** - * @param Binding $binding + * @param BodyBinding $binding */ private function toInt(mixed $value, array $binding): int { @@ -172,7 +397,7 @@ private function toInt(mixed $value, array $binding): int } /** - * @param Binding $binding + * @param BodyBinding $binding */ private function toFloat(mixed $value, array $binding): float { @@ -182,7 +407,7 @@ private function toFloat(mixed $value, array $binding): float } /** - * @param Binding $binding + * @param BodyBinding $binding * @return never */ private function conversionError(array $binding): mixed diff --git a/packages/web/src/Route/RouteDescriptor.php b/packages/web/src/Route/RouteDescriptor.php index d081a2e..225d7db 100644 --- a/packages/web/src/Route/RouteDescriptor.php +++ b/packages/web/src/Route/RouteDescriptor.php @@ -6,9 +6,24 @@ /** * A single compiled route: the HTTP method, full path, controller/method, default status, optional Laravel - * route name, and a pure-array binding plan (no closures/objects) so it var_exports cleanly. + * route name, whether the declaring class is the HTML stereotype, and a pure-array binding plan (no + * closures/objects) so it var_exports cleanly. * - * @phpstan-type Binding array{name: string, kind: string, key: string, type: string|null, required: bool, default: mixed, valid: bool, properties: list} + * `html` records that the route was declared by a #[Controller] rather than a #[RestController]. The + * stereotype is an ATTRIBUTE, so answering the question needs reflection — which is why it is answered once + * at scan time and compiled, rather than asked again by anything downstream. firefly/openapi is the first + * consumer: an HTML page is part of the application's HTTP surface but it is not a JSON API operation, and + * documenting it as `application/json` would generate a typed client for a response that is a web page. + * Defaults to false so a manifest compiled before this field existed still loads. + * + * `dtos` is optional and present only on a body binding whose DTO actually nests: a table keyed by class, + * each row mapping a constructor parameter to the class it is built from (null for a builtin) and whether + * the payload holds a LIST of that class. RouteScanner compiles it; ArgumentResolver hydrates from it + * without reflection. Optional so a plan for a flat DTO — and a manifest compiled before the scanner emitted + * the key — stays exactly as it was. + * + * @phpstan-type PropertyPlan array{class: string|null, list: bool} + * @phpstan-type Binding array{name: string, kind: string, key: string, type: string|null, required: bool, default: mixed, valid: bool, properties: list, dtos?: array>} */ final readonly class RouteDescriptor { @@ -23,10 +38,11 @@ public function __construct( public int $status, public ?string $name, public array $bindings, + public bool $html = false, ) {} /** - * @return array{httpMethod: string, path: string, controllerClass: string, methodName: string, status: int, name: string|null, bindings: list} + * @return array{httpMethod: string, path: string, controllerClass: string, methodName: string, status: int, name: string|null, bindings: list, html: bool} */ public function toArray(): array { @@ -38,11 +54,12 @@ public function toArray(): array 'status' => $this->status, 'name' => $this->name, 'bindings' => $this->bindings, + 'html' => $this->html, ]; } /** - * @param array{httpMethod: string, path: string, controllerClass: string, methodName: string, status: int, name: string|null, bindings: list} $data + * @param array{httpMethod: string, path: string, controllerClass: string, methodName: string, status: int, name: string|null, bindings: list, html?: bool} $data */ public static function fromArray(array $data): self { @@ -54,6 +71,7 @@ public static function fromArray(array $data): self $data['status'], $data['name'], $data['bindings'], + $data['html'] ?? false, ); } } diff --git a/packages/web/src/Route/RouteScanner.php b/packages/web/src/Route/RouteScanner.php index a679e71..b2a72fa 100644 --- a/packages/web/src/Route/RouteScanner.php +++ b/packages/web/src/Route/RouteScanner.php @@ -5,6 +5,7 @@ namespace Firefly\Web\Route; use Firefly\Validation\Valid; +use Firefly\Web\Attributes\Controller; use Firefly\Web\Attributes\ControllerAdvice; use Firefly\Web\Attributes\ExceptionHandler; use Firefly\Web\Attributes\Mapping; @@ -44,6 +45,11 @@ public function scan(array $psr4): array $reflection = new ReflectionClass($class); $base = $this->basePath($reflection); + // #[Controller] is the HTML stereotype and extends #[RestController], so it is found by the same + // IS_INSTANCEOF scan; recording which one matched is the only way anything downstream can tell a + // web page from a JSON operation without reflecting again. + $html = $reflection->getAttributes(Controller::class, ReflectionAttribute::IS_INSTANCEOF) !== []; + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { foreach ($method->getAttributes(Mapping::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { $mapping = $attribute->newInstance(); @@ -55,6 +61,7 @@ public function scan(array $psr4): array status: $mapping->status(), name: $mapping->name(), bindings: $this->bindings($method), + html: $html, ); } } @@ -217,7 +224,7 @@ private function binding(ReflectionParameter $parameter): array } if (($attrs = $parameter->getAttributes(RequestBody::class)) !== []) { - return $this->plan($name, 'body', '', $type, true, null, $valid, $this->constructorProperties($type)); + return $this->plan($name, 'body', '', $type, true, null, $valid, $this->constructorProperties($type), $this->dtoShapes($type)); } if (($attrs = $parameter->getAttributes(RequestHeader::class)) !== []) { @@ -248,11 +255,12 @@ private function binding(ReflectionParameter $parameter): array /** * @param list $properties + * @param array> $dtos * @return Binding */ - private function plan(string $name, string $kind, string $key, ?string $type, bool $required, mixed $default, bool $valid, array $properties = []): array + private function plan(string $name, string $kind, string $key, ?string $type, bool $required, mixed $default, bool $valid, array $properties = [], array $dtos = []): array { - return [ + $binding = [ 'name' => $name, 'kind' => $kind, 'key' => $key, @@ -262,6 +270,15 @@ private function plan(string $name, string $kind, string $key, ?string $type, bo 'valid' => $valid, 'properties' => $properties, ]; + + // Emitted only when there is something to say, so a plan for a flat DTO is byte-identical to the one + // this scanner produced before nested hydration existed, and an already-compiled manifest without the + // key keeps working (ArgumentResolver reads `dtos` with a `?? []` default). + if ($dtos !== []) { + $binding['dtos'] = $dtos; + } + + return $binding; } private function typeName(ReflectionParameter $parameter): ?string @@ -288,4 +305,158 @@ private function constructorProperties(?string $type): array return $names; } + + /** + * The shape table ArgumentResolver hydrates a nested request body from: one row per class reachable from + * the body DTO, each row mapping a constructor parameter to the class it is built from (null for a + * builtin) and whether the payload holds a LIST of that class. + * + * Compiled here because this is the one sanctioned reflection site in the package — the resolver runs on + * the per-request hot path and must stay reflection-free (ReflectionFreeWebTest guards it). + * + * Keyed by CLASS rather than nested inline, so depth is unbounded: a DTO that points at itself is one row, + * and $seen stops the WALK from recursing forever without capping how deep a payload may nest. + * + * @param array $seen + * @return array> + */ + private function dtoShapes(?string $type, array &$seen = []): array + { + if ($type === null || ! class_exists($type) || isset($seen[$type])) { + return []; + } + + $reflection = new ReflectionClass($type); + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return []; + } + + $seen[$type] = true; + $docTypes = $this->docblockParamTypes($constructor->getDocComment() ?: '', $reflection); + + $shape = []; + $shapes = []; + foreach ($constructor->getParameters() as $parameter) { + $name = $parameter->getName(); + $parameterType = $parameter->getType(); + $named = $parameterType instanceof ReflectionNamedType ? $parameterType->getName() : null; + + // A class-typed parameter is a nested DTO; an `array` carries no element type in PHP, so its + // element class can only come from the docblock. + $nested = $named !== null && class_exists($named) ? $named : null; + $isList = false; + + if ($nested === null && $named === 'array' && isset($docTypes[$name])) { + $nested = $docTypes[$name]; + $isList = true; + } + + $shape[$name] = ['class' => $nested, 'list' => $isList]; + + if ($nested !== null) { + $shapes = [...$shapes, ...$this->dtoShapes($nested, $seen)]; + } + } + + return [$type => $shape, ...$shapes]; + } + + /** + * Element classes read out of a constructor docblock: `@param list $lines`, `@param Line[] $lines` + * and `@param array $lines` all mean the same thing to the hydrator. + * + * A docblock name may be written short, so it is resolved the way PHP would resolve it: an explicitly + * leading-slashed or already-qualified name as-is, then the declaring class's own namespace, then the + * file's `use` imports. Anything that does not resolve to a real class is left out of the table entirely, + * which lands the value on the resolver's documented "plan cannot say" path — a clean 400 rather than a + * guess. + * + * @param ReflectionClass $declaring + * @return array parameter name => element class + */ + private function docblockParamTypes(string $docComment, ReflectionClass $declaring): array + { + if ($docComment === '') { + return []; + } + + // Two patterns rather than one alternation: `list`/`array`/`iterable` and the + // `X[]` spelling. Kept separate so each match has a fixed shape. + $types = []; + + foreach ([ + '/@param\s+(?:list|array|iterable)<(?:[^,<>]+,\s*)?([^<>]+)>\s+\$(\w+)/', + '/@param\s+([\w\\\\]+)\[\]\s+\$(\w+)/', + ] as $pattern) { + if (preg_match_all($pattern, $docComment, $matches, PREG_SET_ORDER) === false) { + continue; + } + + foreach ($matches as $match) { + $resolved = $this->resolveClassName(trim($match[1]), $declaring); + if ($resolved !== null) { + $types[$match[2]] = $resolved; + } + } + } + + return $types; + } + + /** + * @param ReflectionClass $declaring + */ + private function resolveClassName(string $name, ReflectionClass $declaring): ?string + { + $name = ltrim($name, '\\'); + if (class_exists($name)) { + return $name; + } + + $namespace = $declaring->getNamespaceName(); + if ($namespace !== '' && class_exists($candidate = $namespace.'\\'.$name)) { + return $candidate; + } + + foreach ($this->imports($declaring) as $alias => $fqcn) { + if ($alias === $name && class_exists($fqcn)) { + return $fqcn; + } + } + + return null; + } + + /** + * The file's `use` imports, alias => FQCN. Read from the source because reflection does not expose them. + * + * @param ReflectionClass $declaring + * @return array + */ + private function imports(ReflectionClass $declaring): array + { + $file = $declaring->getFileName(); + if ($file === false || ! is_file($file)) { + return []; + } + + $source = (string) file_get_contents($file); + if (preg_match_all('/^use\s+([\w\\\\]+)(?:\s+as\s+(\w+))?\s*;/mi', $source, $matches, PREG_SET_ORDER) === false) { + return []; + } + + $imports = []; + foreach ($matches as $match) { + $fqcn = $match[1]; + $alias = $match[2] ?? ''; + if ($alias === '') { + $parts = explode('\\', $fqcn); + $alias = end($parts); + } + $imports[$alias] = $fqcn; + } + + return $imports; + } } diff --git a/packages/web/tests/Dispatch/ArgumentResolverTest.php b/packages/web/tests/Dispatch/ArgumentResolverTest.php index 7421b17..908d188 100644 --- a/packages/web/tests/Dispatch/ArgumentResolverTest.php +++ b/packages/web/tests/Dispatch/ArgumentResolverTest.php @@ -11,10 +11,20 @@ use Firefly\Web\Exception\InvalidRequestException; use Firefly\Web\Http\JsonMessageConverter; use Firefly\Web\Http\MessageConverterRegistry; +use Firefly\Web\Http\UploadedFile; +use Firefly\Web\Tests\Fixtures\AddressPayload; use Firefly\Web\Tests\Fixtures\CreateAccountRequest; +use Firefly\Web\Tests\Fixtures\Currency; +use Firefly\Web\Tests\Fixtures\GeoPoint; +use Firefly\Web\Tests\Fixtures\MoneyTransferRequest; +use Firefly\Web\Tests\Fixtures\NodeRequest; use Firefly\Web\Tests\Fixtures\PartialBodyRequest; +use Firefly\Web\Tests\Fixtures\PricedRequest; +use Firefly\Web\Tests\Fixtures\TransferLine; +use Firefly\Web\Tests\Fixtures\UnbindableRequest; use Illuminate\Container\Container; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile as IlluminateUploadedFile; use Illuminate\Routing\Route; use Illuminate\Translation\ArrayLoader; use Illuminate\Translation\Translator; @@ -188,3 +198,375 @@ function requestWithRoute(Request $request, string $uri, array $params): Request expect($e->httpStatus())->toBe(400)->and($e->errorCode())->toBe('INVALID_REQUEST'); } }); + +/** + * The compiled shape table for the MoneyTransferRequest graph — one row per reachable DTO class, each row + * mapping a constructor parameter to the class it is built from (null for a builtin) and whether the payload + * holds a LIST of that class. This is the `dtos` key RouteScanner must learn to emit; hand-built here so the + * resolver's half of the fix is provable on its own. + * + * @return array> + */ +function transferShapes(): array +{ + return [ + MoneyTransferRequest::class => [ + 'amount' => ['class' => null, 'list' => false], + 'beneficiary' => ['class' => AddressPayload::class, 'list' => false], + 'lines' => ['class' => TransferLine::class, 'list' => true], + 'reference' => ['class' => null, 'list' => false], + ], + AddressPayload::class => [ + 'street' => ['class' => null, 'list' => false], + 'postcode' => ['class' => null, 'list' => false], + 'geo' => ['class' => GeoPoint::class, 'list' => false], + ], + GeoPoint::class => [ + 'lat' => ['class' => null, 'list' => false], + 'lon' => ['class' => null, 'list' => false], + ], + TransferLine::class => [ + 'reference' => ['class' => null, 'list' => false], + 'cents' => ['class' => null, 'list' => false], + ], + ]; +} + +/** + * @param array $body + * @param array> $dtos + * @param list $properties + */ +function resolveBody(string $type, array $body, array $dtos = [], array $properties = [], bool $valid = false): mixed +{ + $request = Request::create('/x', 'POST', content: json_encode($body, JSON_THROW_ON_ERROR)); + $request->headers->set('Content-Type', 'application/json'); + + $binding = ['name' => 'body', 'kind' => 'body', 'key' => '', 'type' => $type, 'required' => true, 'default' => null, 'valid' => $valid, 'properties' => $properties]; + if ($dtos !== []) { + $binding['dtos'] = $dtos; + } + + /** @var class-string $type */ + return resolverFor($type)->resolve([$binding], $request, new Container)[0]; +} + +it('hydrates a nested DTO from its sub-array instead of passing the raw array to the constructor', function () { + $dto = resolveBody(MoneyTransferRequest::class, [ + 'amount' => 250, + 'beneficiary' => ['street' => 'Calle Mayor 1', 'postcode' => '28013'], + ], transferShapes()); + + expect($dto)->toBeInstanceOf(MoneyTransferRequest::class); + if (! $dto instanceof MoneyTransferRequest) { + throw new RuntimeException('Expected a MoneyTransferRequest.'); + } + expect($dto->beneficiary)->toBeInstanceOf(AddressPayload::class) + ->and($dto->beneficiary->postcode)->toBe('28013') + ->and($dto->amount)->toBe(250) + ->and($dto->lines)->toBe([]) + ->and($dto->reference)->toBeNull(); +}); + +it('recurses to arbitrary depth — the third level is hydrated as readily as the first', function () { + $dto = resolveBody(MoneyTransferRequest::class, [ + 'amount' => 1, + 'beneficiary' => [ + 'street' => 'Calle Mayor 1', + 'postcode' => '28013', + 'geo' => ['lat' => 40.415, 'lon' => -3.707], + ], + ], transferShapes()); + + if (! $dto instanceof MoneyTransferRequest) { + throw new RuntimeException('Expected a MoneyTransferRequest.'); + } + expect($dto->beneficiary->geo)->toBeInstanceOf(GeoPoint::class) + ->and($dto->beneficiary->geo?->lat)->toBe(40.415); +}); + +it('hydrates a LIST of DTOs, preserving order', function () { + $dto = resolveBody(MoneyTransferRequest::class, [ + 'amount' => 3, + 'beneficiary' => ['street' => 'A', 'postcode' => 'B'], + 'lines' => [ + ['reference' => 'INV-1', 'cents' => 100], + ['reference' => 'INV-2', 'cents' => 250], + ], + ], transferShapes()); + + if (! $dto instanceof MoneyTransferRequest) { + throw new RuntimeException('Expected a MoneyTransferRequest.'); + } + expect($dto->lines)->toHaveCount(2) + ->and($dto->lines[0])->toBeInstanceOf(TransferLine::class) + ->and($dto->lines[0]->reference)->toBe('INV-1') + ->and($dto->lines[1]->cents)->toBe(250); +}); + +it('leaves an explicit null on a nested property as null rather than building an empty DTO', function () { + $dto = resolveBody(MoneyTransferRequest::class, [ + 'amount' => 1, + 'beneficiary' => ['street' => 'A', 'postcode' => 'B', 'geo' => null], + ], transferShapes()); + + if (! $dto instanceof MoneyTransferRequest) { + throw new RuntimeException('Expected a MoneyTransferRequest.'); + } + expect($dto->beneficiary->geo)->toBeNull(); +}); + +it('descends a SELF-REFERENTIAL DTO as deep as the payload goes, unlike the one-level #[Valid] cascade', function () { + // ConstraintScanner stops expanding NodeRequest after one level (its ancestor guard); hydration is keyed + // by class, so the same single table row serves every level of the payload. + $shapes = [NodeRequest::class => [ + 'label' => ['class' => null, 'list' => false], + 'child' => ['class' => NodeRequest::class, 'list' => false], + ]]; + + $dto = resolveBody(NodeRequest::class, [ + 'label' => 'root', + 'child' => ['label' => 'a', 'child' => ['label' => 'b', 'child' => ['label' => 'c']]], + ], $shapes); + + if (! $dto instanceof NodeRequest) { + throw new RuntimeException('Expected a NodeRequest.'); + } + expect($dto->child?->child?->child?->label)->toBe('c') + ->and($dto->child?->child?->child?->child)->toBeNull(); +}); + +it('throws UNBINDABLE_BODY (400) — not a TypeError 500 — for a nested property that is not an object', function () { + try { + resolveBody(MoneyTransferRequest::class, [ + 'amount' => 1, + 'beneficiary' => 'Calle Mayor 1', + ], transferShapes()); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->httpStatus())->toBe(400) + ->and($e->errorCode())->toBe('UNBINDABLE_BODY') + ->and($e->getMessage())->toContain('beneficiary'); + } +}); + +it('throws UNBINDABLE_BODY (400) and names the OFFENDING ELEMENT when a list holds a scalar', function () { + try { + resolveBody(MoneyTransferRequest::class, [ + 'amount' => 1, + 'beneficiary' => ['street' => 'A', 'postcode' => 'B'], + 'lines' => [['reference' => 'INV-1', 'cents' => 100], 'INV-2'], + ], transferShapes()); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->errorCode())->toBe('UNBINDABLE_BODY') + ->and($e->getMessage())->toContain('lines[1]'); + } +}); + +it('throws UNBINDABLE_BODY (400) when a list-typed property is not a list at all', function () { + try { + resolveBody(MoneyTransferRequest::class, [ + 'amount' => 1, + 'beneficiary' => ['street' => 'A', 'postcode' => 'B'], + 'lines' => 'INV-1', + ], transferShapes()); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->errorCode())->toBe('UNBINDABLE_BODY') + ->and($e->getMessage())->toContain('lines'); + } +}); + +it('throws UNBINDABLE_BODY (400) for an INTERFACE-typed constructor parameter, which has no class to build', function () { + $shapes = [UnbindableRequest::class => [ + 'name' => ['class' => null, 'list' => false], + 'counter' => ['class' => Countable::class, 'list' => false], + ]]; + + try { + resolveBody(UnbindableRequest::class, ['name' => 'Ada', 'counter' => ['n' => 1]], $shapes); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->httpStatus())->toBe(400) + ->and($e->errorCode())->toBe('UNBINDABLE_BODY') + ->and($e->getMessage())->toContain('counter'); + } +}); + +it('throws UNBINDABLE_BODY (400) for a scalar the constructor refuses, instead of leaking the TypeError', function () { + // "amount": "lots" reaches `int $amount` untouched — the DTO constructor is the arbiter for builtins, and + // its TypeError is the one the resolver converts. + try { + resolveBody(MoneyTransferRequest::class, [ + 'amount' => 'lots', + 'beneficiary' => ['street' => 'A', 'postcode' => 'B'], + ], transferShapes()); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->errorCode())->toBe('UNBINDABLE_BODY') + ->and($e->getMessage())->not->toContain('must be of type') + ->and($e->getMessage())->not->toContain(DIRECTORY_SEPARATOR.'packages'.DIRECTORY_SEPARATOR); + } +}); + +it('falls back to a 400 (never a 500) on the LEGACY flat plan that cannot describe the nesting', function () { + // No `dtos` row: `properties` alone says "the constructor takes $amount, $beneficiary, $lines, + // $reference" and nothing about their types, so $beneficiary arrives as a raw array. That is the exact + // shape RouteScanner still compiles today, and the exact request that used to render as a 500. + try { + resolveBody( + MoneyTransferRequest::class, + ['amount' => 1, 'beneficiary' => ['street' => 'A', 'postcode' => 'B']], + [], + ['amount', 'beneficiary', 'lines', 'reference'], + ); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->httpStatus())->toBe(400)->and($e->errorCode())->toBe('UNBINDABLE_BODY'); + } +}); + +it('keeps the compiled shape table authoritative over the flat property list when both are present', function () { + $dto = resolveBody( + MoneyTransferRequest::class, + ['amount' => 7, 'beneficiary' => ['street' => 'A', 'postcode' => 'B']], + transferShapes(), + ['amount'], + ); + + if (! $dto instanceof MoneyTransferRequest) { + throw new RuntimeException('Expected a MoneyTransferRequest.'); + } + expect($dto->beneficiary->street)->toBe('A'); +}); + +it('validates the nested payload BEFORE hydrating it, so a 422 still beats the 400', function () { + try { + resolveBody(MoneyTransferRequest::class, [ + 'amount' => 1, + 'beneficiary' => ['street' => '', 'postcode' => ''], + ], transferShapes(), valid: true); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + expect($e->httpStatus())->toBe(422); + } +}); + +it('throws MISSING_PARAMETER (400) for an absent REQUIRED header instead of a constructor TypeError', function () { + $request = Request::create('/accounts', 'GET'); + + try { + resolverFor()->resolve([ + ['name' => 'tenant', 'kind' => 'header', 'key' => 'X-Tenant', 'type' => 'string', 'required' => true, 'default' => null, 'valid' => false, 'properties' => []], + ], $request, new Container); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->httpStatus())->toBe(400)->and($e->errorCode())->toBe('MISSING_PARAMETER'); + } +}); + +it('coerces a header to the parameter type, the same as a path or query binding', function () { + $request = Request::create('/accounts', 'GET', server: ['HTTP_X_API_VERSION' => '3']); + + $args = resolverFor()->resolve([ + ['name' => 'version', 'kind' => 'header', 'key' => 'X-Api-Version', 'type' => 'int', 'required' => true, 'default' => null, 'valid' => false, 'properties' => []], + ], $request, new Container); + + expect($args)->toBe([3]); +}); + +it('throws TYPE_CONVERSION_ERROR (400) for a header that will not coerce', function () { + $request = Request::create('/accounts', 'GET', server: ['HTTP_X_API_VERSION' => 'three']); + + try { + resolverFor()->resolve([ + ['name' => 'version', 'kind' => 'header', 'key' => 'X-Api-Version', 'type' => 'int', 'required' => true, 'default' => null, 'valid' => false, 'properties' => []], + ], $request, new Container); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->errorCode())->toBe('TYPE_CONVERSION_ERROR'); + } +}); + +it('binds an uploaded file and returns null for an optional one that was not sent', function () { + $path = tempnam(sys_get_temp_dir(), 'fw-upload'); + if ($path === false) { + throw new RuntimeException('Could not create a temporary upload.'); + } + file_put_contents($path, 'hello'); + + try { + $request = Request::create('/uploads', 'POST', files: [ + 'avatar' => new IlluminateUploadedFile($path, 'avatar.txt', 'text/plain', null, true), + ]); + + $args = resolverFor()->resolve([ + ['name' => 'avatar', 'kind' => 'file', 'key' => 'avatar', 'type' => UploadedFile::class, 'required' => true, 'default' => null, 'valid' => false, 'properties' => []], + ['name' => 'banner', 'kind' => 'file', 'key' => 'banner', 'type' => UploadedFile::class, 'required' => false, 'default' => null, 'valid' => false, 'properties' => []], + ], $request, new Container); + + expect($args[0])->toBeInstanceOf(UploadedFile::class) + ->and($args[1])->toBeNull(); + } finally { + @unlink($path); + } +}); + +it('throws MISSING_PARAMETER (400) for an absent REQUIRED uploaded file instead of a constructor TypeError', function () { + $request = Request::create('/uploads', 'POST'); + + try { + resolverFor()->resolve([ + ['name' => 'avatar', 'kind' => 'file', 'key' => 'avatar', 'type' => UploadedFile::class, 'required' => true, 'default' => null, 'valid' => false, 'properties' => []], + ], $request, new Container); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->httpStatus())->toBe(400)->and($e->errorCode())->toBe('MISSING_PARAMETER'); + } +}); + +it('throws TYPE_CONVERSION_ERROR (400) when a multi-file field is bound to a single-file parameter', function () { + $path = tempnam(sys_get_temp_dir(), 'fw-upload'); + if ($path === false) { + throw new RuntimeException('Could not create a temporary upload.'); + } + file_put_contents($path, 'hello'); + + try { + $request = Request::create('/uploads', 'POST', files: [ + 'avatar' => [new IlluminateUploadedFile($path, 'a.txt', 'text/plain', null, true)], + ]); + + resolverFor()->resolve([ + ['name' => 'avatar', 'kind' => 'file', 'key' => 'avatar', 'type' => UploadedFile::class, 'required' => true, 'default' => null, 'valid' => false, 'properties' => []], + ], $request, new Container); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->errorCode())->toBe('TYPE_CONVERSION_ERROR'); + } finally { + @unlink($path); + } +}); + +it('refuses an ENUM-typed property with a 400 rather than letting "cannot instantiate enum" escape as a 500', function () { + // class_exists() answers TRUE for an enum, so Currency reaches the same `new $class(...)` a nested DTO + // does — and raises a plain Error, not a TypeError. Binding an enum from its BACKING VALUE is a separate + // feature that needs the compiled plan to say "this property is an enum"; until then the contract this + // locks in is only that neither spelling of the payload can produce a server error. + $shapes = [PricedRequest::class => [ + 'cents' => ['class' => null, 'list' => false], + 'currency' => ['class' => Currency::class, 'list' => false], + ]]; + + foreach ([['value' => 'EUR'], 'EUR'] as $currency) { + try { + resolveBody(PricedRequest::class, ['cents' => 100, 'currency' => $currency], $shapes); + $this->fail('Expected InvalidRequestException'); + } catch (InvalidRequestException $e) { + expect($e->httpStatus())->toBe(400) + ->and($e->errorCode())->toBe('UNBINDABLE_BODY') + ->and($e->getMessage())->toContain('currency'); + } + } +}); diff --git a/packages/web/tests/Dispatch/NestedBodyBindingTest.php b/packages/web/tests/Dispatch/NestedBodyBindingTest.php new file mode 100644 index 0000000..908726f --- /dev/null +++ b/packages/web/tests/Dispatch/NestedBodyBindingTest.php @@ -0,0 +1,68 @@ +postJson('/transfers', [ + 'amount' => 250, + 'beneficiary' => [ + 'street' => 'Calle Mayor 1', + 'postcode' => '28013', + 'geo' => ['lat' => 40.4168, 'lon' => -3.7038], + ], + 'lines' => [['reference' => 'INV-1', 'cents' => 100]], + ]) + ->assertStatus(201) + ->assertExactJson(['amount' => 250, 'postcode' => '28013', 'lines' => 1]); +}); + +// The other side of the contract: a DTO the plan genuinely cannot describe (an interface-typed property) +// still reaches the constructor untouched, and that rejection is a CLIENT error, not a 500. +it('answers a body it cannot possibly bind with a clean 400', function () { + /** @var UncachedBootTestCase $this */ + $this->postJson('/unbindable', ['name' => 'Ada', 'counter' => ['items' => 2]]) + ->assertStatus(400) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'UNBINDABLE_BODY') + ->assertJsonPath('category', 'validation'); +}); + +it('never quotes a filesystem path or an internal type back to the client', function () { + /** @var UncachedBootTestCase $this */ + $body = (string) $this->postJson('/unbindable', ['name' => 'Ada', 'counter' => ['items' => 2]])->getContent(); + + // The pre-fix payload read: "...must be of type Firefly\Web\Tests\Fixtures\AddressPayload, array given, + // called in /Users//.../packages/web/src/Dispatch/ArgumentResolver.php on line 144". + expect($body)->not->toContain(dirname(__DIR__, 4)) + ->and($body)->not->toContain('ArgumentResolver.php') + ->and($body)->not->toContain('must be of type'); +}); + +it('still validates the nested payload before it ever reaches hydration', function () { + /** @var UncachedBootTestCase $this */ + // The #[Valid] cascade compiles `beneficiary.postcode` as a dot-key, so an empty nested postcode is a + // 422 — it must not be overtaken by hydration succeeding. + $this->postJson('/transfers', [ + 'amount' => 250, + 'beneficiary' => ['street' => 'Calle Mayor 1', 'postcode' => ''], + ])->assertStatus(422) + ->assertJsonPath('code', 'VALIDATION_ERROR'); +}); diff --git a/packages/web/tests/Fixtures/AddressPayload.php b/packages/web/tests/Fixtures/AddressPayload.php new file mode 100644 index 0000000..84de97c --- /dev/null +++ b/packages/web/tests/Fixtures/AddressPayload.php @@ -0,0 +1,25 @@ + AddressPayload -> GeoPoint). + * Carries no constraints on purpose: hydration depth must not depend on a level having validation rules. + */ +final class GeoPoint +{ + public function __construct( + public readonly float $lat, + public readonly float $lon, + ) {} +} diff --git a/packages/web/tests/Fixtures/MoneyTransferRequest.php b/packages/web/tests/Fixtures/MoneyTransferRequest.php new file mode 100644 index 0000000..ae6ed75 --- /dev/null +++ b/packages/web/tests/Fixtures/MoneyTransferRequest.php @@ -0,0 +1,26 @@ + $lines + */ + public function __construct( + public readonly int $amount, + #[Valid] + public readonly AddressPayload $beneficiary, + public readonly array $lines = [], + public readonly ?string $reference = null, + ) {} +} diff --git a/packages/web/tests/Fixtures/NodeRequest.php b/packages/web/tests/Fixtures/NodeRequest.php new file mode 100644 index 0000000..829cd72 --- /dev/null +++ b/packages/web/tests/Fixtures/NodeRequest.php @@ -0,0 +1,23 @@ + */ + #[PostMapping(status: 201)] + public function create(#[Valid] #[RequestBody] NodeRequest $body): array + { + $depth = 0; + for ($node = $body; $node !== null; $node = $node->child) { + $depth++; + } + + return ['label' => $body->label, 'depth' => $depth]; + } +} diff --git a/packages/web/tests/Fixtures/PricedRequest.php b/packages/web/tests/Fixtures/PricedRequest.php new file mode 100644 index 0000000..7641b66 --- /dev/null +++ b/packages/web/tests/Fixtures/PricedRequest.php @@ -0,0 +1,18 @@ + + * validate -> hydrate -> render) behaves, rather than only the resolver in isolation. + */ +#[RestController] +#[RequestMapping('/transfers')] +final class TransfersController +{ + /** @return array */ + #[PostMapping(status: 201)] + public function transfer(#[Valid] #[RequestBody] MoneyTransferRequest $body): array + { + return [ + 'amount' => $body->amount, + 'postcode' => $body->beneficiary->postcode, + 'lines' => count($body->lines), + ]; + } +} diff --git a/packages/web/tests/Fixtures/UnbindableController.php b/packages/web/tests/Fixtures/UnbindableController.php new file mode 100644 index 0000000..a118253 --- /dev/null +++ b/packages/web/tests/Fixtures/UnbindableController.php @@ -0,0 +1,27 @@ + */ + #[PostMapping(status: 201)] + public function create(#[RequestBody] UnbindableRequest $body): array + { + return ['name' => $body->name]; + } +} diff --git a/packages/web/tests/Fixtures/UnbindableRequest.php b/packages/web/tests/Fixtures/UnbindableRequest.php new file mode 100644 index 0000000..cb1e50b --- /dev/null +++ b/packages/web/tests/Fixtures/UnbindableRequest.php @@ -0,0 +1,19 @@ +> */ +function shapeTableFor(string $path): array +{ + $routes = (new RouteScanner)->scan(['Firefly\\Web\\Tests\\Fixtures\\' => dirname(__DIR__).'/Fixtures']); + + foreach ($routes as $route) { + if ($route->path !== $path) { + continue; + } + foreach ($route->bindings as $binding) { + if ($binding['kind'] !== 'body') { + continue; + } + + /** @var array> $dtos */ + $dtos = $binding['dtos'] ?? []; + + return $dtos; + } + } + + return []; +} + +it('compiles a row for every class reachable from the body DTO', function () { + $dtos = shapeTableFor('/transfers'); + + expect(array_keys($dtos))->toEqualCanonicalizing([ + MoneyTransferRequest::class, + AddressPayload::class, + GeoPoint::class, + TransferLine::class, + ]); + + // Three levels deep: the root's nested DTO has a nested DTO of its own. + expect($dtos[AddressPayload::class]['geo'])->toBe(['class' => GeoPoint::class, 'list' => false]); + expect($dtos[GeoPoint::class])->toHaveKeys(['lat', 'lon']); +}); + +// PHP's `array` type carries no element type, so list-ness can only come from the docblock — +// `@param list $lines` on MoneyTransferRequest's constructor. +it('reads the element class of a list out of the constructor docblock', function () { + $dtos = shapeTableFor('/transfers'); + + $root = $dtos[MoneyTransferRequest::class]; + + expect($root['lines'])->toBe(['class' => TransferLine::class, 'list' => true]) + ->and($root['beneficiary'])->toBe(['class' => AddressPayload::class, 'list' => false]) + ->and($root['amount'])->toBe(['class' => null, 'list' => false]); +}); + +// Keying by class is what makes depth unbounded. A DTO that points at itself is ONE row, so the walk +// terminates while the payload may still nest as deep as it likes. +it('emits a single row for a self-referential DTO instead of recursing forever', function () { + $dtos = shapeTableFor('/nodes'); + + expect($dtos)->toHaveKey(NodeRequest::class); + expect($dtos[NodeRequest::class]['child'])->toBe(['class' => NodeRequest::class, 'list' => false]); +}); + +// A flat DTO compiles to a table with no nested class in it, so nothing about the old plan shape changes +// for the routes that never needed one. +it('says "nothing nested here" for a flat DTO', function () { + $dtos = shapeTableFor('/accounts'); + + expect($dtos)->toHaveCount(1); + + $shape = reset($dtos); + expect($shape)->not->toBeFalse(); + + foreach ($shape === false ? [] : $shape as $property) { + expect($property)->toBe(['class' => null, 'list' => false]); + } +}); + +uses(UncachedBootTestCase::class)->in(__FILE__); + +it('hydrates a self-referential body as deep as the payload actually nests', function () { + /** @var UncachedBootTestCase $this */ + $this->postJson('/nodes', [ + 'label' => 'root', + 'child' => ['label' => 'a', 'child' => ['label' => 'b', 'child' => ['label' => 'c']]], + ]) + ->assertStatus(201) + ->assertExactJson(['label' => 'root', 'depth' => 4]); +}); + +it('hydrates a nested body end to end, from a real scan through a real request', function () { + /** @var UncachedBootTestCase $this */ + $this->postJson('/transfers', [ + 'amount' => 2500, + 'beneficiary' => [ + 'street' => 'Calle Mayor 1', + 'postcode' => '28013', + 'geo' => ['lat' => 40.4168, 'lon' => -3.7038], + ], + 'lines' => [ + ['reference' => 'INV-1', 'cents' => 1500], + ['reference' => 'INV-2', 'cents' => 1000], + ], + ]) + ->assertStatus(201) + ->assertExactJson(['amount' => 2500, 'postcode' => '28013', 'lines' => 2]); +}); diff --git a/skeleton/.env.example b/skeleton/.env.example index 2a14d32..35b0d08 100644 --- a/skeleton/.env.example +++ b/skeleton/.env.example @@ -11,3 +11,58 @@ CACHE_STORE=array SESSION_DRIVER=array QUEUE_CONNECTION=sync LOG_CHANNEL=stderr + +# --------------------------------------------------------------------------- +# Firefly +# +# Every value below is commented out at its framework default. config/firefly.php +# is the full reference — it lists every firefly.* key the framework reads, with +# the real default and what it does. These are only the ones that usually differ +# per environment. +# --------------------------------------------------------------------------- + +# Active profiles for #[Profile] / #[ConditionalOnProfile], comma-separated. +# Unset means "the value of APP_ENV". +# FIREFLY_PROFILES_ACTIVE=prod + +# Security is off by default and opt-in surface by surface. +# FIREFLY_SECURITY_ENABLED=true +# FIREFLY_SECURITY_HTTP_ENABLED=true +# FIREFLY_SECURITY_HEADERS_ENABLED=true +# FIREFLY_SECURITY_CSRF_ENABLED=true + +# Refuse to boot when no compiled method-security manifest exists. Method security +# treats "no rule for this method" as ALLOW, so an empty manifest silently disables +# every #[PreAuthorize]. Turn this on in any image that runs firefly:cache. +# FIREFLY_SECURITY_METHOD_STRICT=true + +# Local JWT bearer auth. The secret is rejected at boot if it is a placeholder or +# too short. Mutually exclusive with the OAuth2 resource server. +# FIREFLY_JWT_ENABLED=true +# FIREFLY_JWT_SECRET= + +# OAuth2 resource server (validates bearer tokens against a remote JWKS). +# FIREFLY_OAUTH2_ENABLED=true +# FIREFLY_OAUTH2_JWKS_URI= +# FIREFLY_OAUTH2_ISSUER= +# FIREFLY_OAUTH2_AUDIENCE= + +# Actuator web exposure (CSV or *). Default health,info — env/beans/conditions/ +# mappings/loggers disclose wiring, so expose them only behind security rules. +# FIREFLY_ACTUATOR_EXPOSE=health,info +# FIREFLY_HEALTH_SHOW_DETAILS=always +# FIREFLY_HEALTH_DB_ENABLED=true + +# Metrics survive only the request that recorded them unless a cache store is named +# here — under PHP-FPM every request is a fresh process. Use a store with an atomic +# increment (redis, memcached, apc, dynamodb); array is no better than memory. +# FIREFLY_METRICS_STORE=redis +# FIREFLY_METRICS_TTL=0 + +# Coordination for #[Scheduled] across instances: none | cache | postgres. +# FIREFLY_SCHEDULING_LOCK=cache + +# Transports: memory | queue | rabbitmq | postgres | kafka (events), +# memory | queue (messaging). +# FIREFLY_EDA_PROVIDER=memory +# FIREFLY_MESSAGING_PROVIDER=memory diff --git a/skeleton/README.md b/skeleton/README.md index ce87898..61493bc 100644 --- a/skeleton/README.md +++ b/skeleton/README.md @@ -1,10 +1,10 @@ # LaraFly Skeleton A [Laravel 13](https://laravel.com) application skeleton pre-wired with the **Firefly** framework family -(`firefly/firefly` + `firefly/cli`). It ships a sample slice — a `#[RestController]`, a `#[Service]`, and a -`#[ConfigProperties]` DTO — plus the `firefly:cache` compile step in `post-create-project-cmd`, so a freshly -created app boots on the zero-reflection cached path with **zero external infrastructure** (sqlite + array/sync -drivers by default). +(`firefly/firefly` + `firefly/cli`). It ships a sample slice — a `#[Controller]` welcome page, a +`#[RestController]`, a `#[Service]`, and a `#[ConfigProperties]` DTO — plus the `firefly:cache` compile step in +`post-create-project-cmd`, so a freshly created app boots on the zero-reflection cached path with **zero +external infrastructure** (sqlite + array/sync drivers by default). ## Create a new app @@ -22,11 +22,22 @@ cd my-app ## The sample slice -- `app/Http/GreetingController.php` — a `#[RestController]` exposing `GET /` and `GET /greetings/{name}`. +- `app/Http/WelcomeController.php` — a `#[Controller]` (the HTML stereotype) rendering `GET /`. Nothing on the + page is hard-coded: the boot pipeline, the bean and condition counts, the route table and the actuator's + registered-vs-exposed endpoints all come from the same objects the actuator endpoints serve. +- `app/Http/GreetingController.php` — a `#[RestController]` exposing `GET /greetings/{name}`, which negotiates + to JSON. The pair is the difference between the two stereotypes. - `app/GreetingService.php` — a `#[Service]` autowired into the controller. - `app/GreetingProperties.php` — a `#[ConfigProperties('greeting')]` DTO bound from configuration. -- `app/Support/CachedTransactionalConfiguration.php` — the committed `#[Configuration]`/`#[Bean]` that loads the - compiled `TransactionalManifest` on a cached boot. +- `tests/Feature/WelcomeTest.php` — the smoke test a new application should start from: HTML renders, JSON + negotiates, `/actuator/health` reports UP. Run it with `composer test`. + +## Configuration + +`config/firefly.php` is the full reference: every `firefly.*` key the framework reads, grouped by capability, +with its real default and what it does. Keys a typical app never touches are commented out with their default +shown, so an absent key and a key set to the printed value behave identically. `.env.example` carries the +handful that usually differ per environment. ## Recompile the manifests @@ -36,4 +47,19 @@ After adding or changing Firefly-annotated classes under `app/`, re-run: php artisan firefly:cache ``` -Run `php artisan firefly:clear` to remove the compiled cache and fall back to the dev-scan boot path. +`php artisan firefly:clear` removes the compiled cache. + +### What happens without the cache + +The app still works. Every manifest — routes, exception handlers, CQRS handlers, event and message listeners, +scheduled tasks, validation constraints, method-security rules, `#[ConfigProperties]` DTOs and the +`#[Transactional]` proxies — is resolved the same way: **the compiled artifact if it exists, otherwise an +in-process scan of `firefly.scan.paths` on every boot, otherwise empty**. Compiling buys a reflection-free +boot; it is an optimisation, not a correctness requirement. + +That is worth stating precisely, because this file used to claim the fallback while it did not exist: before +it landed, a `firefly:clear`ed app 404'd every route it owned, and — because method security reads "no rule +recorded for this method" as ALLOW — an empty security manifest silently disabled every `#[PreAuthorize]`. +Set `firefly.security.method.strict` (or `FIREFLY_SECURITY_METHOD_STRICT=true`) to make a boot with no +compiled method-security manifest refuse to start rather than run unprotected. The welcome page reports which +path this boot took. diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index be28b11..ba062c9 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -2,15 +2,671 @@ declare(strict_types=1); +/* +|-------------------------------------------------------------------------- +| Firefly (LaraFly) configuration reference +|-------------------------------------------------------------------------- +| +| Every `firefly.*` key the framework actually reads is listed here, grouped by the package that reads +| it, with the real default the code falls back to when the key is absent. Keys that a typical +| application never touches are left COMMENTED OUT with their default shown, so the file stays short +| enough to read while remaining a complete reference: an absent key and a key set to the value printed +| next to it behave identically. +| +| Reading conventions used throughout: +| +| * "default X" is the literal fallback in the call site, not an aspiration. Where the framework uses +| `Config::has()` rather than a default, the key is documented as "unset" and MUST stay commented +| out — writing it changes behaviour even when you write what looks like the default. +| * Flags gated by #[ConditionalOnProperty] are compared as STRINGS after stringification: only the +| boolean `true` and the string `'true'` match `havingValue: 'true'` — the integer `1` stringifies to +| `'1'` and does NOT. Use boolean literals (or env() values, which Laravel already casts). +| * Duration-shaped values accept either a bare number of seconds or a `Firefly\Resilience\Duration` +| string: `250ms`, `30s`, `5m`, `1h`. +| +*/ + return [ + + /* + |-------------------------------------------------------------------------- + | Component scanning — firefly/autoconfigure, firefly/context + |-------------------------------------------------------------------------- + | + | The PSR-4 roots Firefly scans for stereotypes (#[Component]/#[Service]/#[RestController]/ + | #[Controller]/#[Repository]/#[Configuration]) and for every attribute-driven manifest. This is the + | ONE key an application must get right: `firefly:cache` compiles these roots, and an uncached boot + | scans them in-process. Leave it empty and the app boots with no routes, handlers, listeners, + | scheduled tasks, constraints or method-security rules at all. + | + */ + 'scan' => [ 'paths' => [ 'App\\' => app_path(), ], ], + + /* + |-------------------------------------------------------------------------- + | Compiled artifacts — firefly/cli, firefly/context + |-------------------------------------------------------------------------- + | + | Where `php artisan firefly:cache` writes the compiled manifests and the #[Transactional] proxies, + | and where the boot path looks for them. Boot resolves each manifest in this order: the compiled + | artifact if it exists, else an in-process scan of `scan.paths`, else an empty manifest. So a + | missing cache directory costs reflection at boot, never correctness. + | + | `path` is the directory (read by Firefly\Context\Scan\AppScan and firefly/cli); the two + | `*_manifest` keys are the two files FireflyAutoConfigureServiceProvider loads directly. + | + */ + 'cache' => [ 'path' => base_path('bootstrap/cache/firefly'), 'component_manifest' => base_path('bootstrap/cache/firefly/component.php'), 'context_manifest' => base_path('bootstrap/cache/firefly/context.php'), ], + + /* + |-------------------------------------------------------------------------- + | Profiles — firefly/config + |-------------------------------------------------------------------------- + | + | Active profiles for #[Profile] and #[ConditionalOnProfile]. ProfileResolver reads, in order: the + | FIREFLY_PROFILES_ACTIVE environment variable, then this key, then `app.env`, then the implicit + | `default` profile — so setting nothing here means "the profile is APP_ENV". A list is accepted and + | joined with commas. + | + | Default: unset (falls back to APP_ENV, then 'default'). + | + */ + + // 'profiles' => [ + // 'active' => ['prod', 'eu'], + // ], + + /* + |-------------------------------------------------------------------------- + | Security — firefly/security + |-------------------------------------------------------------------------- + | + | OFF by default, and opt-in surface by surface. `enabled` is the master flag: it gates the principal + | model, the role hierarchy, the user store, the authentication manager, the CQRS authorizers and the + | programmatic AuthorizationChecker. + | + | Each surface below has its own flag. Only `http` ALSO requires the master flag — its filter's + | constructor needs three master-gated beans, so enabling it alone would bind a filter whose + | dependencies do not exist. `jwt`, `oauth2.resource_server`, `csrf` and `headers` are independent of + | the master flag and can be turned on by themselves. Note that authenticating (jwt/oauth2) without + | `http` or method security enforces no authorization at all — it only establishes a principal. + | + */ + + 'security' => [ + + // Master flag. Default: false. + 'enabled' => env('FIREFLY_SECURITY_ENABLED', false), + + /* + | Method security (#[PreAuthorize], #[PostAuthorize], #[Secured], #[RolesAllowed]). + | + | Enforcement treats "no rule recorded for this method" as ALLOW, so an EMPTY method-security + | manifest silently disables every annotation in the application — it fails OPEN. Boot resolves + | the manifest from the compiled artifact, else an in-process scan, else empty; `strict` refuses + | to boot when neither a compiled artifact nor a scan produced one, which is the only defence + | against a build that ships without the compile step. Turn it on in production images. + | + | Default: false. + */ + 'method' => [ + 'strict' => env('FIREFLY_SECURITY_METHOD_STRICT', false), + ], + + /* + | The shipped in-memory user store, keyed by username. `password` is the ENCODED string — + | typically `{id}`-prefixed for the DelegatingPasswordEncoder, e.g. `{bcrypt}$2y$...`. + | `authorities` defaults to [], `enabled` to true, `locked` to false. + | + | Default: [] (no users; every login fails with a 401). + */ + 'users' => [ + // 'alice' => [ + // 'password' => '{bcrypt}$2y$12$...', + // 'authorities' => ['ROLE_ADMIN'], + // 'enabled' => true, + // 'locked' => false, + // ], + ], + + /* + | Role implication rules, one per line, in the form `ROLE_A > ROLE_B`. A principal holding + | ROLE_A is then treated as holding ROLE_B everywhere authority checks run. + | + | Default: [] (no implications; roles are compared literally). + */ + 'role_hierarchy' => [ + // 'ROLE_ADMIN > ROLE_USER', + ], + + /* + | URL authorization. REQUIRES the master flag above as well as this one. DENY BY DEFAULT: with + | both on, a request matching NO rule is refused (401 when anonymous, 403 when authenticated). + | Rules are first-match-wins over Str::is() patterns. + | + | `access` is a FIXED vocabulary, not free expression text — HttpSecurity::fromConfig() maps it: + | + | permitAll | denyAll | authenticated | hasRole: | hasAuthority: + | + | Anything it does not recognise compiles to denyAll(): the spec is fail-closed, so a typo + | locks the path down rather than opening it. Write `hasRole:ADMIN`, never `hasRole('ADMIN')`. + | + | Defaults: enabled false, rules []. + */ + 'http' => [ + 'enabled' => env('FIREFLY_SECURITY_HTTP_ENABLED', false), + 'rules' => [ + // ['pattern' => 'actuator/health', 'access' => 'permitAll'], + // ['pattern' => 'actuator/*', 'access' => 'hasRole:ACTUATOR'], + // ['pattern' => '*', 'access' => 'authenticated'], + ], + ], + + /* + | Local JWT bearer authentication. Mutually exclusive with the OAuth2 resource server below — + | enabling both throws at boot, because the local filter (order -90) would reject tokens before + | the resource-server filter (order -85) could validate them. + | + | `secret` is required once `enabled` is true, and JwtService REFUSES TO BOOT on a placeholder + | or a secret shorter than its minimum byte length. + | + | Defaults: enabled false, algorithm 'HS256', leeway 0, authorities_claim 'authorities'. + */ + 'jwt' => [ + 'enabled' => env('FIREFLY_JWT_ENABLED', false), + 'secret' => env('FIREFLY_JWT_SECRET', ''), + 'algorithm' => 'HS256', + 'leeway' => 0, + 'authorities_claim' => 'authorities', + ], + + /* + | OAuth2 resource server: validates bearer tokens against a remote JWKS. `jwks_uri` is required + | once `enabled` is true. An empty `issuer`/`audience` skips that claim check. + | + | Defaults: enabled false, issuer '', audience '', authorities_claim 'roles', cache_ttl 3600. + */ + 'oauth2' => [ + 'resource_server' => [ + 'enabled' => env('FIREFLY_OAUTH2_ENABLED', false), + 'jwks_uri' => env('FIREFLY_OAUTH2_JWKS_URI', ''), + 'issuer' => env('FIREFLY_OAUTH2_ISSUER', ''), + 'audience' => env('FIREFLY_OAUTH2_AUDIENCE', ''), + 'authorities_claim' => 'roles', + 'cache_ttl' => 3600, + ], + ], + + /* + | Response security headers, applied by a filter ordered -95 so they survive on error responses + | too. Each value below is the framework default and is written verbatim onto the response. + | + | Default: enabled false. + */ + 'headers' => [ + 'enabled' => env('FIREFLY_SECURITY_HEADERS_ENABLED', false), + // 'hsts' => 'max-age=31536000; includeSubDomains', + // 'frame_options' => 'DENY', + // 'content_type_options' => 'nosniff', + // 'referrer_policy' => 'no-referrer', + // 'csp' => "default-src 'self'", + ], + + /* + | CSRF protection for state-changing requests. `except` holds Str::is() patterns skipped by the + | filter — a JSON API authenticated by bearer token usually belongs here. + | + | Defaults: enabled false, except []. + */ + 'csrf' => [ + 'enabled' => env('FIREFLY_SECURITY_CSRF_ENABLED', false), + 'except' => [ + // 'api/*', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Management endpoints — firefly/actuator + |-------------------------------------------------------------------------- + | + | The Spring-Actuator-shaped surface. Mounted at `endpoints.web.base-path`; the shipped endpoint ids + | are: health, info, env, beans, conditions, mappings, loggers, scheduledtasks — plus metrics and + | prometheus from firefly/observability. + | + */ + + 'management' => [ + + // Master gate: false unmounts every actuator route. Default: true. + 'enabled' => true, + + 'endpoints' => [ + 'web' => [ + // Default: '/actuator'. + 'base-path' => '/actuator', + + /* + | Web exposure, CSV or `*`. `*` is a wildcard in BOTH lists and EXCLUDE WINS, so + | `exclude => '*'` is the kill switch. Secure by default: only health and info are + | reachable; anything else answers 404 even though the endpoint exists. `env`, `beans`, + | `conditions`, `mappings` and `loggers` disclose configuration and wiring — expose them + | only behind the security.http rules above. + | + | Defaults: include 'health,info', exclude ''. + */ + 'exposure' => [ + 'include' => env('FIREFLY_ACTUATOR_EXPOSE', 'health,info'), + 'exclude' => '', + ], + ], + ], + + 'endpoint' => [ + + /* + | Per-endpoint kill switch, checked at dispatch AND on the index: `firefly.management. + | endpoint..enabled`. Default for every id: true. + */ + // 'env' => ['enabled' => false], + // 'loggers' => ['enabled' => false], + + 'health' => [ + /* + | 'always' includes each contributor's component details in the body; anything else + | (including the default) returns the aggregated status only. Details name drivers, + | paths and error messages, so they are off by default. + | + | Default: 'never'. + */ + 'show-details' => env('FIREFLY_HEALTH_SHOW_DETAILS', 'never'), + + /* + | The DB indicator is OPT-IN so a database-less app's /health does not 503. A failing + | query is caught and reported DOWN, never surfaced as a 500. + | + | Default: false. + */ + 'db' => [ + 'enabled' => env('FIREFLY_HEALTH_DB_ENABLED', false), + ], + + /* + | Free-space indicator. Reports DOWN below `threshold` bytes at `path`. + | + | Defaults: path = the process working directory (getcwd(), NOT base_path() — the sample + | below is the value you probably want, not the framework default), threshold = 10485760 + | (10 MB). + */ + // 'diskspace' => [ + // 'path' => base_path(), + // 'threshold' => 10485760, + // ], + + /* + | Probe groups served at /actuator/health/{name} — CSV of indicator names. An UNSET + | group is a 404, so these must stay commented out until you mean them. + | + | Default: unset (no groups configured). + */ + // 'group' => [ + // 'liveness' => ['include' => 'ping'], + // 'readiness' => ['include' => 'db,diskSpace'], + // ], + ], + ], + + 'info' => [ + /* + | Surfaced verbatim under the `app` key of /actuator/info. + | + | Default: unset (the contributor returns nothing). + */ + // 'app' => [ + // 'name' => env('APP_NAME', 'LaraFly'), + // 'version' => '1.0.0', + // ], + + /* + | A generated build-info JSON file, surfaced under `build`. A missing file is not an error. + | + | Default: firefly-build.json in the process working directory (getcwd(), NOT base_path() — + | the sample below is the value you probably want, not the framework default). + */ + // 'build' => [ + // 'path' => base_path('firefly-build.json'), + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Admin dashboard — firefly/admin + |-------------------------------------------------------------------------- + | + | A browser dashboard over the actuator's data. It reads the endpoint registry IN-PROCESS, deliberately + | bypassing the exposure model above — seeing beans, conditions and the environment locally without + | first publishing them over HTTP to everyone is the whole point. + | + | That makes the dashboard's own URL the only boundary, so `enabled` DEFAULTS TO `app.debug`: an app + | already serving stack traces is a development environment by definition, and an app with debug off + | must opt in explicitly — and should put the route behind its own auth middleware when it does. + | Setting the key wins over the debug default in both directions. + | + | Defaults: enabled = app.debug, base-path '/firefly', title = app.name. + | + */ + + // 'admin' => [ + // 'enabled' => env('FIREFLY_ADMIN_ENABLED', false), + // 'base-path' => '/firefly', + // 'title' => env('APP_NAME', 'LaraFly'), + // ], + + /* + |-------------------------------------------------------------------------- + | Observability — firefly/observability + |-------------------------------------------------------------------------- + | + | The Micrometer analogue. `metrics.enabled` gates the MeterRegistry, the HTTP MetricsFilter, the + | CQRS metrics recorder and both /actuator/metrics and /actuator/prometheus, with the same key on + | each so they can never disagree. + | + */ + + 'observability' => [ + 'metrics' => [ + + // Default: true (matchIfMissing). + 'enabled' => true, + + /* + | Naming a CACHE STORE swaps SimpleMeterRegistry for CacheMeterRegistry, whose counters and + | timers accumulate ACROSS PROCESSES. This matters under PHP-FPM: each request is a fresh + | process, so with the in-memory registry a scrape of /actuator/metrics sees only what that + | scrape's own request recorded — which reads as data but is not. Point it at a store with + | an atomic increment (redis, memcached, apc, dynamodb); `array` is no better than memory. + | + | Opt-in on purpose: a registry that silently starts writing to whatever cache an app + | happens to have configured is a surprise. + | + | Default: '' (in-process SimpleMeterRegistry). + */ + 'store' => env('FIREFLY_METRICS_STORE', ''), + + /* + | Expiry in seconds for each cache-backed meter, so a meter nothing writes any more is + | eventually reclaimed instead of living in the store forever. Only consulted when `store` + | is set; 0 or less means no expiry. + | + | Default: 0 (no expiry). + */ + 'ttl' => (int) env('FIREFLY_METRICS_TTL', 0), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Resilience — firefly/resilience + |-------------------------------------------------------------------------- + | + | Named pattern instances, read once into ResilienceRegistry. `..` where pattern + | is retry / circuit-breaker / rate-limiter / bulkhead / time-limiter and name is whatever your code + | passes to $registry->circuitBreaker('payments'). An unconfigured name still works — every pattern + | has defaults — as long as no OTHER name is configured under that pattern. + | + | NOTE — this section is the one exception to the "commented out at its default" convention above. A + | pattern instance is named by YOUR code, so there is no default instance to print; the commented blocks + | below are ILLUSTRATIVE named instances, and several of their values are deliberately not the framework + | default (the real defaults are: retry wait-duration 0, backoff-multiplier 1.0; circuit-breaker + | minimum-number-of-calls 0; time-limiter timeout 30s). Only `store.lock-block-timeout` below is written + | at its true default. + | + | See docs/modules/resilience.md for the full key-by-key tables. + | + */ + + 'resilience' => [ + + /* + | How long a pattern waits for the shared-state mutex before failing fast with a 503. This is + | the WAIT budget, not how long the lock is held. The right value depends on the cache driver: + | an array store or a local Redis hands over in microseconds, a database-backed cache across an + | availability zone can legitimately need tens of milliseconds. + | + | Default: 0.5 (500ms). + */ + 'store' => [ + 'lock-block-timeout' => '500ms', + ], + + // 'retry' => [ + // 'payments' => ['max-attempts' => 3, 'wait-duration' => '250ms', 'backoff-multiplier' => 2.0], + // ], + // 'circuit-breaker' => [ + // 'payments' => [ + // 'failure-threshold' => 5, + // 'window-size' => 10, + // 'minimum-number-of-calls' => 5, + // 'wait-duration-in-open' => '30s', + // 'half-open-max-calls' => 1, + // 'half-open-probe-timeout' => '30s', + // ], + // ], + // 'rate-limiter' => [ + // 'api' => ['max-tokens' => 10, 'refill-rate' => 10.0, 'timeout' => 0], + // ], + // 'bulkhead' => [ + // 'db' => ['max-concurrent' => 10, 'max-wait' => 0, 'permit-ttl' => '60s'], + // ], + // 'time-limiter' => [ + // 'payments' => ['timeout' => '2s'], + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Scheduling — firefly/scheduling + |-------------------------------------------------------------------------- + | + | Which DistributedLock backs #[Scheduled] tasks so one task runs once across N instances: + | 'none' — no coordination (correct for a single instance). The default. + | 'cache' — the app's atomic cache lock. + | 'postgres' — Postgres advisory locks; requires firefly/scheduling-postgres. + | + */ + + 'scheduling' => [ + 'lock' => [ + 'provider' => env('FIREFLY_SCHEDULING_LOCK', 'none'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | CQRS — firefly/cqrs + |-------------------------------------------------------------------------- + */ + + 'cqrs' => [ + + /* + | The broker destination domain events are published to when a handler's #[CommandHandler] does + | not name one of its own. + | + | Default: 'cqrs.events'. + */ + 'default_destination' => 'cqrs.events', + + /* + | What DomainEventBridge does when publishing throws. The publish runs AFTER the DB commit, so + | the write already succeeded: + | 'log' — swallow and log; the command result stands, the integration publish is best-effort. + | 'raise' — rethrow wrapped in CommandProcessingException so the caller sees the failure. + | + | Default: 'log'. + */ + 'event_failure_strategy' => 'log', + + /* + | Default TTL in seconds for #[Cacheable] query results that do not declare their own. UNSET + | means "no default TTL" — not zero — so leave it commented out unless you want one. + | + | Default: unset. + */ + // 'query' => [ + // 'cache_ttl' => 60, + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Events — firefly/eda (+ eda-rabbitmq / eda-postgres / eda-kafka) + |-------------------------------------------------------------------------- + | + | `provider` selects the EventPublisher adapter: + | 'memory' — in-process bus. The default; listeners run synchronously. + | 'queue' — Laravel queue; listeners run in `queue:work`. + | 'rabbitmq' — requires firefly/eda-rabbitmq. + | 'postgres' — requires firefly/eda-postgres; enables the same-transaction outbox. + | 'kafka' — requires firefly/eda-kafka and ext-rdkafka. + | + | The broker providers are consumed by `php artisan firefly:eda:consume`; 'queue' uses + | `php artisan queue:work`; 'memory' has no consumer loop. + | + */ + + 'eda' => [ + + 'provider' => env('FIREFLY_EDA_PROVIDER', 'memory'), + + /* + | Envelope encoding. Only 'json' ships today; anything else throws at boot rather than picking + | a format silently. + | + | Default: 'json'. + */ + 'serialization_format' => 'json', + + /* + | In-process delivery retries per listener before the envelope goes to the DeadLetterStore, and + | the delay between attempts in seconds. + | + | Defaults: retries 0, retry_delay 0.0. + */ + 'retries' => 0, + 'retry_delay' => 0.0, + + /* + | Broker destinations `firefly:eda:consume` binds when `--destination` is not passed. Must be a + | list of strings. + | + | Default: []. + */ + 'destinations' => [ + // 'cqrs.events', + ], + + /* + | provider=queue: which Laravel queue connection and queue name carry the envelopes. Both UNSET + | means "the application's own defaults". + | + | Default: unset. + */ + // 'queue' => [ + // 'connection' => 'redis', + // 'name' => 'events', + // ], + + /* + | Consumer group identity, used by the Kafka adapter. + | + | Default: 'firefly'. + */ + // 'consumer' => [ + // 'group_id' => 'firefly', + // ], + + /* + | provider=rabbitmq. The exchange is topic-routed by event type; the DLX receives envelopes a + | consumer nacks. + | + | Defaults: host '127.0.0.1', port 5672, user 'guest', password 'guest', vhost '/', + | exchange 'firefly.events', queue 'firefly.eda', dlx 'firefly.events.dlx', prefetch 10. + */ + // 'rabbitmq' => [ + // 'host' => env('RABBITMQ_HOST', '127.0.0.1'), + // 'port' => (int) env('RABBITMQ_PORT', 5672), + // 'user' => env('RABBITMQ_USER', 'guest'), + // 'password' => env('RABBITMQ_PASSWORD', 'guest'), + // 'vhost' => env('RABBITMQ_VHOST', '/'), + // 'exchange' => 'firefly.events', + // 'queue' => 'firefly.eda', + // 'dlx' => 'firefly.events.dlx', + // 'prefetch' => 10, + // ], + + /* + | provider=postgres — the same-transaction outbox. `connection` UNSET means the default database + | connection. `channel` is the LISTEN/NOTIFY channel; `max_attempts` bounds relay retries before + | a row is marked FAILED. + | + | `relay.downstream_provider` is OPTIONAL and only used by `php artisan firefly:outbox:relay`, + | which forwards committed outbox rows to a SECOND broker. It takes 'rabbitmq', 'kafka', an + | EventPublisher class-string, or the id of your own binding. Leave it unset unless you run the + | relay: provider=postgres already delivers rows in-process via `firefly:eda:consume`. + | + | Defaults: connection unset, channel 'firefly_eda_events', max_attempts 3, + | relay.downstream_provider unset. + */ + // 'postgres' => [ + // 'connection' => 'pgsql', + // 'channel' => 'firefly_eda_events', + // 'max_attempts' => 3, + // 'relay' => [ + // 'downstream_provider' => 'rabbitmq', + // ], + // ], + + /* + | provider=kafka. Comma-separated broker list. + | + | Default: '127.0.0.1:9092'. + */ + // 'kafka' => [ + // 'brokers' => env('KAFKA_BROKERS', '127.0.0.1:9092'), + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Messaging — firefly/messaging + |-------------------------------------------------------------------------- + | + | The point-to-point #[MessageListener] transport, independent of the event bus above. + | 'memory' — in-process. The default. + | 'queue' — Laravel queue; both keys UNSET mean the application's own defaults. + | + */ + + 'messaging' => [ + 'provider' => env('FIREFLY_MESSAGING_PROVIDER', 'memory'), + + // 'queue' => [ + // 'connection' => 'redis', + // 'name' => 'messages', + // ], + ], + ]; diff --git a/tests/ReleaseWorkflowTest.php b/tests/ReleaseWorkflowTest.php index a9419c4..d61c4f4 100644 --- a/tests/ReleaseWorkflowTest.php +++ b/tests/ReleaseWorkflowTest.php @@ -64,7 +64,7 @@ function releaseWorkflowYaml(): array expect($tags)->toContain('v*'); }); -it('the split matrix covers exactly the 26 publishable units, each mapped to fireflyframework/firefly-', function () { +it('the split matrix covers exactly the 28 publishable units, each mapped to fireflyframework/firefly-', function () { $root = dirname(__DIR__); $yaml = releaseWorkflowYaml(); @@ -86,7 +86,7 @@ function releaseWorkflowYaml(): array $expectedLocals[] = 'skeleton'; sort($expectedLocals); - expect($expectedLocals)->toHaveCount(26); + expect($expectedLocals)->toHaveCount(28); /** @var list $actualLocals */ $actualLocals = []; From d1c4f2986b2c9329372c7a5933d14fa26a62118e Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 15:31:22 -0700 Subject: [PATCH 12/31] feat(admin,openapi): rebuild the dashboard and the API reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both UIs were thin. The dashboard's panels were largely apologies telling the operator to go and change a config key, values were raw (`2097152`, `994610155520`), the nav was a flat list of eight, and most of every page was empty space. The API viewer was a list of paths with a Show link. DASHBOARD Data, not apologies. The Health panel now reads the HealthContributorRegistry DIRECTLY instead of going through the health endpoint. That endpoint withholds per-indicator details unless show-details is `always`, and that default is right — it stops an anonymous HTTP caller learning your database host from a failed connection — but the dashboard is not an anonymous HTTP caller, and applying the HTTP disclosure policy to it produced a panel whose entire content was an apology. Each indicator is called in isolation, so one that throws is reported DOWN with its reason and nothing else degrades. Values formatted for humans. New Format: bytes as sizes, seconds at whatever scale reads (µs/ms/s/m), timestamps as "3m ago", class names split into a short name over a dimmed namespace. Units are inferred from the meter NAME, the same convention the Prometheus exposition relies on, because there is no unit metadata on the wire. The JSON surface keeps returning raw numbers — Prometheus scrapes it. Four new pages (Health, HTTP traffic, Config properties, Caches) and a nav grouped Runtime / Wiring / Configuration, because a flat list of twelve is a worse menu than three short ones. A page whose endpoint is absent stays hidden rather than offering a link that lands on an apology. A neutral shell. Colour now means status — green up, red down, amber attention — so a failing indicator no longer competes with a heading for the eye. Plus a theme toggle with three states, auto-refresh with a visible countdown, per-panel filters with a live "shown of total", and "/" to focus the first filter. Two rendering bugs fixed: `.panel + .panel` applied its stacking margin inside grids, pushing the second column 16px down so paired panel headers were visibly out of line; and `overflow-wrap:anywhere` broke class names mid-word ("SecurityHeadersFilte / r"), so the class cell now truncates with max-width:0 and the columns beside it stopped being pushed off the panel. API REFERENCE The built-in viewer is now an actual reference rather than an index: operations grouped by tag with a filter, and per operation a parameters table, the request-body schema as a nested tree, a responses table, a resolved schema tree per response, and Try it — which sends the real request and shows the status, duration and body. Constraint keywords (minimum, enum, format, pattern, minLength…) render beside each member, which is where the validation attributes finally become visible to a reader. $ref pointers are resolved everywhere a schema can appear, so nobody reads a pointer into #/components/schemas. Still zero dependencies and one - @endpush -@endonce diff --git a/packages/admin/resources/views/_panel-head.blade.php b/packages/admin/resources/views/_panel-head.blade.php new file mode 100644 index 0000000..7648136 --- /dev/null +++ b/packages/admin/resources/views/_panel-head.blade.php @@ -0,0 +1,12 @@ +{{-- A panel header with an optional filter box and a live "shown of total" readout. --}} +
+

{{ $title }}

+ + @isset($filter) + + {{ $count }} + @else + {{ $count }} + @endisset +
diff --git a/packages/admin/resources/views/beans.blade.php b/packages/admin/resources/views/beans.blade.php index 0e8e2d5..d3f4df2 100644 --- a/packages/admin/resources/views/beans.blade.php +++ b/packages/admin/resources/views/beans.blade.php @@ -1,29 +1,42 @@ @extends('firefly-admin::layout') @section('title', 'Beans') @section('body') + @php use Firefly\Admin\Format; @endphp +

Beans

Every bean the container registered, with the stereotype that declared it and the scope it lives in.

-

Container {{ count($beans) }} beans

- @include('firefly-admin::_filter', ['target' => 'beans-body', 'placeholder' => 'Filter by class, stereotype or interface…']) + @include('firefly-admin::_panel-head', [ + 'title' => 'Container', 'count' => count($beans), + 'filter' => 'beans-body', 'placeholder' => 'Filter by class, stereotype or interface…', + ]) @if ($beans === []) -

No beans registered.

+ @include('firefly-admin::_empty', [ + 'title' => 'No beans registered', + 'body' => 'Check firefly.scan.paths points at your application namespace.', + ]) @else
@foreach ($beans as $bean) + @php $class = is_string($bean['class'] ?? null) ? $bean['class'] : ''; @endphp - - - - - + + + + @endforeach diff --git a/packages/admin/resources/views/caches.blade.php b/packages/admin/resources/views/caches.blade.php new file mode 100644 index 0000000..968d87f --- /dev/null +++ b/packages/admin/resources/views/caches.blade.php @@ -0,0 +1,49 @@ +@extends('firefly-admin::layout') +@section('title', 'Caches') +@section('body') + @php + $stores = is_array($cacheManagers ?? null) ? $cacheManagers : (is_array($stores ?? null) ? $stores : $caches); + $rows = []; + foreach (is_array($stores) ? $stores : [] as $name => $store) { + $rows[] = [ + 'name' => (string) $name, + 'driver' => is_array($store) && is_string($store['driver'] ?? null) ? $store['driver'] : '', + 'default' => is_array($store) && ($store['default'] ?? false) === true, + ]; + } + @endphp + +
+

Caches

+

The cache stores this application has configured. Several framework features read one: + firefly.observability.metrics.store, resilience state, and scheduling locks.

+
+ +
+ @include('firefly-admin::_panel-head', ['title' => 'Stores', 'count' => count($rows)]) + @if ($rows === []) + @include('firefly-admin::_empty', [ + 'title' => 'No stores configured', + 'body' => 'Laravel ships a config/cache.php with several stores defined. If this is empty, the config file is missing.', + ]) + @else +
+
ClassStereotypeScopeNameImplements
{{ $bean['class'] ?? '' }}{{ $bean['stereotype'] ?? '' }}{{ $bean['scope'] ?? '' }}{{ $bean['name'] ?: '—' }} - {{ is_array($bean['interfaces'] ?? null) && $bean['interfaces'] !== [] ? implode(', ', $bean['interfaces']) : '—' }} + {{ Format::shortClass($class) }}{{ rtrim(Format::namespaceOf($class), '\\') }}{{ $bean['stereotype'] ?? '' }}{{ $bean['scope'] ?? '' }}{{ $bean['name'] ?: '—' }} + @php $interfaces = is_array($bean['interfaces'] ?? null) ? $bean['interfaces'] : []; @endphp + @forelse ($interfaces as $interface) +
{{ Format::shortClass((string) $interface) }}
+ @empty + — + @endforelse
+ + + @foreach ($rows as $row) + + + + + + @endforeach + +
StoreDriverDefault
{{ $row['name'] }}{{ $row['driver'] ?: '—' }}@if ($row['default'])default@endif
+
+ @endif +
+ +

This view is read-only. Evicting a cache from a dashboard is a destructive operation and + needs an authorization story this package does not have — use php artisan cache:clear.

+@endsection diff --git a/packages/admin/resources/views/conditions.blade.php b/packages/admin/resources/views/conditions.blade.php index a6beb25..c8f2598 100644 --- a/packages/admin/resources/views/conditions.blade.php +++ b/packages/admin/resources/views/conditions.blade.php @@ -2,61 +2,48 @@ @section('title', 'Conditions') @section('body') @php + use Firefly\Admin\Format; $positive = is_array($positiveMatches ?? null) ? $positiveMatches : []; $negative = is_array($negativeMatches ?? null) ? $negativeMatches : []; @endphp

Conditions

-

Conditional auto-configuration wires a capability only until you supply your own bean, then steps - aside. Everything under Backed off is a decision the framework made in your favour.

+

Auto-configuration wires a capability only until you supply your own bean, then steps aside. + Everything under Backed off is a decision the framework made in your favour.

-
-

Applied {{ count($positive) }}

- @include('firefly-admin::_filter', ['target' => 'pos-body', 'placeholder' => 'Filter applied…']) - @if ($positive === []) -

Nothing matched.

- @else -
- - - - @foreach ($positive as $row) - - - - - @endforeach - -
ClassCondition
{{ $row['class'] ?? '' }} - #[{{ class_basename($row['condition'] ?? '') }}] -
+
+ @foreach ([['Applied', $positive, 'pos-body'], ['Backed off', $negative, 'neg-body']] as [$title, $rows, $id]) +
+ @include('firefly-admin::_panel-head', [ + 'title' => $title, 'count' => count($rows), + 'filter' => $id, 'placeholder' => 'Filter…', + ]) + @if ($rows === []) + @include('firefly-admin::_empty', [ + 'title' => 'Nothing here', + 'body' => $title === 'Applied' + ? 'No condition matched — unusual, and worth checking that auto-configuration is discovering your packages.' + : 'No auto-configuration found a reason to stand down. Every capability is running its framework default.', + ]) + @else +
+ + + + @foreach ($rows as $row) + @php $class = is_string($row['class'] ?? null) ? $row['class'] : ''; @endphp + + + + + @endforeach + +
ClassCondition
{{ Format::shortClass($class) }}{{ rtrim(Format::namespaceOf($class), '\\') }}#[{{ Format::shortClass((string) ($row['condition'] ?? '')) }}]
+
+ @endif
- @endif -
- -
-

Backed off {{ count($negative) }}

- @include('firefly-admin::_filter', ['target' => 'neg-body', 'placeholder' => 'Filter backed off…']) - @if ($negative === []) -

Nothing backed off — no auto-configuration found a reason to stand down.

- @else -
- - - - @foreach ($negative as $row) - - - - - @endforeach - -
ClassCondition
{{ $row['class'] ?? '' }} - #[{{ class_basename($row['condition'] ?? '') }}] -
-
- @endif + @endforeach
@endsection diff --git a/packages/admin/resources/views/configprops.blade.php b/packages/admin/resources/views/configprops.blade.php new file mode 100644 index 0000000..968df75 --- /dev/null +++ b/packages/admin/resources/views/configprops.blade.php @@ -0,0 +1,63 @@ +@extends('firefly-admin::layout') +@section('title', 'Config properties') +@section('body') + @php + use Firefly\Admin\Format; + // The endpoint groups DTOs under a context key, mirroring Spring's /actuator/configprops. Flatten to + // one row per bound property so the table is scannable and filterable as a single list. + $rows = []; + foreach ($contexts as $context => $beans) { + if (! is_array($beans)) { continue; } + foreach ($beans as $name => $bean) { + if (! is_array($bean)) { continue; } + $prefix = is_string($bean['prefix'] ?? null) ? $bean['prefix'] : ''; + $properties = is_array($bean['properties'] ?? null) ? $bean['properties'] : []; + foreach ($properties as $key => $value) { + $rows[] = [ + 'class' => is_string($name) ? $name : '', + 'prefix' => $prefix, + 'key' => (string) $key, + 'value' => is_scalar($value) || $value === null + ? ($value === null ? 'null' : (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value)) + : json_encode($value, JSON_UNESCAPED_SLASHES), + ]; + } + } + } + @endphp + +
+

Config properties

+

Every #[ConfigProperties] DTO the application bound, with the values it actually + resolved — which is not always what the config file says, once relaxed binding and profiles apply.

+
+ +
+ @include('firefly-admin::_panel-head', [ + 'title' => 'Bound DTOs', 'count' => count($rows), + 'filter' => 'props-body', 'placeholder' => 'Filter by class, prefix or key…', + ]) + @if ($rows === []) + @include('firefly-admin::_empty', [ + 'title' => 'Nothing bound', + 'body' => 'Create one with php artisan make:firefly-config-properties, then re-run firefly:cache if this application boots compiled.', + ]) + @else +
+ + + + @foreach ($rows as $row) + + + + + + + @endforeach + +
ClassPrefixPropertyValue
{{ Format::shortClass($row['class']) }}{{ rtrim(Format::namespaceOf($row['class']), '\\') }}{{ $row['prefix'] ?: '—' }}{{ $row['key'] }}{{ $row['value'] }}
+
+ @endif +
+@endsection diff --git a/packages/admin/resources/views/env.blade.php b/packages/admin/resources/views/env.blade.php index 10f415b..09927ed 100644 --- a/packages/admin/resources/views/env.blade.php +++ b/packages/admin/resources/views/env.blade.php @@ -3,15 +3,20 @@ @section('body')

Environment

-

Resolved firefly.* configuration as this process sees it. Values whose key looks - secret are masked by the endpoint before they reach this page.

+

Resolved firefly.* configuration as this process sees it. Keys that look secret are + masked by the endpoint before they reach this page.

-

Configuration {{ count($env) }} keys

- @include('firefly-admin::_filter', ['target' => 'env-body', 'placeholder' => 'Filter by key or value…']) + @include('firefly-admin::_panel-head', [ + 'title' => 'Configuration', 'count' => count($env), + 'filter' => 'env-body', 'placeholder' => 'Filter by key or value…', + ]) @if ($env === []) -

Nothing set under firefly.

+ @include('firefly-admin::_empty', [ + 'title' => 'Nothing set', + 'body' => 'No firefly.* configuration is present. The skeleton ships a documented reference at config/firefly.php.', + ]) @else
@@ -19,8 +24,8 @@ @foreach ($env as $key => $value) - - + + @endforeach diff --git a/packages/admin/resources/views/health.blade.php b/packages/admin/resources/views/health.blade.php new file mode 100644 index 0000000..e8e9a59 --- /dev/null +++ b/packages/admin/resources/views/health.blade.php @@ -0,0 +1,51 @@ +@extends('firefly-admin::layout') +@section('title', 'Health') +@section('topchips') + {{ $aggregate }} +@endsection +@section('body') + @php use Firefly\Admin\Format; @endphp + +
+

Health

+

Every indicator this process registered, called directly so its details are visible here even + when the HTTP endpoint withholds them.

+
+ +
+ @include('firefly-admin::_panel-head', [ + 'title' => 'Indicators', 'count' => count($indicators), + 'filter' => 'health-body', 'placeholder' => 'Filter indicators…', + ]) + @if ($indicators === []) + @include('firefly-admin::_empty', [ + 'title' => 'No indicators registered', + 'body' => 'Implement Firefly\Actuator\Health\HealthIndicator and register it as a bean. The framework ships a ping indicator, a disk-space indicator, and an opt-in database indicator.', + ]) + @else +
+
{{ $key }}{{ $value }}{{ $key }}{{ $value }}
+ + + @foreach ($indicators as $indicator) + + + + + + @endforeach + +
IndicatorStatusDetails
{{ $indicator['name'] }}{{ $indicator['status'] }} + @forelse ($indicator['details'] as $key => $value) +
{{ $key }} {{ Format::detail((string) $key, $value) }}
+ @empty + — + @endforelse +
+
+ @endif +
+ +

The aggregate is the worst status any indicator reports. The HTTP endpoint answers 503 + when it is DOWN, which is what a load balancer reads.

+@endsection diff --git a/packages/admin/resources/views/http.blade.php b/packages/admin/resources/views/http.blade.php new file mode 100644 index 0000000..1417423 --- /dev/null +++ b/packages/admin/resources/views/http.blade.php @@ -0,0 +1,42 @@ +@extends('firefly-admin::layout') +@section('title', 'HTTP traffic') +@section('body') + @php use Firefly\Admin\Format; @endphp + +
+

HTTP traffic

+

The most recent requests this application served, newest first. Bodies and headers are never + recorded — that is how these views leak credentials.

+
+ +
+ @include('firefly-admin::_panel-head', [ + 'title' => 'Exchanges', 'count' => count($exchanges), + 'filter' => 'http-body', 'placeholder' => 'Filter by path, status or id…', + ]) + @if ($exchanges === []) + @include('firefly-admin::_empty', [ + 'title' => 'No exchanges recorded', + 'body' => 'Recording is off, or nothing has been served since this process started. Under PHP-FPM the buffer must be cache-backed to survive a request — see firefly.observability.httpexchanges.', + ]) + @else +
+ + + + @foreach ($exchanges as $exchange) + + + + + + + + + @endforeach + +
WhenMethodPathStatusTookCorrelation
{{ $exchange['timestamp'] > 0 ? Format::since($exchange['timestamp'], $now) : '—' }}{{ $exchange['method'] }}{{ $exchange['path'] }}{{ $exchange['status'] ?: '—' }}{{ $exchange['duration'] }}{{ $exchange['correlationId'] !== '' ? substr($exchange['correlationId'], 0, 8) : '—' }}
+
+ @endif +
+@endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index d92de28..074a8b7 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -1,10 +1,16 @@ {{-- - The dashboard shell. Inline CSS on purpose: a composer package cannot assume npm has run, and a - dashboard that needs a CDN at request time is useless in exactly the network-isolated environments where - you most want to look at one. System fonts for the same reason. + The dashboard shell. + + Inline CSS and system fonts on purpose: a composer package cannot assume npm has run, and a dashboard + that needs a CDN at request time is useless in exactly the network-isolated environments where you most + want to look at one. + + The chrome is deliberately NEUTRAL. Colour here means status — green is up, red is down, amber is + attention — so spending it on decoration would make a failing indicator compete with a heading for the + eye. The brand appears once, as the dot in the wordmark. --}} - + @@ -13,132 +19,377 @@ @yield('title', 'Admin') · {{ $settings->title }} -
+
+
+ {{ $settings->title }}admin + @hasSection('topchips') @yield('topchips') @endif + + + +
+ +
@yield('body')
-@stack('scripts') + + diff --git a/packages/admin/resources/views/loggers.blade.php b/packages/admin/resources/views/loggers.blade.php index 40dc2c7..ec8e262 100644 --- a/packages/admin/resources/views/loggers.blade.php +++ b/packages/admin/resources/views/loggers.blade.php @@ -12,20 +12,26 @@
-

Channels {{ count($channels) }}

+ @include('firefly-admin::_panel-head', [ + 'title' => 'Channels', 'count' => count($channels), + 'filter' => 'log-body', 'placeholder' => 'Filter channels…', + ]) @if ($channels === []) -

No channels configured under logging.channels.

+ @include('firefly-admin::_empty', [ + 'title' => 'No channels configured', + 'body' => 'Nothing is defined under logging.channels.', + ]) @else
- - + + @foreach ($channels as $name => $logger) @php $level = is_array($logger) && is_string($logger['configuredLevel'] ?? null) ? $logger['configuredLevel'] : 'INFO'; @endphp - - - + + diff --git a/packages/admin/resources/views/data-list.blade.php b/packages/admin/resources/views/data-list.blade.php index cb96660..1ca74ba 100644 --- a/packages/admin/resources/views/data-list.blade.php +++ b/packages/admin/resources/views/data-list.blade.php @@ -11,24 +11,13 @@ $identifier = $schema?->identifierColumn(); $base = $settings->url('data').'?resource='.urlencode($resource?->slug ?? ''); - // Carried onto every sort link and both pager links. A filter that survived neither would widen the - // listing back to every row the moment someone sorted it, which reads as rows appearing from nowhere. - $keepFilter = $listing->filter !== null ? '&'.$listing->filter->toQuery() : ''; - - /** - * Values arrive RAW: an Eloquent-backed row holds the driver's value, so a bool column can be int 1 - * and a json column a string. The column TYPE is the rendering hint — never the value's PHP type. - */ - $render = static function (mixed $value, ?DataColumn $column): string { - if ($value === null) { return '—'; } - $type = $column?->type ?? DataColumn::TYPE_STRING; - - return match ($type) { - DataColumn::TYPE_BOOL => ((int) $value) === 1 ? 'true' : 'false', - DataColumn::TYPE_JSON => is_string($value) ? $value : (string) json_encode($value, JSON_UNESCAPED_SLASHES), - default => is_scalar($value) ? (string) $value : (string) json_encode($value, JSON_UNESCAPED_SLASHES), - }; - }; + // Everything a link must carry to survive being clicked. A sort that dropped the filter would widen + // the listing back to every row, which reads as rows appearing from nowhere; a filter that dropped + // the page size would silently resize the table under the reader. + $keepFilter = $listing->filterQuery() === '' ? '' : '&'.$listing->filterQuery(); + $keepSearch = $listing->search !== null ? '&q='.urlencode($listing->search) : ''; + $keepSize = '&size='.$listing->perPage; + $keepSort = $listing->sort !== null ? '&sort='.urlencode($listing->sort).'&dir='.$listing->direction : ''; @endphp
@@ -37,14 +26,26 @@ @if ($resource?->entityClass !== null){{ Format::shortClass($resource->entityClass) }}@endif @if ($resource?->table)· table {{ $resource->table }}@endif · all resources + @if ($relations !== []) + · related: + @foreach ($relations as $relation) + @if ($relation->navigable() && $relation->toMany === false) + {{ $relation->shortRelated() }}@if (! $loop->last), @endif + @else + {{ $relation->shortRelated() ?: $relation->kind }}@if (! $loop->last), @endif + @endif + @endforeach + @endif

- @if ($listing->filter !== null) + @if ($listing->filters !== [])

- Showing only rows where {{ $listing->filter->column }} is - {{ $listing->filter->value }}. - Show all {{ strtolower($resource?->label ?? 'records') }} + Showing only rows where + @foreach ($listing->filters as $filter) + {{ $filter->describe() }}@if (! $loop->last) and @endif + @endforeach + · clear

@endif @@ -60,35 +61,86 @@ ]) @else + + {{-- THE FILTER BAR. One row per condition, each a column, a comparison and a value. It is a GET form, + so every filtered view is a URL an operator can bookmark, paste into a ticket or hand to someone + else — which is most of what a data explorer is for. --}} +
filters !== []) open @endif> + + Filter + + {{ count($listing->filters) ?: 'none' }}{{ count($listing->filters) ? ' active' : '' }} + +
+ + @if ($listing->sort)@endif + + + @if ($listing->search !== null)@endif + + @php $rows = $listing->filters; $rows[] = null; @endphp + @foreach ($rows as $row) +
+ + + +
+ @endforeach + +
+ + @if ($listing->filters !== []) + Clear + @endif +
+ +
+

Records

@if ($schema->searchable() !== []) -
+ @if ($listing->sort)@endif - {{-- Searching inside a relation's listing NARROWS it; without these the search box - would silently drop the relation and search the whole table. --}} - @if ($listing->filter !== null) - - - @endif + + {{-- Searching inside a filtered listing NARROWS it; without these the search box + would silently drop the filter and search the whole table. --}} + @foreach ($listing->filters as $filter) + + + + @endforeach @endif + @if ($writable && $resource?->isEloquentBacked()) + New record + @endif {{ number_format($listing->total) }} total
@if ($listing->isEmpty()) @include('firefly-admin::_empty', [ - 'title' => $listing->search !== null ? 'Nothing matches that search' : 'No records yet', - 'body' => $listing->search !== null ? 'Try a shorter term, or clear the search.' : 'This resource has no rows.', + 'title' => $listing->search !== null || $listing->filters !== [] ? 'Nothing matches' : 'No records yet', + 'body' => $listing->search !== null || $listing->filters !== [] + ? 'Loosen a condition, or clear them all.' + : 'This resource has no rows.', ]) @else
-
ChannelConfigured levelSet
ChannelLevelSet
{{ $name }}{{ $level }} + {{ $name }}{{ $level }}
@csrf @@ -46,7 +52,7 @@ {{-- - Honesty about what this control does. LoggersEndpoint::setLevel() reaches into the Monolog handlers of + Honesty about what the control does. LoggersEndpoint::setLevel() reaches into the Monolog handlers of the CURRENT process, so under PHP-FPM the change lasts exactly as long as this request. Saying so is better than letting someone believe they have changed production logging. --}} diff --git a/packages/admin/resources/views/mappings.blade.php b/packages/admin/resources/views/mappings.blade.php index ecf3c45..ed4d27b 100644 --- a/packages/admin/resources/views/mappings.blade.php +++ b/packages/admin/resources/views/mappings.blade.php @@ -1,28 +1,36 @@ @extends('firefly-admin::layout') -@section('title', 'Mappings') +@section('title', 'Routes') @section('body') + @php use Firefly\Admin\Format; @endphp +
-

Mappings

-

The compiled route table the dispatcher serves from — discovered from your +

Routes

+

The compiled route table the dispatcher serves from, discovered from your #[RestController] and #[Controller] classes.

-

Routes {{ count($mappings) }}

- @include('firefly-admin::_filter', ['target' => 'map-body', 'placeholder' => 'Filter by path or handler…']) + @include('firefly-admin::_panel-head', [ + 'title' => 'Mappings', 'count' => count($mappings), + 'filter' => 'map-body', 'placeholder' => 'Filter by path or handler…', + ]) @if ($mappings === []) -

No routes mapped.

+ @include('firefly-admin::_empty', [ + 'title' => 'No routes mapped', + 'body' => 'Create one with php artisan make:firefly-controller, then re-run firefly:cache if this application boots compiled.', + ]) @else
@foreach ($mappings as $route) + @php $handler = is_string($route['handler'] ?? null) ? $route['handler'] : ''; @endphp - - - - + + + + @endforeach diff --git a/packages/admin/resources/views/metrics.blade.php b/packages/admin/resources/views/metrics.blade.php index 079093d..7703e96 100644 --- a/packages/admin/resources/views/metrics.blade.php +++ b/packages/admin/resources/views/metrics.blade.php @@ -1,35 +1,48 @@ @extends('firefly-admin::layout') @section('title', 'Metrics') @section('body') + @php + $peak = 0.0; + foreach ($metrics as $metric) { foreach ($metric['rows'] as $row) { $peak = max($peak, abs($row['value'])); } } + @endphp +

Metrics

-

Counters, timers and gauges recorded through the meter registry.

+

Counters, timers and gauges recorded through the meter registry. Units are inferred from the + meter name, the same convention the Prometheus exposition uses.

-

Meters {{ count($metrics) }}

+ @include('firefly-admin::_panel-head', [ + 'title' => 'Meters', 'count' => count($metrics), + 'filter' => 'metrics-body', 'placeholder' => 'Filter meters…', + ]) @if ($metrics === []) -

Nothing recorded yet. Note that the default registry keeps meters in process - memory, so under PHP-FPM a scrape only ever sees its own request — set - firefly.observability.metrics.store to a cache store to accumulate across workers.

+ @include('firefly-admin::_empty', [ + 'title' => 'Nothing recorded yet', + 'body' => 'The default registry keeps meters in process memory, so under PHP-FPM a page only ever sees its own request. Set firefly.observability.metrics.store to a cache store to accumulate across workers.', + ]) @else - @include('firefly-admin::_filter', ['target' => 'metrics-body', 'placeholder' => 'Filter by meter name…'])
MethodPathHandlerName
{{ $route['httpMethod'] ?? '' }}{{ $route['path'] ?? '' }}{{ $route['handler'] ?? '' }}{{ $route['name'] ?: '—' }}{{ $route['httpMethod'] ?? '' }}{{ $route['path'] ?? '' }}{{ Format::shortClass($handler) }}{{ rtrim(Format::namespaceOf($handler), '\\') }}{{ $route['name'] ?: '—' }}
- + @foreach ($metrics as $metric) - @php $measurements = is_array($metric['measurements']) ? $metric['measurements'] : []; @endphp - @forelse ($measurements as $measurement) + @forelse ($metric['rows'] as $row) - - - + + + + @empty - - + + @endforelse @endforeach diff --git a/packages/admin/resources/views/missing.blade.php b/packages/admin/resources/views/missing.blade.php index 24674ac..0868d03 100644 --- a/packages/admin/resources/views/missing.blade.php +++ b/packages/admin/resources/views/missing.blade.php @@ -1,8 +1,11 @@ @extends('firefly-admin::layout') @section('title', 'Not found') @section('body') -
-

No such page

-

The dashboard has no page called {{ $slug }}. Pick one from the menu.

+

No such page

+
+ @include('firefly-admin::_empty', [ + 'title' => 'The dashboard has no page called “'.$slug.'”', + 'body' => 'Pick one from the menu on the left.', + ])
@endsection diff --git a/packages/admin/resources/views/overview.blade.php b/packages/admin/resources/views/overview.blade.php index c09d613..b7e9b88 100644 --- a/packages/admin/resources/views/overview.blade.php +++ b/packages/admin/resources/views/overview.blade.php @@ -1,98 +1,154 @@ @extends('firefly-admin::layout') @section('title', 'Overview') + +@section('topchips') + {{ $aggregate }} + {{ $bootMode }} +@endsection + @section('body') @php - $status = is_string($health['status'] ?? null) ? $health['status'] : 'UNKNOWN'; - $components = is_array($health['components'] ?? null) ? $health['components'] : []; - $positive = is_array($conditions['positiveMatches'] ?? null) ? $conditions['positiveMatches'] : []; - $negative = is_array($conditions['negativeMatches'] ?? null) ? $conditions['negativeMatches'] : []; + use Firefly\Admin\Format; + $down = array_values(array_filter($indicators, fn ($i) => $i['status'] !== 'UP')); @endphp

Overview

-

What this process wired at boot, and how it is doing now.

+

Health, runtime and what this process wired at boot.

-
-
+
+
Health
-
{{ $status }}
-
-
Beans
{{ count($beans) }}
-
Routes
{{ count($mappings) }}
-
Auto-config met
{{ count($positive) }}
-
Backed off
{{ count($negative) }}
-
-
Boot
-
{{ $bootMode }}
+
{{ $aggregate }}
+
Indicators
{{ count($indicators) }}@if ($down !== []){{ count($down) }} down@endif
+ + +
Auto-config
{{ count($positive) }}{{ count($negative) }} off
+
Scheduled
{{ count($tasks) }}
+
Boot
{{ $bootMode }}
@if ($bootMode !== 'compiled') -

This process scanned its classes by reflection at startup. That is right while - developing; run php artisan firefly:cache before deploying.

+

This process scanned its classes by reflection at startup — right while developing. + Run php artisan firefly:cache before deploying for a zero-reflection boot.

@endif - @if ($components === []) -
-

Health indicators

-

The health endpoint is reporting its aggregate status only. Set - firefly.management.endpoint.health.show-details to always to see each - indicator and its details here.

+
+
+ @include('firefly-admin::_panel-head', ['title' => 'Health indicators', 'count' => count($indicators)]) + @if ($indicators === []) + @include('firefly-admin::_empty', [ + 'title' => 'No indicators registered', + 'body' => 'Implement Firefly\Actuator\Health\HealthIndicator and register it as a bean to see it here.', + ]) + @else +
+
MeterStatisticValue
MeterStatisticValueRelative
{{ $loop->first ? $metric['name'] : '' }}{{ $measurement['statistic'] ?? '' }}{{ is_numeric($measurement['value'] ?? null) ? rtrim(rtrim(number_format((float) $measurement['value'], 4, '.', ''), '0'), '.') : '—' }}{{ $loop->first ? $metric['name'] : '' }}{{ $row['statistic'] }}{{ $row['display'] }} + {{-- One shared scale across every meter: the bar answers "which of these is + large", which is the only comparison a mixed-unit list supports. --}} +
+
{{ $metric['name'] }}no measurements{{ $metric['name'] }}no measurements
+ + @foreach ($indicators as $indicator) + + + + + + @endforeach + +
{{ $indicator['name'] }}{{ $indicator['status'] }} + @if ($indicator['details'] === []) + — + @else + {{ implode(' · ', array_map( + fn ($k, $v) => $k.' '.Format::detail((string) $k, $v), + array_keys($indicator['details']), $indicator['details'] + )) }} + @endif +
+
+ @endif
- @else -
-

Health indicators {{ count($components) }}

-
- - - - @foreach ($components as $name => $component) - @php $s = is_array($component) && is_string($component['status'] ?? null) ? $component['status'] : 'UNKNOWN'; @endphp - - - - - - @endforeach - -
IndicatorStatusDetails
{{ $name }}{{ $s }} - @php $d = is_array($component) && is_array($component['details'] ?? null) ? $component['details'] : []; @endphp - {{ $d === [] ? '—' : json_encode($d, JSON_UNESCAPED_SLASHES) }} -
-
+ +
+ @include('firefly-admin::_panel-head', ['title' => 'Runtime', 'count' => count($info)]) + @if ($info === []) + @include('firefly-admin::_empty', [ + 'title' => 'Nothing published', + 'body' => 'No InfoContributor has contributed anything. Set firefly.management.info.app, or register your own contributor.', + ]) + @else +
+ + + @foreach ($info as $key => $value) + + + + + @endforeach + +
{{ $key }}{{ $value }}
+
+ @endif
- @endif +
+ +
+ @if ($exchanges !== []) +
+ @include('firefly-admin::_panel-head', ['title' => 'Recent requests', 'count' => count($exchanges)]) +
+ + + @foreach ($exchanges as $exchange) + + + + + + + @endforeach + +
{{ $exchange['method'] }}{{ $exchange['path'] }} + {{ $exchange['status'] }} + {{ $exchange['duration'] }}
+
+ +
+ @endif -
-

Build information /actuator/info

- @if ($info === []) -

No InfoContributor has published anything. Set - firefly.management.info.app, or register your own contributor.

- @else -
- - - @foreach ($info as $key => $value) - - - - - @endforeach - -
{{ $key }}{{ is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_SLASHES) }}
+ @if ($metrics !== []) +
+ @include('firefly-admin::_panel-head', ['title' => 'Metrics', 'count' => count($metrics)]) +
+ + + @foreach (array_slice($metrics, 0, 8) as $metric) + + + + + @endforeach + +
{{ $metric['name'] }}{{ $metric['rows'][0]['display'] ?? '—' }}
+
+
@endif
-
-

Registered endpoints {{ count($endpoints) }}

-
+
+ @include('firefly-admin::_panel-head', ['title' => 'Registered endpoints', 'count' => count($endpoints)]) +
@foreach ($endpoints as $id) - {{ $id }} + {{ $id }} @endforeach
-

These are readable here in-process. Which of them answer over - HTTP is a separate decision — see firefly.management.endpoints.web.exposure.include.

+

Readable here in-process. Which of them answer + over HTTP is a separate decision — see firefly.management.endpoints.web.exposure.include.

@endsection diff --git a/packages/admin/resources/views/scheduled.blade.php b/packages/admin/resources/views/scheduled.blade.php index d2c31e1..593a552 100644 --- a/packages/admin/resources/views/scheduled.blade.php +++ b/packages/admin/resources/views/scheduled.blade.php @@ -1,6 +1,8 @@ @extends('firefly-admin::layout') @section('title', 'Scheduled') @section('body') + @php use Firefly\Admin\Format; @endphp +

Scheduled tasks

Methods registered by #[Scheduled], with the cron expression or fixed interval that @@ -8,21 +10,25 @@

-

Tasks {{ count($tasks) }}

+ @include('firefly-admin::_panel-head', ['title' => 'Tasks', 'count' => count($tasks)]) @if ($tasks === []) -

Nothing scheduled. Add #[Scheduled] to a bean method.

+ @include('firefly-admin::_empty', [ + 'title' => 'Nothing scheduled', + 'body' => 'Add #[Scheduled] to a bean method, then run the scheduler with php artisan schedule:work.', + ]) @else
@foreach ($tasks as $task) + @php $runnable = is_string($task['runnable'] ?? null) ? $task['runnable'] : ''; @endphp - - - - - + + + + + @endforeach diff --git a/packages/admin/resources/views/unavailable.blade.php b/packages/admin/resources/views/unavailable.blade.php index 83f21f2..5f84f95 100644 --- a/packages/admin/resources/views/unavailable.blade.php +++ b/packages/admin/resources/views/unavailable.blade.php @@ -1,12 +1,11 @@ @extends('firefly-admin::layout') @section('title', 'Unavailable') @section('body') -
-

{{ $page->label }} is not available

-

{{ $page->blurb }}

+

{{ $page->label }}

{{ $page->blurb }}

+
+ @include('firefly-admin::_empty', [ + 'title' => 'This page has no endpoint to read', + 'body' => 'It renders the '.$page->requires.' actuator endpoint, which this process has not registered or has switched off. Check firefly.management.endpoint.'.$page->requires.'.enabled, and that the package providing it is installed.', + ])
-

This page reads the {{ $page->requires }} actuator endpoint, which this - process has not registered or has switched off. Check - firefly.management.endpoint.{{ $page->requires }}.enabled, and that the package providing it - is installed.

@endsection diff --git a/packages/admin/src/AdminEndpointReader.php b/packages/admin/src/AdminEndpointReader.php index 8814e55..9640ad7 100644 --- a/packages/admin/src/AdminEndpointReader.php +++ b/packages/admin/src/AdminEndpointReader.php @@ -6,7 +6,9 @@ use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Actuator\Endpoint\EndpointRequest; +use Firefly\Actuator\Health\HealthContributorRegistry; use Firefly\Config\Config; +use Illuminate\Contracts\Container\Container; use Throwable; /** @@ -27,8 +29,58 @@ public function __construct( private ActuatorRegistry $registry, private Config $config, + private ?Container $container = null, ) {} + /** + * Every health indicator with its own status and details, read from the CONTRIBUTOR REGISTRY rather than + * through the health endpoint. + * + * The endpoint withholds per-indicator details unless + * `firefly.management.endpoint.health.show-details` is `always`, and that default is right: it protects + * anonymous HTTP callers from learning your database host from a failed connection. The dashboard is not + * an anonymous HTTP caller — it is already rendering beans and env in-process — so applying the HTTP + * disclosure policy to it produced a Health panel whose entire content was an apology telling the + * operator to go and change a config key. It reads the indicators directly instead. + * + * Each indicator is called in isolation: one that throws is reported DOWN with the reason, exactly as + * HealthEndpoint's own fail-safe read does, so a broken indicator degrades its own row and nothing else. + * + * @return list}> + */ + public function healthIndicators(): array + { + if ($this->container === null || ! $this->container->bound(HealthContributorRegistry::class)) { + return []; + } + + try { + $registry = $this->container->get(HealthContributorRegistry::class); + } catch (Throwable) { + return []; + } + + $indicators = []; + foreach ($registry->all() as $name => $indicator) { + try { + $health = $indicator->health(); + $indicators[] = [ + 'name' => $name, + 'status' => $health->status->value, + 'details' => $health->details, + ]; + } catch (Throwable $e) { + $indicators[] = [ + 'name' => $name, + 'status' => 'DOWN', + 'details' => ['error' => $e::class, 'message' => $e->getMessage()], + ]; + } + } + + return $indicators; + } + /** * The endpoint ids that are registered AND not switched off, in registration order. * diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php index 5fe328e..7b7fe86 100644 --- a/packages/admin/src/Boot/AdminRouteRegistrar.php +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -59,6 +59,7 @@ public function run(BootContext $context): void $container->singleton(AdminEndpointReader::class, static fn (): AdminEndpointReader => new AdminEndpointReader( $container->make(ActuatorRegistry::class), $context->config, + $container, )); $container->singleton(AdminAction::class, static fn (): AdminAction => new AdminAction( $container->make(AdminSettings::class), diff --git a/packages/admin/src/Format.php b/packages/admin/src/Format.php new file mode 100644 index 0000000..a06b01c --- /dev/null +++ b/packages/admin/src/Format.php @@ -0,0 +1,160 @@ += 1024 && $unit < count($units) - 1) { + $value /= 1024; + $unit++; + } + + $decimals = $unit === 0 ? 0 : ($value < 10 ? 1 : 0); + + return ($bytes < 0 ? '-' : '').number_format($value, $decimals).' '.$units[$unit]; + } + + /** A duration given in SECONDS, rendered at whatever scale keeps it readable. */ + public static function duration(float $seconds): string + { + return match (true) { + $seconds < 0.001 => number_format($seconds * 1_000_000, 0).' µs', + $seconds < 1 => number_format($seconds * 1000, $seconds < 0.1 ? 1 : 0).' ms', + $seconds < 60 => number_format($seconds, 2).' s', + $seconds < 3600 => floor($seconds / 60).'m '.number_format(fmod($seconds, 60), 0).'s', + default => floor($seconds / 3600).'h '.floor(fmod($seconds, 3600) / 60).'m', + }; + } + + public static function milliseconds(float $ms): string + { + return self::duration($ms / 1000); + } + + /** A plain count, thousands-separated so six figures are scannable. */ + public static function count(float $value): string + { + return $value === floor($value) && abs($value) < 1e15 + ? number_format($value) + : rtrim(rtrim(number_format($value, 4, '.', ','), '0'), '.'); + } + + /** + * Formats a measurement by inferring its unit from the METER NAME, the way a Prometheus consumer does. + * There is no unit metadata on the wire — `php_memory_peak_bytes` says what it is in its own name, which + * is exactly the convention the exposition format relies on. + */ + public static function measurement(string $meterName, float $value): string + { + $name = strtolower($meterName); + + foreach (self::BYTE_SUFFIXES as $suffix) { + if (str_ends_with($name, $suffix)) { + return self::bytes($value); + } + } + + foreach (self::SECOND_SUFFIXES as $suffix) { + if (str_ends_with($name, $suffix)) { + return self::duration($value); + } + } + + return self::count($value); + } + + /** "3 minutes ago" for a unix timestamp, or an ISO instant when it is older than a day. */ + public static function since(float $timestamp, float $now): string + { + $delta = max(0.0, $now - $timestamp); + + return match (true) { + $delta < 2 => 'just now', + $delta < 60 => (int) $delta.'s ago', + $delta < 3600 => (int) ($delta / 60).'m ago', + $delta < 86400 => (int) ($delta / 3600).'h ago', + default => date('Y-m-d H:i', (int) $timestamp), + }; + } + + /** The share one value takes of a maximum, clamped to 0..100 for a bar width. */ + public static function percent(float $value, float $max): float + { + if ($max <= 0) { + return 0.0; + } + + return max(0.0, min(100.0, $value / $max * 100)); + } + + /** + * One health-indicator detail, rendered for a human. + * + * Indicator details are a free-form map, so there is no unit metadata to read — but the keys the shipped + * indicators use are conventional, and a disk-space indicator reporting `total=994610155520` is a number + * nobody can parse at a glance. Byte-ish keys are formatted as sizes; a filesystem path is elided from + * the LEFT, because the tail of a path is the part that identifies it. + */ + public static function detail(string $key, mixed $value): string + { + $name = strtolower($key); + + if (is_numeric($value) && in_array($name, ['total', 'free', 'used', 'peak', 'limit', 'threshold', 'available', 'size'], true)) { + return self::bytes((float) $value); + } + + if (is_string($value) && in_array($name, ['path', 'directory', 'dir', 'file'], true)) { + return self::elide($value, 44); + } + + return match (true) { + is_bool($value) => $value ? 'true' : 'false', + $value === null => 'null', + is_scalar($value) => (string) $value, + default => (string) json_encode($value, JSON_UNESCAPED_SLASHES), + }; + } + + /** Keeps the TAIL of an over-long string, which for a path or a class name is the identifying half. */ + public static function elide(string $value, int $max): string + { + return strlen($value) <= $max ? $value : '…'.substr($value, -($max - 1)); + } + + /** A short, readable class name with its namespace kept as a separate, dimmable prefix. */ + public static function shortClass(string $fqcn): string + { + $position = strrpos($fqcn, '\\'); + + return $position === false ? $fqcn : substr($fqcn, $position + 1); + } + + public static function namespaceOf(string $fqcn): string + { + $position = strrpos($fqcn, '\\'); + + return $position === false ? '' : substr($fqcn, 0, $position + 1); + } +} diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 5600294..eef64d9 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -6,6 +6,7 @@ use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; +use Firefly\Admin\Format; use Firefly\Context\Scan\AppScan; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\View\Factory as ViewFactory; @@ -17,9 +18,9 @@ /** * The single invokable behind every dashboard page. * - * Each page is a view over one ActuatorEndpoint's payload, read in-process (see AdminEndpointReader). The - * action's only jobs are to pick the page, collect its data, and hand both to Blade — the views hold no - * logic beyond formatting, so the shapes here are the shapes the actuator actually returns. + * Each page is a view over one ActuatorEndpoint's payload, read in-process (see AdminEndpointReader). This + * class picks the page, collects its data in the shape the view wants, and renders — the views hold no logic + * beyond formatting, so the array shapes here are the shapes the actuator actually returns. */ final readonly class AdminAction { @@ -41,90 +42,124 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse } if ($current === null) { - return new Response($this->render('missing', ['slug' => $slug]), 404, ['Content-Type' => 'text/html; charset=UTF-8']); + return $this->html($this->render('missing', ['slug' => $slug]), 404); } if ($current->requires !== null && ! $this->reader->has($current->requires)) { - return new Response($this->render('unavailable', ['page' => $current]), 404, ['Content-Type' => 'text/html; charset=UTF-8']); + return $this->html($this->render('unavailable', ['page' => $current]), 404); } if ($slug === 'loggers' && $request->isMethod('POST')) { return $this->setLoggerLevel($request); } - return new Response( - $this->render($slug === '' ? 'overview' : $slug, $this->data($slug)), - 200, - ['Content-Type' => 'text/html; charset=UTF-8'], - ); + return $this->html($this->render($slug === '' ? 'overview' : $slug, $this->data($slug), $current), 200); } /** @return array */ private function data(string $slug): array { - /** @var array $data */ - $data = match ($slug) { - '' => [ - 'health' => $this->payload('health'), - 'info' => $this->payload('info'), - 'beans' => $this->listOf('beans', 'beans'), - 'conditions' => $this->payload('conditions'), - 'mappings' => $this->listOf('mappings', 'mappings'), - 'bootMode' => AppScan::cachedFile($this->container, AppScan::ROUTES) !== null ? 'compiled' : 'scanned', - 'endpoints' => $this->reader->available(), - ], + return match ($slug) { + '' => $this->overview(), + 'health' => ['indicators' => $this->reader->healthIndicators(), 'aggregate' => $this->aggregateStatus()], + 'metrics' => ['metrics' => $this->metrics()], + 'http' => ['exchanges' => $this->exchanges()], 'beans' => ['beans' => $this->listOf('beans', 'beans')], 'conditions' => $this->payload('conditions') + ['positiveMatches' => [], 'negativeMatches' => []], 'mappings' => ['mappings' => $this->listOf('mappings', 'mappings')], 'scheduled' => ['tasks' => $this->listOf('scheduledtasks', 'tasks')], - 'metrics' => ['metrics' => $this->metrics()], - 'loggers' => $this->payload('loggers') + ['levels' => [], 'loggers' => []], 'env' => ['env' => $this->flatten($this->subArray($this->payload('env'), 'firefly'), 'firefly')], + 'configprops' => ['contexts' => $this->payload('configprops')], + 'caches' => ['caches' => $this->payload('caches')], + 'loggers' => $this->payload('loggers') + ['levels' => [], 'loggers' => []], default => [], }; - - return $data; } /** - * An endpoint's payload as a string-keyed array — the shape every actuator endpoint returns. + * The overview is the page an operator leaves open, so it answers the three questions that matter + * without a click: is it healthy, what is it doing, and what did it wire. * * @return array */ - private function payload(string $id): array + private function overview(): array { - /** @var array $body */ - $body = $this->reader->read($id) ?? []; + $indicators = $this->reader->healthIndicators(); + $conditions = $this->payload('conditions'); - return $body; + return [ + 'aggregate' => $this->aggregateStatus(), + 'indicators' => $indicators, + 'info' => $this->runtime(), + 'beans' => $this->listOf('beans', 'beans'), + 'mappings' => $this->listOf('mappings', 'mappings'), + 'positive' => $this->subArray($conditions, 'positiveMatches'), + 'negative' => $this->subArray($conditions, 'negativeMatches'), + 'tasks' => $this->listOf('scheduledtasks', 'tasks'), + 'metrics' => $this->metrics(), + 'exchanges' => array_slice($this->exchanges(), 0, 8), + 'bootMode' => AppScan::cachedFile($this->container, AppScan::ROUTES) !== null ? 'compiled' : 'scanned', + 'endpoints' => $this->reader->available(), + ]; } /** - * One list-valued key out of an endpoint's payload (e.g. beans => 'beans', mappings => 'mappings'). + * /actuator/info flattened to dotted keys and formatted for reading. * - * @return array + * Contributors publish nested maps, so rendering the top level only produced cells containing raw JSON + * — `{"used":2097152,"peak":2097152}` where an operator wants `2.0 MB`. Flattening gives one row per + * fact, and the byte-ish keys the runtime contributor uses are formatted as sizes. + * + * @return array */ - private function listOf(string $id, string $key): array + private function runtime(): array { - return $this->subArray($this->payload($id), $key); + $flat = $this->flatten($this->payload('info'), 'info'); + + $rows = []; + foreach ($flat as $key => $value) { + $leaf = substr($key, strrpos($key, '.') + 1); + $rows[substr($key, strlen('info.'))] = is_numeric($value) + ? Format::detail($leaf, $value + 0) + : $value; + } + + return $rows; } /** - * @param array $payload - * @return array + * The worst status any indicator reports — the same aggregation the health endpoint performs, computed + * here so the page shows a status even when the endpoint withholds its components. */ - private function subArray(array $payload, string $key): array + private function aggregateStatus(): string { - $value = $payload[$key] ?? []; + $body = $this->payload('health'); + $status = $body['status'] ?? null; + if (is_string($status) && $status !== '') { + return $status; + } - return is_array($value) ? $value : []; + $worst = 'UNKNOWN'; + foreach ($this->reader->healthIndicators() as $indicator) { + if ($indicator['status'] === 'DOWN') { + return 'DOWN'; + } + if ($indicator['status'] === 'UP' && $worst === 'UNKNOWN') { + $worst = 'UP'; + } + } + + return $worst; } /** * The metrics index returns names only, so each name is read back for its measurements — N in-process - * calls, which is the right trade for a dashboard and keeps MetricsEndpoint's contract untouched. + * calls, the right trade for a dashboard, and it keeps MetricsEndpoint's contract untouched. * - * @return list}> + * Each measurement is pre-formatted here (bytes as MB, seconds as ms) because the view must not be + * doing arithmetic, and the JSON surface must keep returning raw numbers for Prometheus. + * + * @return list}> */ private function metrics(): array { @@ -134,16 +169,56 @@ private function metrics(): array continue; } - $detail = $this->reader->read('metrics', [$name]); /** @var array $detail */ - $detail = is_array($detail) ? $detail : []; + $detail = $this->reader->read('metrics', [$name]) ?? []; + + $rows = []; + foreach ($this->subArray($detail, 'measurements') as $measurement) { + if (! is_array($measurement)) { + continue; + } + $value = $measurement['value'] ?? null; + $statistic = $measurement['statistic'] ?? ''; + $rows[] = [ + 'statistic' => is_string($statistic) ? $statistic : '', + 'value' => is_numeric($value) ? (float) $value : 0.0, + 'display' => is_numeric($value) ? Format::measurement($name, (float) $value) : '—', + ]; + } - $metrics[] = ['name' => $name, 'measurements' => $this->subArray($detail, 'measurements')]; + $metrics[] = ['name' => $name, 'rows' => $rows]; } return $metrics; } + /** + * Recent HTTP exchanges, newest first, with each duration pre-formatted. + * + * @return list> + */ + private function exchanges(): array + { + $rows = []; + foreach ($this->subArray($this->payload('httpexchanges'), 'exchanges') as $exchange) { + if (! is_array($exchange)) { + continue; + } + + $duration = $exchange['durationMs'] ?? $exchange['duration'] ?? null; + $rows[] = [ + 'method' => is_string($exchange['method'] ?? null) ? $exchange['method'] : '', + 'path' => is_string($exchange['path'] ?? null) ? $exchange['path'] : '', + 'status' => is_numeric($exchange['status'] ?? null) ? (int) $exchange['status'] : 0, + 'duration' => is_numeric($duration) ? Format::milliseconds((float) $duration) : '—', + 'correlationId' => is_string($exchange['correlationId'] ?? null) ? $exchange['correlationId'] : '', + 'timestamp' => is_numeric($exchange['timestamp'] ?? null) ? (float) $exchange['timestamp'] : 0.0, + ]; + } + + return $rows; + } + private function setLoggerLevel(Request $request): RedirectResponse { $name = $request->input('logger'); @@ -156,9 +231,35 @@ private function setLoggerLevel(Request $request): RedirectResponse return new RedirectResponse($this->settings->url('loggers')); } + /** @return array */ + private function payload(string $id): array + { + /** @var array $body */ + $body = $this->reader->read($id) ?? []; + + return $body; + } + + /** @return array */ + private function listOf(string $id, string $key): array + { + return $this->subArray($this->payload($id), $key); + } + /** - * Flattens the nested firefly.* config into dotted keys, which is how a developer looks a key up and how - * every other part of the framework names one. + * @param array $payload + * @return array + */ + private function subArray(array $payload, string $key): array + { + $value = $payload[$key] ?? []; + + return is_array($value) ? $value : []; + } + + /** + * Flattens nested firefly.* config into dotted keys — how a developer looks a key up, and how every + * other part of the framework names one. * * @param array $values * @return array @@ -193,16 +294,24 @@ private function scalar(mixed $value): string } /** @param array $data */ - private function render(string $view, array $data): string + private function render(string $view, array $data, ?AdminPage $page = null): string { return $this->views->make('firefly-admin::'.$view, [ ...$data, 'settings' => $this->settings, 'nav' => $this->nav(), - 'active' => $view === 'overview' ? '' : $view, + 'groups' => AdminPage::groups(), + 'active' => $page instanceof AdminPage ? $page->slug : ($view === 'overview' ? '' : $view), + 'page' => $data['page'] ?? $page, + 'now' => microtime(true), ])->render(); } + private function html(string $body, int $status): SymfonyResponse + { + return new Response($body, $status, ['Content-Type' => 'text/html; charset=UTF-8']); + } + /** @return list */ private function nav(): array { diff --git a/packages/admin/src/Web/AdminPage.php b/packages/admin/src/Web/AdminPage.php index 6058c76..5ca6630 100644 --- a/packages/admin/src/Web/AdminPage.php +++ b/packages/admin/src/Web/AdminPage.php @@ -5,19 +5,32 @@ namespace Firefly\Admin\Web; /** - * One page of the dashboard: its slug, its nav label, and the actuator endpoint it needs. + * One page of the dashboard: its slug, nav label, group, the actuator endpoint it needs, and a one-line + * blurb used as the page's own subtitle. * - * `requires` is the endpoint id a page cannot render without. The nav hides a page whose endpoint is absent - * or switched off, rather than offering a link that lands on an apology — the actuator's own endpoints are - * conditional (metrics disappears when firefly.observability.metrics.enabled is false), so the nav has to be - * built from what this process actually registered. + * `requires` is the endpoint id a page cannot render without. The nav HIDES a page whose endpoint is absent + * or switched off rather than offering a link that lands on an apology — the actuator's endpoints are + * conditional (metrics disappears when firefly.observability.metrics.enabled is false, configprops and + * httpexchanges only exist if those packages are installed), so the menu has to be built from what this + * process actually registered. + * + * `group` exists because a flat list of eleven links is a worse menu than three short ones. The grouping is + * the operator's mental model, not the package layout: what is it doing right now, what did it wire at boot, + * and how is it configured. */ final readonly class AdminPage { + public const GROUP_RUNTIME = 'Runtime'; + + public const GROUP_WIRING = 'Wiring'; + + public const GROUP_CONFIG = 'Configuration'; + public function __construct( public string $slug, public string $label, public ?string $requires, + public string $group, public string $blurb, ) {} @@ -25,14 +38,42 @@ public function __construct( public static function all(): array { return [ - new self('', 'Overview', null, 'Health, build information and what this process wired at boot.'), - new self('beans', 'Beans', 'beans', 'Every bean the container registered, with its stereotype and scope.'), - new self('conditions', 'Conditions', 'conditions', 'Which auto-configurations applied, and which backed off because you supplied your own.'), - new self('mappings', 'Mappings', 'mappings', 'The compiled route table the dispatcher serves from.'), - new self('scheduled', 'Scheduled', 'scheduledtasks', 'Tasks registered by #[Scheduled], with their cron or fixed rate.'), - new self('metrics', 'Metrics', 'metrics', 'Counters, timers and gauges recorded through the meter registry.'), - new self('loggers', 'Loggers', 'loggers', 'Log channels and their levels. Changing a level here affects this process only.'), - new self('env', 'Environment', 'env', 'Resolved firefly.* configuration, with secrets masked.'), + new self('', 'Overview', null, self::GROUP_RUNTIME, + 'Health, runtime and what this process wired at boot.'), + new self('health', 'Health', 'health', self::GROUP_RUNTIME, + 'Every health indicator this process registered, with its own status and details.'), + new self('metrics', 'Metrics', 'metrics', self::GROUP_RUNTIME, + 'Counters, timers and gauges recorded through the meter registry.'), + new self('http', 'HTTP traffic', 'httpexchanges', self::GROUP_RUNTIME, + 'The most recent requests this application served.'), + + new self('beans', 'Beans', 'beans', self::GROUP_WIRING, + 'Every bean the container registered, with the stereotype that declared it.'), + new self('conditions', 'Conditions', 'conditions', self::GROUP_WIRING, + 'Which auto-configurations applied, and which backed off because you supplied your own.'), + new self('mappings', 'Routes', 'mappings', self::GROUP_WIRING, + 'The compiled route table the dispatcher serves from.'), + new self('scheduled', 'Scheduled', 'scheduledtasks', self::GROUP_WIRING, + 'Methods registered by #[Scheduled], with the cron or interval that drives them.'), + + new self('env', 'Environment', 'env', self::GROUP_CONFIG, + 'Resolved firefly.* configuration, with secrets masked.'), + new self('configprops', 'Config properties', 'configprops', self::GROUP_CONFIG, + 'Every #[ConfigProperties] DTO the application bound, with the values it resolved.'), + new self('caches', 'Caches', 'caches', self::GROUP_CONFIG, + 'The cache stores this application has configured.'), + new self('loggers', 'Loggers', 'loggers', self::GROUP_CONFIG, + 'Log channels and their levels.'), ]; } + + /** + * The groups in menu order, so the nav does not depend on array_unique's ordering guarantees. + * + * @return list + */ + public static function groups(): array + { + return [self::GROUP_RUNTIME, self::GROUP_WIRING, self::GROUP_CONFIG]; + } } diff --git a/packages/admin/tests/CapstoneAdminIntegrationTest.php b/packages/admin/tests/CapstoneAdminIntegrationTest.php index 903011e..108e67f 100644 --- a/packages/admin/tests/CapstoneAdminIntegrationTest.php +++ b/packages/admin/tests/CapstoneAdminIntegrationTest.php @@ -12,7 +12,8 @@ ->assertStatus(200) ->assertHeader('Content-Type', 'text/html; charset=UTF-8') ->assertSee('Overview', false) - ->assertSee('Backed off', false); + ->assertSee('Health indicators', false) + ->assertSee('Registered endpoints', false); }); // The point of reading endpoints in-process: exposure is at its secure default of health,info here, so diff --git a/packages/openapi/src/Web/ViewerPage.php b/packages/openapi/src/Web/ViewerPage.php index ebfafbd..003d61c 100644 --- a/packages/openapi/src/Web/ViewerPage.php +++ b/packages/openapi/src/Web/ViewerPage.php @@ -43,202 +43,438 @@ public function render(string $specUrl, bool $cdn): string private function builtIn(string $specUrl): string { - $title = $this->escape($this->title); - $url = $this->json($specUrl); - - return << - + - {$title} — API reference + + __TITLE__ — API reference + -

Loading the specification…

+
+
+ __TITLE__API reference + + OpenAPI 3.1 + +
+ +
Loading the document…
+
- HTML; + HTML, [ + '__TITLE__' => $this->escape($this->title), + '__SPEC_URL__' => $this->json($specUrl), + ]); } private function swaggerUi(string $specUrl): string diff --git a/packages/openapi/tests/Web/ViewerPageTest.php b/packages/openapi/tests/Web/ViewerPageTest.php index 55036d0..0f6387f 100644 --- a/packages/openapi/tests/Web/ViewerPageTest.php +++ b/packages/openapi/tests/Web/ViewerPageTest.php @@ -7,12 +7,16 @@ it('renders a self-contained page that never reaches the network', function () { $html = new ViewerPage('Orders API')->render('/openapi.json', cdn: false); + // The ONE network call the default viewer makes is to the spec route it was handed, so no element may + // FETCH from another origin. Asserting on src/href rather than on the raw substring "http://" is the + // honest version of that rule: the inline favicon is an SVG data URI, and an SVG carries the XML + // namespace http://www.w3.org/2000/svg, which is an identifier a browser never requests. Banning the + // substring would fail on a page that makes no request at all. + preg_match_all('#\b(?:src|href)\s*=\s*["\']?(https?:)?//[^"\'\s>]+#i', $html, $external); + expect($html)->toStartWith('') ->and($html)->toContain('Orders API') - // The ONE network call the default viewer makes is to the spec route it was handed. Any other - // absolute URL in this page would be an undeclared third-party dependency at request time. - ->and($html)->not->toContain('https://') - ->and($html)->not->toContain('http://') + ->and($external[0])->toBe([]) ->and($html)->not->toContain('//cdn.') ->and(substr_count($html, 'toBe(1); }); @@ -23,7 +27,10 @@ expect($html)->toContain('function deref') // The JSON Pointer walk itself: a local "#/a/b" pointer split and followed into the loaded document. ->and($html)->toContain("ref.slice(2).split('/')") - ->and($html)->toContain('schema.$ref'); + // deref() is applied wherever a schema can be a pointer, so a reader never sees one. + ->and($html)->toContain('typeof node.$ref') + ->and($html)->toContain('deref(schema.properties[name])') + ->and($html)->toContain('deref(content[type].schema)'); }); it('only reaches a CDN when the opt-in flag is explicitly turned on', function () { From 987990d80c3feebfb243ec32fbef3e7f3866b3ab Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 15:33:14 -0700 Subject: [PATCH 13/31] feat(skeleton): the welcome page now shows what is actually running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page listed routes and static learning links. It said nothing about the surfaces the application is serving right now — a developer who had just installed firefly/admin or firefly/openapi had no way to discover the dashboard or the API reference except by reading a README. Adds a "What is running" section listing the actuator, the dashboard, the API reference and the OpenAPI document, each with the path it is mounted at. Each entry is resolved from the same config keys the packages themselves read, and appears only when its package is installed AND turned on, so a card never links to a 404. That matters more than it sounds: the dashboard defaults to off outside debug, the reference disappears when firefly/openapi is absent, and both paths are configurable — a hard-coded link would be wrong for most applications. Also drops the duplicated /actuator row from the routes list, which now belongs to that section, and renames the heading to "Your routes" since it lists only the application's own. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- skeleton/app/Http/WelcomeController.php | 51 ++++++++++++++++++++++ skeleton/resources/views/welcome.blade.php | 36 +++++++++++---- skeleton/tests/Feature/WelcomeTest.php | 3 +- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/skeleton/app/Http/WelcomeController.php b/skeleton/app/Http/WelcomeController.php index 1a1a1d5..9823b1d 100644 --- a/skeleton/app/Http/WelcomeController.php +++ b/skeleton/app/Http/WelcomeController.php @@ -7,10 +7,12 @@ use Composer\InstalledVersions; use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Actuator\Introspection\BeansCatalog; +use Firefly\Admin\AdminSettings; use Firefly\Config\Config; use Firefly\Context\Boot\BootPhase; use Firefly\Context\Condition\ConditionEvaluationReport; use Firefly\Context\Scan\AppScan; +use Firefly\OpenApi\OpenApiProperties; use Firefly\Web\Attributes\Controller; use Firefly\Web\Attributes\GetMapping; use Firefly\Web\Route\RouteManifest; @@ -62,9 +64,58 @@ public function index(): View 'exposed' => $this->exposed(), 'endpoints' => $this->registeredEndpoints(), 'routes' => $this->appRoutes($base), + 'tools' => $this->tools($base), ]); } + /** + * The other surfaces this application is serving right now. + * + * Each is present only when its package is installed AND turned on, resolved from the same config keys + * the packages themselves read — so the card never links to a 404. That matters more than it sounds: the + * dashboard is off by default outside debug, and the API reference disappears when firefly/openapi is + * not installed, so a hard-coded link would be wrong for most applications. + * + * @return list + */ + private function tools(string $actuatorBase): array + { + $tools = [[ + 'href' => '/'.$actuatorBase, + 'label' => 'Actuator', + 'blurb' => 'Health, info and the endpoints you expose, as JSON.', + ]]; + + if (class_exists(AdminSettings::class)) { + $admin = AdminSettings::fromConfig($this->config); + if ($admin->enabled) { + $tools[] = [ + 'href' => $admin->url(), + 'label' => 'Dashboard', + 'blurb' => 'Health, beans, routes, metrics and configuration in the browser.', + ]; + } + } + + if ($this->config->bool('firefly.openapi.enabled', true) && class_exists(OpenApiProperties::class)) { + if ($this->config->bool('firefly.openapi.viewer.enabled', true)) { + $tools[] = [ + 'href' => '/'.trim($this->config->string('firefly.openapi.viewer.path', '/openapi'), '/'), + 'label' => 'API reference', + 'blurb' => 'Every endpoint, its schema and a request console — generated from your code.', + ]; + } + + $tools[] = [ + 'href' => '/'.trim($this->config->string('firefly.openapi.path', '/openapi.json'), '/'), + 'label' => 'OpenAPI document', + 'blurb' => 'The 3.1 spec, for a client generator or an API gateway.', + ]; + } + + return $tools; + } + /** * The real boot pipeline. The ordinals are gapped on purpose so a later milestone can slot a phase * between two existing ones without renumbering — which is why they read 100, 200, … 650, 700. diff --git a/skeleton/resources/views/welcome.blade.php b/skeleton/resources/views/welcome.blade.php index 9e85cdc..48cd0ec 100644 --- a/skeleton/resources/views/welcome.blade.php +++ b/skeleton/resources/views/welcome.blade.php @@ -160,6 +160,19 @@ .copy:hover{border-color:var(--amber);color:var(--amber)} .copy[data-done]{border-color:var(--ok);color:var(--ok)} + /* what is running — the live surfaces, with the path shown so it is obvious where they are */ + .tools{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px} + .tools .tool{ + display:flex;flex-direction:column;gap:4px; + border:1px solid var(--line);border-radius:var(--r-sm);background:var(--card); + padding:15px 17px;transition:border-color .14s,background .14s; + } + .tools .tool:hover{border-color:var(--amber-2);background:var(--card-2)} + .tools .tool strong{display:flex;align-items:center;gap:6px;font-size:14.5px;font-weight:600} + .tools .tool .go{color:var(--amber);font-size:13px} + .tools .tool span{font-size:13px;color:var(--text-3);line-height:1.5} + .tools .tool code{align-self:flex-start;margin-top:3px;font-size:11.5px} + /* learn */ .links{display:grid;grid-template-columns:repeat(auto-fit,minmax(215px,1fr));gap:10px} .links a{ @@ -203,7 +216,7 @@
-

Your paths

+

Your routes

@forelse ($routes as $route) @if (str_contains($route['path'], '{')) @@ -225,12 +238,6 @@

No routes yet. Run php artisan make:firefly-controller to add one.

@endforelse - - GET - {{ $actuatorBase }} - Health and info - -
@@ -252,6 +259,19 @@
+
+

What is running

+ +
+

Learn

RunnableCronFixed rateFixed delayZone
{{ $task['runnable'] ?? '' }}{{ $task['cron'] ?: '—' }}{{ $task['fixedRate'] ?: '—' }}{{ $task['fixedDelay'] ?: '—' }}{{ $task['zone'] ?: '—' }}{{ Format::shortClass($runnable) }}{{ rtrim(Format::namespaceOf($runnable), '\\') }}{{ $task['cron'] ?: '—' }}{{ $task['fixedRate'] ?: '—' }}{{ $task['fixedDelay'] ?: '—' }}{{ $task['zone'] ?: '—' }}
- - @foreach ($rows as $row) + + @foreach ($stores as $name => $store) + @php + $store = is_array($store) ? $store : []; + $label = is_string($store['name'] ?? null) ? $store['name'] : (string) $name; + $driver = is_string($store['driver'] ?? null) ? $store['driver'] : ''; + $isDefault = ($store['default'] ?? false) === true; + @endphp - - - + + + @endforeach @@ -44,6 +41,11 @@ @endif -

This view is read-only. Evicting a cache from a dashboard is a destructive operation and - needs an authorization story this package does not have — use php artisan cache:clear.

+

+ @if ($defaultStore !== null) + cache.default is {{ $defaultStore }}. + @endif + This view is read-only. Evicting a cache from a dashboard is destructive and needs an authorization + story this package does not have — use php artisan cache:clear. +

@endsection diff --git a/packages/admin/resources/views/configprops.blade.php b/packages/admin/resources/views/configprops.blade.php index 968df75..f1e1c4e 100644 --- a/packages/admin/resources/views/configprops.blade.php +++ b/packages/admin/resources/views/configprops.blade.php @@ -3,25 +3,38 @@ @section('body') @php use Firefly\Admin\Format; - // The endpoint groups DTOs under a context key, mirroring Spring's /actuator/configprops. Flatten to - // one row per bound property so the table is scannable and filterable as a single list. + + // The endpoint answers one row per #[ConfigProperties] DTO. Flattening to one row per PROPERTY makes + // the table filterable as a single list, which is how someone actually looks a value up; the + // unbound rows are kept separately because "it did not bind, and here is why" is the more urgent + // thing this page can tell you. $rows = []; - foreach ($contexts as $context => $beans) { - if (! is_array($beans)) { continue; } - foreach ($beans as $name => $bean) { - if (! is_array($bean)) { continue; } - $prefix = is_string($bean['prefix'] ?? null) ? $bean['prefix'] : ''; - $properties = is_array($bean['properties'] ?? null) ? $bean['properties'] : []; - foreach ($properties as $key => $value) { - $rows[] = [ - 'class' => is_string($name) ? $name : '', - 'prefix' => $prefix, - 'key' => (string) $key, - 'value' => is_scalar($value) || $value === null - ? ($value === null ? 'null' : (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value)) - : json_encode($value, JSON_UNESCAPED_SLASHES), - ]; - } + $problems = []; + foreach ($beans as $class => $bean) { + if (! is_array($bean)) { continue; } + $class = is_string($bean['class'] ?? null) ? $bean['class'] : (string) $class; + $prefix = is_string($bean['prefix'] ?? null) ? $bean['prefix'] : ''; + $bound = ($bean['bound'] ?? false) === true; + $error = is_string($bean['error'] ?? null) ? $bean['error'] : null; + $profiles = is_array($bean['profiles'] ?? null) ? $bean['profiles'] : []; + + if (! $bound) { + $problems[] = ['class' => $class, 'prefix' => $prefix, 'profiles' => $profiles, 'error' => $error]; + continue; + } + + foreach (is_array($bean['properties'] ?? null) ? $bean['properties'] : [] as $key => $value) { + $rows[] = [ + 'class' => $class, + 'prefix' => $prefix, + 'key' => (string) $key, + 'value' => match (true) { + is_bool($value) => $value ? 'true' : 'false', + $value === null => 'null', + is_scalar($value) => (string) $value, + default => (string) json_encode($value, JSON_UNESCAPED_SLASHES), + }, + ]; } } @endphp @@ -29,12 +42,40 @@

Config properties

Every #[ConfigProperties] DTO the application bound, with the values it actually - resolved — which is not always what the config file says, once relaxed binding and profiles apply.

+ resolved — which is not always what the file says, once relaxed binding and profiles apply.

+ @if ($problems !== []) +
+ @include('firefly-admin::_panel-head', ['title' => 'Not bound', 'count' => count($problems)]) +
+
StoreDriverDefault
{{ $row['name'] }}{{ $row['driver'] ?: '—' }}@if ($row['default'])default@endif{{ $label }}{{ $driver ?: '—' }}@if ($isDefault)default@endif
+ + + @foreach ($problems as $problem) + + + + + + @endforeach + +
ClassPrefixWhy
{{ Format::shortClass($problem['class']) }}{{ rtrim(Format::namespaceOf($problem['class']), '\\') }}{{ $problem['prefix'] ?: '—' }} + @if ($problem['error'] !== null) + {{ $problem['error'] }} + @elseif ($problem['profiles'] !== []) + Requires the {{ implode(', ', $problem['profiles']) }} profile, which is not active. + @else + Not bound on this boot. + @endif +
+
+
+ @endif +
@include('firefly-admin::_panel-head', [ - 'title' => 'Bound DTOs', 'count' => count($rows), + 'title' => 'Bound values', 'count' => count($rows), 'filter' => 'props-body', 'placeholder' => 'Filter by class, prefix or key…', ]) @if ($rows === []) @@ -60,4 +101,7 @@
@endif
+ +

Values that look secret are masked by the endpoint before they reach this page — the key + decides, so a sensitive key holding an array is replaced whole rather than descended into.

@endsection diff --git a/packages/admin/resources/views/graph.blade.php b/packages/admin/resources/views/graph.blade.php new file mode 100644 index 0000000..eced842 --- /dev/null +++ b/packages/admin/resources/views/graph.blade.php @@ -0,0 +1,175 @@ +@extends('firefly-admin::layout') +@section('title', 'Bean graph') +@section('body') + @php + use Firefly\Admin\BeanGraph; + + // Group by level for the layered drawing. Levels come from the model; the view only positions. + $byLevel = []; + foreach ($graph->nodes as $node) { $byLevel[$node['level']][] = $node; } + ksort($byLevel); + + $nodeW = 186; $nodeH = 42; $gapX = 22; $gapY = 74; + $widest = 0; + foreach ($byLevel as $row) { $widest = max($widest, count($row)); } + $canvasW = max(720, $widest * ($nodeW + $gapX)); + $canvasH = max(240, count($byLevel) * ($nodeH + $gapY)); + + // Centre each level, then remember every node's box so the edges can be drawn between them. + $at = []; + foreach ($byLevel as $level => $row) { + $rowW = count($row) * ($nodeW + $gapX) - $gapX; + $startX = ($canvasW - $rowW) / 2; + foreach (array_values($row) as $i => $node) { + $at[$node['id']] = [ + 'x' => $startX + $i * ($nodeW + $gapX), + 'y' => $level * ($nodeH + $gapY) + 16, + ]; + } + } + @endphp + +
+

Bean graph

+

How your beans depend on one another. A constructor asks for a type, so an edge through an + interface is drawn to the bean that actually implements it and labelled with the interface.

+
+ +
+
Beans
{{ count($graph->nodes) }}
+
Relations
{{ count($graph->edges) }}
+
Layers
{{ count($byLevel) }}
+
+
Cycles
+
@if ($graph->cycles === []){{ 0 }}@else{{ count($graph->cycles) }}@endif
+
+
Unresolved
{{ count($graph->unresolved) }}
+
+ + @if ($graph->cycles !== []) +
+ @include('firefly-admin::_panel-head', ['title' => 'Circular dependencies', 'count' => count($graph->cycles)]) +
+ + + + @foreach ($graph->cycles as $cycle) + + + + + @endforeach + +
FromDepends on
{{ Firefly\Admin\Format::shortClass($cycle['from']) }}{{ Firefly\Admin\Format::shortClass($cycle['to']) }}
+
+

The container has no cycle detection, so a + cycle among eager singletons exhausts memory at boot rather than reporting itself. Break one of + these edges — usually by injecting an interface and letting the other side depend on that.

+
+ @endif + +
+ @include('firefly-admin::_panel-head', [ + 'title' => 'Wiring', 'count' => count($graph->nodes).' beans', + 'filter' => 'graph-body', 'placeholder' => 'Highlight a bean…', + ]) + + @if ($graph->nodes === []) + @include('firefly-admin::_empty', [ + 'title' => 'No beans to graph', + 'body' => 'Check firefly.scan.paths points at your application namespace.', + ]) + @elseif (count($graph->nodes) > $settings->graphMaxNodes) + @include('firefly-admin::_empty', [ + 'title' => 'Too many beans to draw at once', + 'body' => 'This application has '.count($graph->nodes).' beans. A diagram past '.$settings->graphMaxNodes.' nodes is a hairball rather than something you can read, so the relations are listed below instead. Raise firefly.admin.graph.max-nodes to draw it anyway.', + ]) + @else +
+ + + + + + + + + @foreach ($graph->edges as $edge) + @continue (! isset($at[$edge['from']], $at[$edge['to']])) + @php + $a = $at[$edge['from']]; $b = $at[$edge['to']]; + $x1 = $a['x'] + $nodeW / 2; $y1 = $a['y'] + $nodeH; + $x2 = $b['x'] + $nodeW / 2; $y2 = $b['y']; + $mid = ($y1 + $y2) / 2; + @endphp + + @if ($edge['via'] !== null) + via {{ $edge['via'] }} + @endif + + @endforeach + + + + @foreach ($graph->nodes as $node) + @php $pos = $at[$node['id']]; @endphp + + {{ $node['id'] }} — {{ $node['stereotype'] }}, {{ $node['scope'] }} · {{ $node['in'] }} in, {{ $node['out'] }} out + + {{ \Illuminate\Support\Str::limit($node['label'], 24) }} + {{ $node['stereotype'] }} · {{ $node['in'] }}↑ {{ $node['out'] }}↓ + + @endforeach + + +
+ @endif +
+ +
+ @include('firefly-admin::_panel-head', [ + 'title' => 'Relations', 'count' => count($graph->edges), + 'filter' => 'edges-body', 'placeholder' => 'Filter relations…', + ]) + @if ($graph->edges === []) + @include('firefly-admin::_empty', [ + 'title' => 'No relations found', + 'body' => 'Every bean here is constructed without depending on another bean. Constructor parameters typed as scalars are configuration, not wiring, and are deliberately not edges.', + ]) + @else +
+ + + + @foreach ($graph->edges as $edge) + + + + + + @endforeach + +
BeanDepends onWired by
{{ Firefly\Admin\Format::shortClass($edge['from']) }}{{ rtrim(Firefly\Admin\Format::namespaceOf($edge['from']), '\\') }}{{ Firefly\Admin\Format::shortClass($edge['to']) }}{{ rtrim(Firefly\Admin\Format::namespaceOf($edge['to']), '\\') }}{{ $edge['via'] !== null ? Firefly\Admin\Format::shortClass($edge['via']) : 'class' }}
+
+ @endif +
+ + @if ($graph->unresolved !== []) +
+ @include('firefly-admin::_panel-head', ['title' => 'Provided outside the container', 'count' => count($graph->unresolved)]) +
+ @foreach ($graph->unresolved as $type) + {{ Firefly\Admin\Format::shortClass($type) }} + @endforeach +
+

These constructor types are satisfied by a + Laravel container binding rather than a scanned bean — the request, the config repository, a + connection — so they are not drawn as nodes.

+
+ @endif +@endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 074a8b7..a5ddbbf 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -10,7 +10,7 @@ eye. The brand appears once, as the dot in the wordmark. --}} - + @@ -278,6 +278,20 @@ } .act:hover{border-color:var(--accent);color:var(--accent)} + /* ── bean graph ──────────────────────────────────────────────────── */ + .canvas{overflow:auto;padding:14px;background:var(--panel-2);max-height:70vh} + .canvas svg{display:block;margin-inline:auto} + .edges .edge{fill:none;stroke:var(--line-2);stroke-width:1.4;color:var(--line-2);transition:stroke .12s,opacity .12s} + .edges .edge.via{stroke-dasharray:4 3} + .edges .edge.lit{stroke:var(--accent);color:var(--accent);stroke-width:2} + .edges .edge.dimmed{opacity:.15} + .nodes .node rect{fill:var(--panel);stroke:var(--line-2);stroke-width:1.2;transition:stroke .12s,fill .12s} + .nodes .node text{font-family:var(--mono);font-size:11.5px;fill:var(--ink);pointer-events:none} + .nodes .node text.sub{font-size:10px;fill:var(--ink-3)} + .nodes .node{cursor:pointer} + .nodes .node:hover rect,.nodes .node.lit rect{stroke:var(--accent);fill:var(--accent-soft)} + .nodes .node.dimmed{opacity:.25} + [hidden]{display:none!important} @@ -348,8 +362,10 @@ function stop() { try { localStorage.removeItem('firefly-admin-refresh'); } catch (e) { /* private mode */ } } + var INTERVAL = {{ $settings->refreshSeconds }}; + function start() { - left = 10; + left = INTERVAL; tick.textContent = left + 's'; button.setAttribute('aria-pressed', 'true'); try { localStorage.setItem('firefly-admin-refresh', '1'); } catch (e) { /* private mode */ } @@ -381,6 +397,75 @@ function start() { }); }); + // Bean graph: hovering or clicking a node lights its edges and both endpoints, and dims everything + // else. Reading a dependency diagram is asking "what touches THIS", and a static picture cannot + // answer that once there is more than a handful of nodes. + (function () { + var svg = document.querySelector('.canvas svg'); + if (!svg) { return; } + + var nodes = svg.querySelectorAll('.node'); + var edges = svg.querySelectorAll('.edge'); + + function clear() { + nodes.forEach(function (n) { n.classList.remove('lit', 'dimmed'); }); + edges.forEach(function (e) { e.classList.remove('lit', 'dimmed'); }); + } + + function focus(id) { + var touched = {}; + touched[id] = true; + edges.forEach(function (edge) { + var from = edge.getAttribute('data-from'), to = edge.getAttribute('data-to'); + if (from === id || to === id) { + edge.classList.add('lit'); + edge.classList.remove('dimmed'); + touched[from] = true; + touched[to] = true; + } else { + edge.classList.add('dimmed'); + edge.classList.remove('lit'); + } + }); + nodes.forEach(function (node) { + var hit = touched[node.getAttribute('data-id')]; + node.classList.toggle('lit', !!hit); + node.classList.toggle('dimmed', !hit); + }); + } + + var pinned = null; + nodes.forEach(function (node) { + var id = node.getAttribute('data-id'); + node.addEventListener('mouseenter', function () { if (!pinned) { focus(id); } }); + node.addEventListener('mouseleave', function () { if (!pinned) { clear(); } }); + node.addEventListener('click', function () { + pinned = pinned === id ? null : id; + pinned ? focus(pinned) : clear(); + }); + }); + svg.addEventListener('click', function (event) { + if (event.target === svg) { pinned = null; clear(); } + }); + + // The graph filter highlights rather than hides: removing a node would silently remove its + // edges too, and an edge to something you cannot see is worse than no filter at all. + var find = document.querySelector('[data-filter="graph-body"]'); + if (find) { + find.addEventListener('input', function () { + var needle = find.value.toLowerCase(); + pinned = null; + if (needle === '') { clear(); return; } + nodes.forEach(function (node) { + var hit = (node.getAttribute('data-search') || '').indexOf(needle) !== -1; + node.classList.toggle('lit', hit); + node.classList.toggle('dimmed', !hit); + }); + edges.forEach(function (e) { e.classList.add('dimmed'); e.classList.remove('lit'); }); + }); + } + })(); + // "/" focuses the first filter on the page — the shortcut every log and table UI uses. document.addEventListener('keydown', function (event) { if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) { return; } diff --git a/packages/admin/src/AdminSettings.php b/packages/admin/src/AdminSettings.php index 5eb1004..c3b9f1c 100644 --- a/packages/admin/src/AdminSettings.php +++ b/packages/admin/src/AdminSettings.php @@ -22,10 +22,17 @@ */ final readonly class AdminSettings { + /** + * @param list $excludedPages page slugs hidden from the menu and refused by the router + */ public function __construct( public bool $enabled, public string $basePath, public string $title, + public int $refreshSeconds = 10, + public string $theme = 'auto', + public int $graphMaxNodes = 220, + public array $excludedPages = [], ) {} public static function fromConfig(Config $config): self @@ -36,9 +43,49 @@ public static function fromConfig(Config $config): self enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)), basePath: $base === '' ? 'firefly' : $base, title: $config->string('firefly.admin.title', $config->string('app.name', 'LaraFly')), + // Floored at 2s: a shorter interval reloads faster than a page renders, so the countdown would + // never finish and the dashboard would hammer the application it is supposed to be observing. + refreshSeconds: max(2, $config->int('firefly.admin.refresh-seconds', 10)), + theme: self::theme($config->string('firefly.admin.theme', 'auto')), + // Past this, a dependency diagram is a hairball rather than something anyone can read, so the + // graph page lists the relations instead of drawing them. Configurable because "unreadable" + // depends on the screen and the application. + graphMaxNodes: max(0, $config->int('firefly.admin.graph.max-nodes', 220)), + excludedPages: self::csv($config->string('firefly.admin.pages.exclude', '')), ); } + /** An unrecognised theme falls back to following the operating system rather than rendering unstyled. */ + private static function theme(string $configured): string + { + $theme = strtolower(trim($configured)); + + return in_array($theme, ['auto', 'light', 'dark'], true) ? $theme : 'auto'; + } + + /** + * Whether a page may be reached at all. + * + * `firefly.admin.pages.exclude` is a hard refusal, not a menu preference: the page is hidden AND its URL + * 404s. A deployment that hides `env` because it is uncomfortable having resolved configuration one + * click away has not achieved anything if the URL still answers. + */ + public function allows(string $slug): bool + { + return ! in_array($slug === '' ? 'overview' : $slug, $this->excludedPages, true); + } + + /** + * @return list + */ + private static function csv(string $value): array + { + return array_values(array_filter( + array_map(static fn (string $part): string => strtolower(trim($part)), explode(',', $value)), + static fn (string $part): bool => $part !== '', + )); + } + /** An absolute path for a dashboard page, e.g. url('beans') => /firefly/beans. */ public function url(string $page = ''): string { diff --git a/packages/admin/src/BeanGraph.php b/packages/admin/src/BeanGraph.php new file mode 100644 index 0000000..04b30ca --- /dev/null +++ b/packages/admin/src/BeanGraph.php @@ -0,0 +1,233 @@ + $nodes + * @param list $edges + * @param list $cycles + * @param list $unresolved + */ + public function __construct( + public readonly array $nodes, + public readonly array $edges, + public readonly array $cycles, + public readonly array $unresolved, + ) {} + + /** + * @param array $beans rows as BeansCatalog publishes them + */ + public static function fromCatalog(array $beans): self + { + [$rows, $byInterface] = self::index($beans); + + $edges = []; + $unresolved = []; + foreach ($rows as $class => $row) { + foreach ($row['dependencies'] as $dependency) { + $target = isset($rows[$dependency]) ? $dependency : ($byInterface[$dependency] ?? null); + + if ($target === null || $target === $class) { + // A type nothing in the container provides: a framework contract satisfied by a binding + // rather than a bean, or a class the scan never saw. Reported, not silently dropped — + // "why is my bean not in the graph" is exactly the question this page has to answer. + if ($target === null) { + $unresolved[] = $dependency; + } + + continue; + } + + $edges[] = ['from' => $class, 'to' => $target, 'via' => $target === $dependency ? null : $dependency]; + } + } + + $edges = self::dedupe($edges); + [$levels, $cycles] = self::levels(array_keys($rows), $edges); + + $degree = []; + foreach ($edges as $edge) { + $degree[$edge['from']]['out'] = ($degree[$edge['from']]['out'] ?? 0) + 1; + $degree[$edge['to']]['in'] = ($degree[$edge['to']]['in'] ?? 0) + 1; + } + + $nodes = []; + foreach ($rows as $class => $row) { + $nodes[] = [ + 'id' => $class, + 'label' => Format::shortClass($class), + 'namespace' => rtrim(Format::namespaceOf($class), '\\'), + 'stereotype' => $row['stereotype'], + 'scope' => $row['scope'], + 'level' => $levels[$class] ?? 0, + 'in' => $degree[$class]['in'] ?? 0, + 'out' => $degree[$class]['out'] ?? 0, + ]; + } + + usort($nodes, static fn (array $a, array $b): int => [$a['level'], $a['label']] <=> [$b['level'], $b['label']]); + + return new self($nodes, $edges, $cycles, array_values(array_unique($unresolved))); + } + + public function isRenderable(): bool + { + return count($this->nodes) <= self::MAX_RENDERABLE; + } + + /** + * @param array $beans + * @return array{0: array}>, 1: array} + */ + private static function index(array $beans): array + { + $rows = []; + $byInterface = []; + + foreach ($beans as $bean) { + if (! is_array($bean) || ! is_string($bean['class'] ?? null)) { + continue; + } + + $class = $bean['class']; + $rows[$class] = [ + 'stereotype' => is_string($bean['stereotype'] ?? null) ? $bean['stereotype'] : '', + 'scope' => is_string($bean['scope'] ?? null) ? $bean['scope'] : '', + 'dependencies' => array_values(array_filter( + is_array($bean['dependencies'] ?? null) ? $bean['dependencies'] : [], + static fn (mixed $d): bool => is_string($d) && $d !== '', + )), + ]; + + foreach (is_array($bean['interfaces'] ?? null) ? $bean['interfaces'] : [] as $interface) { + // First implementor wins, deterministically: the catalogue is emitted in scan order, so the + // same application always draws the same graph. An interface with several implementors is a + // real ambiguity the container resolves with #[Primary]/#[Qualifier], and the graph says so + // by listing the edge as `via` rather than pretending the choice was obvious. + if (is_string($interface) && ! isset($byInterface[$interface])) { + $byInterface[$interface] = $class; + } + } + } + + return [$rows, $byInterface]; + } + + /** + * @param list $edges + * @return list + */ + private static function dedupe(array $edges): array + { + $seen = []; + $out = []; + foreach ($edges as $edge) { + $key = $edge['from'].'>'.$edge['to']; + if (! isset($seen[$key])) { + $seen[$key] = true; + $out[] = $edge; + } + } + + return $out; + } + + /** + * Longest-path layering, so a node always sits below everything that depends on it and arrows read + * downward. Depth is memoised and the walk carries a visited set, so a cycle terminates instead of + * recursing forever — and the edge that closed it is reported. + * + * @param list $classes + * @param list $edges + * @return array{0: array, 1: list} + */ + private static function levels(array $classes, array $edges): array + { + $out = []; + foreach ($edges as $edge) { + $out[$edge['from']][] = $edge['to']; + } + + $depth = []; + $cycles = []; + + $walk = static function (string $node, array $path) use (&$walk, &$depth, &$cycles, $out): int { + if (isset($depth[$node])) { + return $depth[$node]; + } + if (isset($path[$node])) { + return 0; // the caller records the closing edge + } + + $path[$node] = true; + $deepest = 0; + foreach ($out[$node] ?? [] as $next) { + if (isset($path[$next])) { + $cycles[] = ['from' => $node, 'to' => $next]; + + continue; + } + $deepest = max($deepest, $walk($next, $path) + 1); + } + + return $depth[$node] = $deepest; + }; + + foreach ($classes as $class) { + $walk($class, []); + } + + // Depth counts how far a node's longest chain of dependencies runs; the drawing wants the opposite, + // with dependents on top. Flip it so level 0 is the thing nothing depends on. + $max = $depth === [] ? 0 : max($depth); + $levels = []; + foreach ($depth as $class => $value) { + $levels[$class] = $max - $value; + } + + return [$levels, self::dedupeCycles($cycles)]; + } + + /** + * @param list $cycles + * @return list + */ + private static function dedupeCycles(array $cycles): array + { + $seen = []; + $out = []; + foreach ($cycles as $cycle) { + $key = $cycle['from'].'>'.$cycle['to']; + if (! isset($seen[$key])) { + $seen[$key] = true; + $out[] = $cycle; + } + } + + return $out; + } +} diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index eef64d9..16c5924 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -6,6 +6,7 @@ use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; +use Firefly\Admin\BeanGraph; use Firefly\Admin\Format; use Firefly\Context\Scan\AppScan; use Illuminate\Contracts\Container\Container; @@ -41,7 +42,8 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse } } - if ($current === null) { + // An excluded page is refused, not merely unlisted — see AdminSettings::allows(). + if ($current === null || ! $this->settings->allows($slug)) { return $this->html($this->render('missing', ['slug' => $slug]), 404); } @@ -65,12 +67,20 @@ private function data(string $slug): array 'metrics' => ['metrics' => $this->metrics()], 'http' => ['exchanges' => $this->exchanges()], 'beans' => ['beans' => $this->listOf('beans', 'beans')], + 'graph' => ['graph' => BeanGraph::fromCatalog($this->listOf('beans', 'beans'))], 'conditions' => $this->payload('conditions') + ['positiveMatches' => [], 'negativeMatches' => []], 'mappings' => ['mappings' => $this->listOf('mappings', 'mappings')], 'scheduled' => ['tasks' => $this->listOf('scheduledtasks', 'tasks')], 'env' => ['env' => $this->flatten($this->subArray($this->payload('env'), 'firefly'), 'firefly')], - 'configprops' => ['contexts' => $this->payload('configprops')], - 'caches' => ['caches' => $this->payload('caches')], + // Shapes verified against the real endpoints: configprops answers {beans: {class => row}} + // and caches answers {default: name|null, caches: {name => row}}. + 'configprops' => ['beans' => $this->subArray($this->payload('configprops'), 'beans')], + 'caches' => [ + 'stores' => $this->subArray($this->payload('caches'), 'caches'), + 'defaultStore' => is_string($this->payload('caches')['default'] ?? null) + ? $this->payload('caches')['default'] + : null, + ], 'loggers' => $this->payload('loggers') + ['levels' => [], 'loggers' => []], default => [], }; @@ -317,7 +327,8 @@ private function nav(): array { return array_values(array_filter( AdminPage::all(), - fn (AdminPage $page): bool => $page->requires === null || $this->reader->has($page->requires), + fn (AdminPage $page): bool => $this->settings->allows($page->slug) + && ($page->requires === null || $this->reader->has($page->requires)), )); } } diff --git a/packages/admin/src/Web/AdminPage.php b/packages/admin/src/Web/AdminPage.php index 5ca6630..507e0d8 100644 --- a/packages/admin/src/Web/AdminPage.php +++ b/packages/admin/src/Web/AdminPage.php @@ -49,6 +49,8 @@ public static function all(): array new self('beans', 'Beans', 'beans', self::GROUP_WIRING, 'Every bean the container registered, with the stereotype that declared it.'), + new self('graph', 'Bean graph', 'beans', self::GROUP_WIRING, + 'How your beans depend on one another, resolved through the interfaces they are wired by.'), new self('conditions', 'Conditions', 'conditions', self::GROUP_WIRING, 'Which auto-configurations applied, and which backed off because you supplied your own.'), new self('mappings', 'Routes', 'mappings', self::GROUP_WIRING, diff --git a/packages/admin/tests/AdminSettingsTest.php b/packages/admin/tests/AdminSettingsTest.php index e0688af..9320631 100644 --- a/packages/admin/tests/AdminSettingsTest.php +++ b/packages/admin/tests/AdminSettingsTest.php @@ -47,3 +47,37 @@ function adminConfig(array $values): Config it('titles itself after the application', function () { expect(AdminSettings::fromConfig(adminConfig(['app' => ['name' => 'Lumen']]))->title)->toBe('Lumen'); }); + +it('floors the refresh interval so the page cannot reload faster than it renders', function (int $configured, int $expected) { + $settings = AdminSettings::fromConfig(adminConfig(['firefly' => ['admin' => ['refresh-seconds' => $configured]]])); + + expect($settings->refreshSeconds)->toBe($expected); +})->with([[30, 30], [1, 2], [0, 2], [-5, 2]]); + +it('falls back to following the operating system for an unrecognised theme', function (string $configured, string $expected) { + expect(AdminSettings::fromConfig(adminConfig(['firefly' => ['admin' => ['theme' => $configured]]]))->theme)->toBe($expected); +})->with([['dark', 'dark'], ['LIGHT', 'light'], ['auto', 'auto'], ['solarized', 'auto'], ['', 'auto']]); + +it('defaults the graph cap and never lets it go negative', function () { + expect(AdminSettings::fromConfig(adminConfig([]))->graphMaxNodes)->toBe(220) + ->and(AdminSettings::fromConfig(adminConfig(['firefly' => ['admin' => ['graph' => ['max-nodes' => 40]]]]))->graphMaxNodes)->toBe(40) + ->and(AdminSettings::fromConfig(adminConfig(['firefly' => ['admin' => ['graph' => ['max-nodes' => -9]]]]))->graphMaxNodes)->toBe(0); +}); + +// Excluding a page is a refusal, not a menu preference: hiding `env` from the menu achieves nothing if the +// URL still answers. +it('refuses an excluded page as well as hiding it', function () { + $settings = AdminSettings::fromConfig(adminConfig(['firefly' => ['admin' => ['pages' => ['exclude' => 'env, Caches']]]])); + + expect($settings->excludedPages)->toBe(['env', 'caches']) + ->and($settings->allows('env'))->toBeFalse() + ->and($settings->allows('caches'))->toBeFalse() + ->and($settings->allows('beans'))->toBeTrue(); +}); + +it('lets the overview itself be excluded, under its own slug', function () { + $settings = AdminSettings::fromConfig(adminConfig(['firefly' => ['admin' => ['pages' => ['exclude' => 'overview']]]])); + + expect($settings->allows(''))->toBeFalse() + ->and($settings->allows('beans'))->toBeTrue(); +}); diff --git a/packages/admin/tests/BeanGraphTest.php b/packages/admin/tests/BeanGraphTest.php new file mode 100644 index 0000000..0161ece --- /dev/null +++ b/packages/admin/tests/BeanGraphTest.php @@ -0,0 +1,131 @@ +, because the endpoint's rows + * are whatever the manifest held. The malformed-row test below depends on being able to pass junk. + * + * @param array $beans + */ +function graphOf(array $beans): BeanGraph +{ + return BeanGraph::fromCatalog($beans); +} + +/** + * @param list $dependencies + * @param list $interfaces + * @return array + */ +function bean(string $class, array $dependencies = [], array $interfaces = []): array +{ + return [ + 'class' => $class, + 'stereotype' => 'service', + 'scope' => 'Singleton', + 'name' => null, + 'interfaces' => $interfaces, + 'beans' => [], + 'dependencies' => $dependencies, + ]; +} + +it('links a dependency on a concrete class straight to that bean', function () { + $graph = graphOf([bean('App\\Controller', ['App\\Service']), bean('App\\Service')]); + + expect($graph->edges)->toBe([['from' => 'App\\Controller', 'to' => 'App\\Service', 'via' => null]]); +}); + +// The reason a naive edge list produces a field of disconnected dots: constructors ask for INTERFACES, and +// the bean that satisfies one is a concrete class with a different name. +it('resolves a dependency on an interface to the bean that implements it', function () { + $graph = graphOf([ + bean('App\\Publisher', ['App\\Contracts\\Transport']), + bean('App\\KafkaTransport', [], ['App\\Contracts\\Transport']), + ]); + + expect($graph->edges)->toBe([ + ['from' => 'App\\Publisher', 'to' => 'App\\KafkaTransport', 'via' => 'App\\Contracts\\Transport'], + ]); +}); + +it('reports a type nothing provides instead of dropping it silently', function () { + $graph = graphOf([bean('App\\Controller', ['Illuminate\\Http\\Request'])]); + + expect($graph->edges)->toBe([]) + ->and($graph->unresolved)->toBe(['Illuminate\\Http\\Request']); +}); + +it('never draws a self-edge', function () { + $graph = graphOf([bean('App\\Recursive', ['App\\Recursive'])]); + + expect($graph->edges)->toBe([]); +}); + +it('de-duplicates repeated relations between the same pair', function () { + $graph = graphOf([ + bean('App\\A', ['App\\B', 'App\\Contracts\\B'], []), + bean('App\\B', [], ['App\\Contracts\\B']), + ]); + + expect($graph->edges)->toHaveCount(1); +}); + +// A cycle must terminate and be REPORTED — the container has no cycle detection, so a cycle among eager +// singletons exhausts memory at boot, and naming it is the most useful thing this page can do. +it('terminates on a cycle and reports the edge that closed it', function () { + $graph = graphOf([bean('App\\A', ['App\\B']), bean('App\\B', ['App\\A'])]); + + expect($graph->cycles)->not->toBeEmpty() + ->and($graph->nodes)->toHaveCount(2); +}); + +it('layers so a dependency always sits below what depends on it', function () { + $graph = graphOf([ + bean('App\\Controller', ['App\\Service']), + bean('App\\Service', ['App\\Repository']), + bean('App\\Repository'), + ]); + + $level = []; + foreach ($graph->nodes as $node) { + $level[$node['id']] = $node['level']; + } + + expect($level['App\\Controller'])->toBeLessThan($level['App\\Service']) + ->and($level['App\\Service'])->toBeLessThan($level['App\\Repository']); +}); + +it('counts in and out degree per bean', function () { + $graph = graphOf([ + bean('App\\A', ['App\\C']), + bean('App\\B', ['App\\C']), + bean('App\\C'), + ]); + + $byId = []; + foreach ($graph->nodes as $node) { + $byId[$node['id']] = $node; + } + + expect($byId['App\\C']['in'])->toBe(2) + ->and($byId['App\\C']['out'])->toBe(0) + ->and($byId['App\\A']['out'])->toBe(1); +}); + +it('ignores malformed catalogue rows rather than failing the page', function () { + $graph = graphOf([bean('App\\Ok'), ['no-class' => true], ['class' => 42]]); + + expect($graph->nodes)->toHaveCount(1) + ->and($graph->nodes[0]['id'])->toBe('App\\Ok'); +}); + +it('splits a class into a short label and its namespace for the drawing', function () { + $graph = graphOf([bean('App\\Domain\\OrderService')]); + + expect($graph->nodes[0]['label'])->toBe('OrderService') + ->and($graph->nodes[0]['namespace'])->toBe('App\\Domain'); +}); diff --git a/packages/container/src/Descriptor/BeanDescriptor.php b/packages/container/src/Descriptor/BeanDescriptor.php index b14b6a6..3d93b30 100644 --- a/packages/container/src/Descriptor/BeanDescriptor.php +++ b/packages/container/src/Descriptor/BeanDescriptor.php @@ -36,10 +36,23 @@ public function __construct( * captures it, and Firefly\Container\Attributes\Lazy's docblock). */ public bool $lazy = false, + /** + * The class types this factory method asks for — the bean graph's edges for the #[Bean] path. + * + * Most of a framework's wiring lives HERE rather than in component constructors: an + * auto-configuration is a #[Configuration] whose #[Bean] methods take their collaborators as + * parameters. A graph built only from component constructors therefore draws almost no edges at + * all, which is exactly what it did before this field existed. + * + * Last, with a default, so a manifest compiled before the bean graph shipped still rehydrates. + * + * @var list + */ + public array $dependencies = [], ) {} /** - * @return array{method: string, returns: string, name: string|null, scope: string, primary: bool, order: int, lazy: bool} + * @return array{method: string, returns: string, name: string|null, scope: string, primary: bool, order: int, lazy: bool, dependencies: list} */ public function toArray(): array { @@ -51,11 +64,12 @@ public function toArray(): array 'primary' => $this->primary, 'order' => $this->order, 'lazy' => $this->lazy, + 'dependencies' => $this->dependencies, ]; } /** - * @param array{method: string, returns: string, name: string|null, scope: string, primary: bool, order: int, lazy?: bool} $data + * @param array{method: string, returns: string, name: string|null, scope: string, primary: bool, order: int, lazy?: bool, dependencies?: list} $data */ public static function fromArray(array $data): self { @@ -70,6 +84,8 @@ public static function fromArray(array $data): self // default false rather than fatal, so an old cached manifest on disk still loads // (see ComponentScanner / Firefly\Container\Attributes\Lazy). $data['lazy'] ?? false, + // Same reasoning as $lazy: absent on a manifest cached before the bean graph shipped. + $data['dependencies'] ?? [], ); } } diff --git a/packages/container/src/Descriptor/ComponentDescriptor.php b/packages/container/src/Descriptor/ComponentDescriptor.php index 66b3965..dbeae00 100644 --- a/packages/container/src/Descriptor/ComponentDescriptor.php +++ b/packages/container/src/Descriptor/ComponentDescriptor.php @@ -23,6 +23,19 @@ public function __construct( public array $interfaces, public array $beans, public bool $lazy = false, + /** + * The class types this component's constructor asks for — the edges of the bean graph. + * + * Recorded at scan time, where reflection is already sanctioned, because the alternative is + * reflecting at request time to answer "what depends on what", which the reflection-free boot + * contract forbids. Only CLASS and INTERFACE types are kept: a scalar or a builtin is configuration, + * not a wiring edge, and putting it in the graph would drown the edges that matter. + * + * Last, with a default, so a manifest compiled before this field existed still rehydrates. + * + * @var list + */ + public array $dependencies = [], ) {} /** @@ -52,6 +65,7 @@ public function toArray(): array 'interfaces' => $this->interfaces, 'beans' => array_map(static fn (BeanDescriptor $b): array => $b->toArray(), $this->beans), 'lazy' => $this->lazy, + 'dependencies' => $this->dependencies, ]; } @@ -67,6 +81,7 @@ public function toArray(): array * interfaces: list, * beans: list, * lazy?: bool, + * dependencies?: list, * } $data */ public static function fromArray(array $data): self @@ -85,6 +100,8 @@ public static function fromArray(array $data): self // than fatal, so an old cached manifest on disk still loads (see ComponentScanner / // Firefly\Container\Attributes\Lazy). $data['lazy'] ?? false, + // Same reasoning as $lazy above: absent on a manifest cached before the bean graph shipped. + $data['dependencies'] ?? [], ); } } diff --git a/packages/container/src/Scanner/ComponentScanner.php b/packages/container/src/Scanner/ComponentScanner.php index 07d06e0..33ac673 100644 --- a/packages/container/src/Scanner/ComponentScanner.php +++ b/packages/container/src/Scanner/ComponentScanner.php @@ -117,9 +117,45 @@ class: $class, interfaces: $interfaces, beans: $this->beansOf($reflection), lazy: $reflection->getAttributes(Lazy::class) !== [], + dependencies: $this->dependenciesOf($reflection), ); } + /** + * The class and interface types this component's constructor asks for — the edges of the bean graph. + * + * Scalars, builtins and untyped parameters are skipped: those are configuration, not wiring, and putting + * them in the graph would bury the edges that matter under `string $name` noise. A nullable or defaulted + * class parameter IS kept, because an optional collaborator is still a relationship. + * + * @param ReflectionClass $reflection + * @return list + */ + private function dependenciesOf(ReflectionClass $reflection): array + { + $constructor = $reflection->getConstructor(); + + return $constructor === null ? [] : $this->parameterTypes($constructor); + } + + /** + * The class and interface types a callable asks for, in declaration order and de-duplicated. + * + * @return list + */ + private function parameterTypes(ReflectionMethod $method): array + { + $types = []; + foreach ($method->getParameters() as $parameter) { + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) { + $types[] = $type->getName(); + } + } + + return array_values(array_unique($types)); + } + /** * Collect the #[Bean] factory methods declared on an already-discovered component. * @@ -170,6 +206,7 @@ private function beansOf(ReflectionClass $reflection): array primary: $method->getAttributes(Primary::class) !== [], order: $this->orderOf($method->getAttributes(Order::class)), lazy: $method->getAttributes(Lazy::class) !== [], + dependencies: $this->parameterTypes($method), ); } diff --git a/packages/cqrs/cache/firefly-cqrs-components.php b/packages/cqrs/cache/firefly-cqrs-components.php index b842cea..62dcfe7 100644 --- a/packages/cqrs/cache/firefly-cqrs-components.php +++ b/packages/cqrs/cache/firefly-cqrs-components.php @@ -24,6 +24,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 1 => [ 'method' => 'correlationContext', @@ -33,6 +35,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 2 => [ 'method' => 'messageValidator', @@ -42,6 +46,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', + ], ], 3 => [ 'method' => 'commandAuthorizer', @@ -51,6 +58,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 4 => [ 'method' => 'queryAuthorizer', @@ -60,6 +69,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 5 => [ 'method' => 'cqrsMetrics', @@ -69,6 +80,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 6 => [ 'method' => 'queryCache', @@ -78,6 +91,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 7 => [ 'method' => 'commandEventPublisher', @@ -87,6 +102,12 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', + 1 => 'Firefly\\Config\\Config', + 2 => 'Firefly\\Cqrs\\Handler\\HandlerManifest', + 3 => 'Firefly\\Cqrs\\Correlation\\CorrelationContext', + ], ], 8 => [ 'method' => 'domainEventBridge', @@ -96,6 +117,11 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Cqrs\\Event\\CommandEventPublisher', + 1 => 'Firefly\\Config\\Config', + 2 => 'Illuminate\\Container\\Container', + ], ], 9 => [ 'method' => 'commandBus', @@ -105,6 +131,13 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Cqrs\\Handler\\HandlerRegistry', + 1 => 'Firefly\\Cqrs\\Validation\\MessageValidator', + 2 => 'Firefly\\Cqrs\\Security\\CommandAuthorizer', + 3 => 'Firefly\\Cqrs\\Correlation\\CorrelationContext', + 4 => 'Firefly\\Cqrs\\Metrics\\CqrsMetrics', + ], ], 10 => [ 'method' => 'queryBus', @@ -114,8 +147,19 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Cqrs\\Handler\\HandlerRegistry', + 1 => 'Firefly\\Cqrs\\Validation\\MessageValidator', + 2 => 'Firefly\\Cqrs\\Security\\QueryAuthorizer', + 3 => 'Firefly\\Cqrs\\Correlation\\CorrelationContext', + 4 => 'Firefly\\Cqrs\\Metrics\\CqrsMetrics', + 5 => 'Firefly\\Cqrs\\Cache\\QueryCache', + 6 => 'Firefly\\Config\\Config', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/data/cache/firefly-data-components.php b/packages/data/cache/firefly-data-components.php index 3a6ea1a..8cbb2ce 100644 --- a/packages/data/cache/firefly-data-components.php +++ b/packages/data/cache/firefly-data-components.php @@ -24,6 +24,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 1 => [ 'method' => 'domainEventDispatcher', @@ -33,6 +35,10 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Data\\Domain\\AggregateTracker', + 1 => 'Firefly\\Context\\Event\\ApplicationEventPublisher', + ], ], 2 => [ 'method' => 'transactionTemplate', @@ -42,6 +48,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Data\\Domain\\DomainEventDispatcher', + ], ], 3 => [ 'method' => 'transactionInterceptor', @@ -51,6 +60,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Data\\Transaction\\TransactionTemplate', + ], ], 4 => [ 'method' => 'transactionalManifest', @@ -60,6 +72,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Contracts\\Container\\Container', + ], ], 5 => [ 'method' => 'proxyFactory', @@ -69,9 +84,13 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], 1 => [ 'class' => 'Firefly\\Data\\Transaction\\TransactionalBeanPostProcessor', @@ -87,5 +106,10 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Data\\Transaction\\TransactionalManifest', + 1 => 'Firefly\\Data\\Proxy\\ProxyFactory', + 2 => 'Firefly\\Data\\Transaction\\TransactionInterceptor', + ], ], ]; diff --git a/packages/eda-kafka/cache/firefly-eda-kafka-components.php b/packages/eda-kafka/cache/firefly-eda-kafka-components.php index 5467b03..05e96e5 100644 --- a/packages/eda-kafka/cache/firefly-eda-kafka-components.php +++ b/packages/eda-kafka/cache/firefly-eda-kafka-components.php @@ -24,6 +24,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 1 => [ 'method' => 'eventPublisher', @@ -33,6 +35,10 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Firefly\\Eda\\Bus\\SubscriberRegistry', + ], ], 2 => [ 'method' => 'eventConsumer', @@ -42,9 +48,14 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], 1 => [ 'class' => 'Firefly\\Eda\\Kafka\\KafkaHealthIndicator', @@ -60,5 +71,7 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/eda-postgres/cache/firefly-eda-postgres-components.php b/packages/eda-postgres/cache/firefly-eda-postgres-components.php index 9e92260..f43a791 100644 --- a/packages/eda-postgres/cache/firefly-eda-postgres-components.php +++ b/packages/eda-postgres/cache/firefly-eda-postgres-components.php @@ -19,6 +19,9 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Database\\ConnectionResolverInterface', + ], ], 1 => [ 'class' => 'Firefly\\Eda\\Postgres\\PostgresOutboxAutoConfiguration', @@ -39,6 +42,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 1 => [ 'method' => 'eventPublisher', @@ -48,6 +53,11 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Database\\ConnectionResolverInterface', + 2 => 'Firefly\\Eda\\Bus\\SubscriberRegistry', + ], ], 2 => [ 'method' => 'outboxPreCommitHook', @@ -57,6 +67,13 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Database\\ConnectionResolverInterface', + 1 => 'Firefly\\Config\\Config', + 2 => 'Firefly\\Cqrs\\Handler\\HandlerManifest', + 3 => 'Firefly\\Cqrs\\Correlation\\CorrelationContext', + 4 => 'Firefly\\Eda\\Bus\\SubscriberRegistry', + ], ], 3 => [ 'method' => 'domainEventDispatcher', @@ -66,6 +83,11 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Data\\Domain\\AggregateTracker', + 1 => 'Firefly\\Context\\Event\\ApplicationEventPublisher', + 2 => 'Firefly\\Eda\\Postgres\\Outbox\\OutboxPreCommitHook', + ], ], 4 => [ 'method' => 'commandEventPublisher', @@ -75,6 +97,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 5 => [ 'method' => 'eventConsumer', @@ -84,8 +108,14 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Database\\ConnectionResolverInterface', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/eda-rabbitmq/cache/firefly-eda-rabbitmq-components.php b/packages/eda-rabbitmq/cache/firefly-eda-rabbitmq-components.php index 96d4c9a..a132da0 100644 --- a/packages/eda-rabbitmq/cache/firefly-eda-rabbitmq-components.php +++ b/packages/eda-rabbitmq/cache/firefly-eda-rabbitmq-components.php @@ -24,6 +24,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 1 => [ 'method' => 'connectionOpener', @@ -33,6 +36,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Eda\\Rabbitmq\\RabbitMqConnectionFactory', + ], ], 2 => [ 'method' => 'subscriberRegistry', @@ -42,6 +48,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 3 => [ 'method' => 'eventPublisher', @@ -51,6 +59,11 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Firefly\\Eda\\Rabbitmq\\RabbitMqConnectionFactory', + 2 => 'Firefly\\Eda\\Bus\\SubscriberRegistry', + ], ], 4 => [ 'method' => 'eventConsumer', @@ -60,9 +73,15 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Firefly\\Eda\\Rabbitmq\\RabbitMqConnectionFactory', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], 1 => [ 'class' => 'Firefly\\Eda\\Rabbitmq\\RabbitMqHealthIndicator', @@ -78,5 +97,8 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Eda\\Rabbitmq\\OpensConnection', + ], ], ]; diff --git a/packages/eda/cache/firefly-eda-components.php b/packages/eda/cache/firefly-eda-components.php index 480dd2c..97e73c1 100644 --- a/packages/eda/cache/firefly-eda-components.php +++ b/packages/eda/cache/firefly-eda-components.php @@ -24,6 +24,10 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Container\\Container', + ], ], 1 => [ 'method' => 'serializer', @@ -33,6 +37,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 2 => [ 'method' => 'deadLetterStore', @@ -42,8 +49,12 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/messaging/cache/firefly-messaging-components.php b/packages/messaging/cache/firefly-messaging-components.php index 8f86c7b..e3ca779 100644 --- a/packages/messaging/cache/firefly-messaging-components.php +++ b/packages/messaging/cache/firefly-messaging-components.php @@ -24,6 +24,10 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Container\\Container', + ], ], 1 => [ 'method' => 'deadLetterStore', @@ -33,8 +37,12 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/observability/cache/firefly-observability-components.php b/packages/observability/cache/firefly-observability-components.php index c9a7f3e..9ea8870 100644 --- a/packages/observability/cache/firefly-observability-components.php +++ b/packages/observability/cache/firefly-observability-components.php @@ -6,7 +6,7 @@ return [ 0 => [ - 'class' => 'Firefly\\Observability\\Endpoint\\MetricsEndpoint', + 'class' => 'Firefly\\Observability\\Endpoint\\HttpExchangesEndpoint', 'stereotype' => 'component', 'name' => null, 'scope' => 'Singleton', @@ -19,9 +19,13 @@ 'beans' => [ ], 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Observability\\HttpExchanges\\HttpExchangeRecorder', + 1 => 'Firefly\\Config\\Config', + ], ], 1 => [ - 'class' => 'Firefly\\Observability\\Endpoint\\PrometheusEndpoint', + 'class' => 'Firefly\\Observability\\Endpoint\\MetricsEndpoint', 'stereotype' => 'component', 'name' => null, 'scope' => 'Singleton', @@ -34,8 +38,49 @@ 'beans' => [ ], 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Observability\\Metrics\\MeterRegistry', + ], ], 2 => [ + 'class' => 'Firefly\\Observability\\Endpoint\\ProcessEndpoint', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Endpoint\\ActuatorEndpoint', + ], + 'beans' => [ + ], + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Observability\\HttpExchanges\\HttpExchangeRecorder', + 1 => 'Firefly\\Observability\\Process\\RuntimeSnapshot', + ], + ], + 3 => [ + 'class' => 'Firefly\\Observability\\Endpoint\\PrometheusEndpoint', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Endpoint\\ActuatorEndpoint', + ], + 'beans' => [ + ], + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Observability\\Metrics\\MeterRegistry', + 1 => 'Firefly\\Observability\\Prometheus\\PrometheusTextFormat', + ], + ], + 4 => [ 'class' => 'Firefly\\Observability\\ObservabilityAutoConfiguration', 'stereotype' => 'configuration', 'name' => null, @@ -54,6 +99,10 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', + 1 => 'Firefly\\Config\\Config', + ], ], 1 => [ 'method' => 'metricsRecorder', @@ -63,6 +112,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', + ], ], 2 => [ 'method' => 'prometheusTextFormat', @@ -72,6 +124,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 3 => [ 'method' => 'tracer', @@ -81,8 +135,23 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 4 => [ + 'method' => 'httpExchangeRecorder', + 'returns' => 'Firefly\\Observability\\HttpExchanges\\HttpExchangeRecorder', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', + 1 => 'Firefly\\Config\\Config', + ], + ], + 5 => [ 'method' => 'cqrsMetrics', 'returns' => 'Firefly\\Cqrs\\Metrics\\CqrsMetrics', 'name' => null, @@ -90,11 +159,35 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Observability\\Metrics\\MetricsRecorder', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], - 3 => [ + 5 => [ + 'class' => 'Firefly\\Observability\\Web\\HttpExchangeFilter', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => -100, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Web\\Filter\\WebFilter', + ], + 'beans' => [ + ], + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Observability\\HttpExchanges\\HttpExchangeRecorder', + 1 => 'Firefly\\Config\\Config', + ], + ], + 6 => [ 'class' => 'Firefly\\Observability\\Web\\MetricsFilter', 'stereotype' => 'component', 'name' => null, @@ -108,5 +201,8 @@ 'beans' => [ ], 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Observability\\Metrics\\MetricsRecorder', + ], ], ]; diff --git a/packages/observability/cache/firefly-observability-context.php b/packages/observability/cache/firefly-observability-context.php index 46a0315..07f7b3b 100644 --- a/packages/observability/cache/firefly-observability-context.php +++ b/packages/observability/cache/firefly-observability-context.php @@ -111,6 +111,17 @@ ], ], 4 => [ + 'method' => 'httpExchangeRecorder', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\Observability\\HttpExchanges\\HttpExchangeRecorder', + ], + ], + ], + ], + 5 => [ 'method' => 'cqrsMetrics', 'conditions' => [ 0 => [ @@ -132,6 +143,27 @@ ], ], 3 => [ + 'class' => 'Firefly\\Observability\\Web\\HttpExchangeFilter', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.observability.httpexchanges.enabled', + 1 => 'true', + 2 => true, + ], + ], + ], + 'beanConditions' => [ + ], + ], + 4 => [ 'class' => 'Firefly\\Observability\\Web\\MetricsFilter', 'postConstruct' => [ ], diff --git a/packages/observability/src/Endpoint/HttpExchangesEndpoint.php b/packages/observability/src/Endpoint/HttpExchangesEndpoint.php new file mode 100644 index 0000000..ec07b3c --- /dev/null +++ b/packages/observability/src/Endpoint/HttpExchangesEndpoint.php @@ -0,0 +1,138 @@ +recorder->exchanges(); + + $limit = $this->limit($request); + if ($limit !== null) { + $exchanges = array_slice($exchanges, 0, $limit); + } + + return EndpointResponse::json([ + 'exchanges' => array_map(static fn ($exchange): array => $exchange->toArray(), $exchanges), + 'count' => count($exchanges), + 'capacity' => $this->recorder->capacity(), + 'recorded' => $this->recorder->recorded(), + 'storage' => $this->recorder->storage(), + 'processLocal' => $this->recorder->processLocal(), + 'recording' => $this->recording(), + ]); + } + + /** + * `?limit=N` trims the newest-first list, so a dashboard panel showing ten rows fetches ten rows instead of + * the whole ring. Anything that is not a positive integer is ignored rather than rejected: a management + * endpoint answering 400 to a malformed query string turns a cosmetic client bug into a blank panel, and + * there is a correct, obvious answer available (the unfiltered list). + */ + /** + * Whether HttpExchangeFilter is actually REGISTERED — not merely whether the flag reads as truthy. + * + * The filter is gated by #[ConditionalOnProperty(havingValue: 'true', matchIfMissing: true)], and + * ConditionEvaluator compares the STRINGIFIED config value against the literal 'true'. A truthy spelling + * that is not that literal — `FIREFLY_HTTPEXCHANGES_ENABLED=1`, which Laravel's env() hands back as the + * string "1", or 'on'/'yes' — therefore drops the filter, while Config::bool() would happily call it + * enabled. Read through bool(), this endpoint would answer `"recording": true` beside a permanently empty + * list: exactly the "my application must be serving no traffic" dead end that this field exists to prevent. + * Mirroring the condition's own comparison keeps the reported flag equal to the observable behaviour. + */ + private function recording(): bool + { + $value = $this->config->has(self::ENABLED_KEY) ? $this->config->get(self::ENABLED_KEY) : null; + + if ($value === null) { + return true; + } + + if (is_bool($value)) { + return $value; + } + + return is_scalar($value) && (string) $value === 'true'; + } + + private function limit(EndpointRequest $request): ?int + { + $raw = $request->query['limit'] ?? null; + + if (is_int($raw)) { + return $raw > 0 ? $raw : null; + } + + if (is_string($raw) && preg_match('/^\d+$/', $raw) === 1 && (int) $raw > 0) { + return (int) $raw; + } + + return null; + } +} diff --git a/packages/observability/src/Endpoint/ProcessEndpoint.php b/packages/observability/src/Endpoint/ProcessEndpoint.php new file mode 100644 index 0000000..232ce25 --- /dev/null +++ b/packages/observability/src/Endpoint/ProcessEndpoint.php @@ -0,0 +1,101 @@ +bootedAt = $bootedAt ?? microtime(true); + } + + public function endpointId(): string + { + return 'process'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): EndpointResponse + { + $pid = getmypid(); + + return EndpointResponse::json([ + 'pid' => $pid === false ? 0 : $pid, + 'uptimeMs' => round((microtime(true) - $this->bootedAt) * 1000, 3), + 'php' => [ + 'version' => PHP_VERSION, + 'sapi' => PHP_SAPI, + ], + 'memory' => $this->runtime->memory(), + 'opcache' => $this->runtime->opcache(), + // The request count the framework is ALREADY keeping — the exchange recorder's monotonic counter — + // rather than a second counter invented for this endpoint. `recorded` is every request the filter + // has seen, cross-process when the recorder is cache-backed. It is zero when recording is switched + // off, which is what /actuator/httpexchanges' `recording` flag is there to explain. + // + // How many exchanges are CURRENTLY BUFFERED is deliberately not reported here even though it would + // read naturally alongside `capacity`: answering it means calling exchanges(), which on the + // cache-backed recorder is a capacity-wide multi-get. This is the endpoint a dashboard POLLS to plot + // memory over time, so it must stay O(1) per call — a memory chart that issues a hundred cache reads + // per data point is a load generator, not a monitor. /actuator/httpexchanges reports `count`. + 'requests' => [ + 'recorded' => $this->recorder->recorded(), + 'capacity' => $this->recorder->capacity(), + ], + ]); + } +} diff --git a/packages/observability/src/HttpExchanges/CacheHttpExchangeRecorder.php b/packages/observability/src/HttpExchanges/CacheHttpExchangeRecorder.php new file mode 100644 index 0000000..bdf9051 --- /dev/null +++ b/packages/observability/src/HttpExchanges/CacheHttpExchangeRecorder.php @@ -0,0 +1,193 @@ +seq`, and `capacity` independent rows under `slot:`. + * + * - A writer takes a slot by ATOMICALLY incrementing `seq` and using `seq % capacity`. Two workers finishing a + * request in the same microsecond therefore get two DIFFERENT slots and both rows survive. The obvious + * alternative — keeping the whole ring in one cache key — is a read-modify-write, and under concurrency it + * loses every exchange but the last writer's. That is the same reason CacheMeterRegistry counts through + * increment() rather than get-add-put. + * - Each row stores its own `seq` alongside the exchange, so exchanges() can sort newest-first from the stored + * ordinal instead of trusting the slot index (which wraps) or the timestamp (which is only as monotonic as + * the clocks of the machines writing it — under a load balancer, not very). + * - Rows are stored as PLAIN ARRAYS, never serialized HttpExchange objects. A rolling deploy in which two + * versions of this class are live at once, or a cache retained across an upgrade, must not be able to fatal a + * worker on `__PHP_Incomplete_Class`; HttpExchange::fromArray() validates and returns null for anything it + * does not recognise, and the row is skipped. + * + * KNOWN, BOUNDED IMPRECISIONS — stated because a rolling buffer that pretends to be a transaction log is worse + * than one that admits what it is: + * + * 1. A writer that stalls between taking `seq` and writing its row can be overtaken by a writer capacity + * ordinals later targeting the same slot, and would then overwrite a NEWER row with an older one. The + * stale-write guard below reads the slot first and declines to overwrite a higher `seq`, which closes the + * window in practice; it is a guard, not a lock, and the residual failure is one row out of order in the + * list — never a crash and never a lost newer row that matters. + * 2. Lowering `capacity` between deploys orphans the slots above the new capacity: they stop being read (and + * expire on their own if a ttl is configured). Raising it makes the extra slots read as empty until traffic + * fills them. Neither corrupts anything. + * 3. The store is shared. Two applications pointed at the same Redis database with the same prefix will + * interleave their exchanges. That is true of every cache-backed collector in this package, and the prefix + * is constructor-injected so it can be made unique. + */ +final class CacheHttpExchangeRecorder implements HttpExchangeRecorder +{ + private const SEQ = 'seq'; + + private const SLOT = 'slot:'; + + private readonly int $capacity; + + public function __construct( + private readonly Cache $cache, + private readonly string $storeName, + int $capacity = HttpExchangeCapacity::DEFAULT, + private readonly string $prefix = 'firefly:httpexchanges:', + private readonly ?int $ttlSeconds = null, + ) { + $this->capacity = HttpExchangeCapacity::clamp($capacity); + } + + public function record(HttpExchange $exchange): void + { + $seq = $this->nextSequence(); + $key = $this->prefix.self::SLOT.($seq % $this->capacity); + + // Stale-write guard (imprecision 1 above): never let a straggler overwrite a row that a later writer + // already put in this slot. + $existing = $this->cache->get($key); + if (is_array($existing) && is_int($existing['seq'] ?? null) && $existing['seq'] > $seq) { + return; + } + + $this->put($key, ['seq' => $seq, 'exchange' => $exchange->toArray()]); + } + + /** + * Every buffered exchange, newest first, read in ONE multi-get rather than `capacity` round trips. + * + * getMultiple() is the PSR-16 method Illuminate\Contracts\Cache\Repository inherits, and Illuminate's + * implementation routes it to the store's native many()/MGET — so a 100-slot ring costs one Redis command, + * not 100. That is the whole reason the slot keys are enumerable by index instead of being hashed: a layout + * whose keys cannot be listed would force a key scan, which several drivers do not support at all. + * + * @return list + */ + public function exchanges(): array + { + $keys = []; + for ($slot = 0; $slot < $this->capacity; $slot++) { + $keys[] = $this->prefix.self::SLOT.$slot; + } + + /** @var list $rows */ + $rows = []; + + foreach ($this->cache->getMultiple($keys) as $value) { + if (! is_array($value)) { + continue; + } + + $seq = $value['seq'] ?? null; + $payload = $value['exchange'] ?? null; + if (! is_int($seq) || ! is_array($payload)) { + continue; + } + + $exchange = HttpExchange::fromArray($payload); + if ($exchange === null) { + continue; + } + + $rows[] = ['seq' => $seq, 'exchange' => $exchange]; + } + + usort($rows, static fn (array $a, array $b): int => $b['seq'] <=> $a['seq']); + + return array_map(static fn (array $row): HttpExchange => $row['exchange'], $rows); + } + + public function capacity(): int + { + return $this->capacity; + } + + public function recorded(): int + { + $value = $this->cache->get($this->prefix.self::SEQ); + + return is_numeric($value) ? (int) $value : 0; + } + + public function storage(): string + { + return 'cache:'.$this->storeName; + } + + public function processLocal(): bool + { + return false; + } + + /** + * The atomic slot allocator. + * + * increment() returns false on a store that has no value under the key yet (and on drivers that cannot + * increment a missing key), so the counter is seeded and the increment retried — the same seed-and-retry + * CacheMeterRegistry::add() uses, for the same reason: losing the sample is not an option. + * + * The final fallback is the millisecond clock, and it is deliberate rather than defensive noise. If a store + * genuinely cannot increment (a custom driver, or a key holding a non-numeric value someone else wrote), + * returning a constant would send EVERY exchange to slot 0 and the buffer would degenerate to a single row + * that is silently overwritten forever — precisely the "worse than no buffer" failure this whole class + * exists to avoid. A millisecond ordinal is monotonic, distributes across slots, and sorts correctly; its + * only cost is that two exchanges completing in the same millisecond may contend for a slot, which is a far + * smaller lie than a permanently one-row buffer. + */ + private function nextSequence(): int + { + $key = $this->prefix.self::SEQ; + + $next = $this->cache->increment($key); + if ($next === false) { + $this->cache->add($key, 0, $this->ttlSeconds); + $next = $this->cache->increment($key); + } + + if (is_int($next)) { + return $next; + } + + return (int) round(microtime(true) * 1000); + } + + /** @param array{seq: int, exchange: array} $value */ + private function put(string $key, array $value): void + { + if ($this->ttlSeconds === null) { + $this->cache->forever($key, $value); + + return; + } + + $this->cache->put($key, $value, $this->ttlSeconds); + } +} diff --git a/packages/observability/src/HttpExchanges/HeaderMasker.php b/packages/observability/src/HttpExchanges/HeaderMasker.php new file mode 100644 index 0000000..2f1af52 --- /dev/null +++ b/packages/observability/src/HttpExchanges/HeaderMasker.php @@ -0,0 +1,74 @@ +` straight into the buffer while looking, in review, exactly like the endpoint that is already trusted + * to mask secrets. + * + * So: the EnvEndpoint alternation is kept as-is (an X-Api-Key or an X-Csrf-Token still matches on `key`/`token`, + * and a future config-side addition stays meaningful here), and the three header-specific credential names are + * added to it. Widening a mask is always safe — the failure mode is a masked header an operator wanted to see, + * not a leaked one. Narrowing it never is. + */ +final class HeaderMasker +{ + public const MASK = '******'; + + /** + * EnvEndpoint::SENSITIVE verbatim, plus authorization/cookie (which covers set-cookie as a substring match). + * `proxy-authorization` and `www-authenticate` are covered by the `authorization`/`authenticate`-adjacent + * `authorization` alternative and by `credential` respectively; `x-amz-security-token` and friends fall out + * of `token`/`secret`. + */ + private const SENSITIVE = '/password|secret|token|key|credential|passwd|authorization|cookie|authenticate/i'; + + /** + * Flattens Symfony's header bag (name => list of values) into a single string per header, masking any header + * whose NAME matches. Multi-valued headers are joined with ", " — the same folding RFC 9110 §5.3 permits — + * because a dashboard cell renders a string, and because a header with two values is not more interesting + * than a header with one. + * + * Null entries (Symfony models a header set to null as `[null]`) become empty strings rather than being + * dropped, so "the header was present but empty" stays distinguishable from "the header was absent". + * + * @param array> $headers + * @return array + */ + public static function mask(array $headers): array + { + $masked = []; + + foreach ($headers as $name => $values) { + $name = strtolower($name); + + if (preg_match(self::SENSITIVE, $name) === 1) { + $masked[$name] = self::MASK; + + continue; + } + + $masked[$name] = implode(', ', array_map(static fn (?string $value): string => $value ?? '', $values)); + } + + ksort($masked); + + return $masked; + } +} diff --git a/packages/observability/src/HttpExchanges/HttpExchange.php b/packages/observability/src/HttpExchanges/HttpExchange.php new file mode 100644 index 0000000..7de0a3b --- /dev/null +++ b/packages/observability/src/HttpExchanges/HttpExchange.php @@ -0,0 +1,159 @@ + $requestHeaders masked and opt-in; empty unless header capture is on. + */ + public function __construct( + public string $timestamp, + public string $method, + public string $uri, + public int $status, + public float $durationMs, + public ?string $correlationId, + public array $requestHeaders = [], + ) {} + + /** + * Formats a `microtime(true)` float as ISO-8601 UTC with microsecond precision. + * + * Not `(new DateTimeImmutable('@'.$epoch))`: the `@` seconds-since-epoch constructor TRUNCATES to whole + * seconds, so every row in a buffer filled by a burst of traffic would carry the same timestamp and the + * newest-first ordering would look arbitrary to anyone reading it. `createFromFormat('U.u', ...)` keeps the + * microseconds, and number_format (not (string) casting, which switches to scientific notation and drops + * precision on large floats) produces the fixed 6-decimal input that format demands. + */ + public static function timestampFrom(float $epochSeconds): string + { + $formatted = DateTimeImmutable::createFromFormat('U.u', number_format($epochSeconds, 6, '.', '')); + + if ($formatted === false) { + // Unreachable for any finite float, but createFromFormat's signature admits false and PHPStan is + // right to insist: falling back to "now" keeps a row in the buffer rather than dropping it. + $formatted = new DateTimeImmutable; + } + + return $formatted->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s.u\Z'); + } + + /** + * The JSON row. + * + * `requestHeaders` is OMITTED entirely when empty rather than emitted as an empty collection. json_encode + * renders an empty PHP array as `[]`, not `{}`, so a client deserialising the field into a map would break + * on exactly the requests that had nothing to show — the same empty-array/empty-object hazard + * ActuatorDispatchAction::toResponse() documents for the top-level body. Present-or-absent is a distinction + * every JSON client already handles correctly. + * + * @return array{timestamp: string, method: string, uri: string, status: int, durationMs: float, correlationId: string|null, requestHeaders?: array} + */ + public function toArray(): array + { + $row = [ + 'timestamp' => $this->timestamp, + 'method' => $this->method, + 'uri' => $this->uri, + 'status' => $this->status, + 'durationMs' => $this->durationMs, + 'correlationId' => $this->correlationId, + ]; + + if ($this->requestHeaders !== []) { + $row['requestHeaders'] = $this->requestHeaders; + } + + return $row; + } + + /** + * Rebuilds a row written by an earlier process (CacheHttpExchangeRecorder round-trips exchanges through the + * cache as plain arrays, never serialized objects, so a deploy that changes this class cannot fatal on a + * stale payload). + * + * Returns null — rather than throwing or fabricating defaults — for any row that is not shaped like an + * exchange. A shared cache store is not a private data structure: another application on the same Redis, a + * key collision, or a rolling deploy mid-schema-change can all put something else under these keys, and a + * dashboard panel must degrade to "one fewer row" rather than to a 500 on the whole endpoint. + * + * @param array $row + */ + public static function fromArray(array $row): ?self + { + $timestamp = $row['timestamp'] ?? null; + $method = $row['method'] ?? null; + $uri = $row['uri'] ?? null; + $status = $row['status'] ?? null; + $durationMs = $row['durationMs'] ?? null; + $correlationId = $row['correlationId'] ?? null; + + // durationMs accepts int as well as float on the way back in: a cache driver that round-trips through + // JSON (rather than PHP serialize()) writes 12.0 and reads back the integer 12, and rejecting that row + // would silently drop every exchange that happened to land on a whole millisecond. + if (! is_string($timestamp) || ! is_string($method) || ! is_string($uri) || ! is_int($status) || ! is_int($durationMs) && ! is_float($durationMs)) { + return null; + } + + $headers = []; + if (is_array($row['requestHeaders'] ?? null)) { + /** @var array $raw */ + $raw = $row['requestHeaders']; + foreach ($raw as $name => $value) { + if (is_string($name) && is_string($value)) { + $headers[$name] = $value; + } + } + } + + return new self( + $timestamp, + $method, + $uri, + $status, + (float) $durationMs, + is_string($correlationId) ? $correlationId : null, + $headers, + ); + } +} diff --git a/packages/observability/src/HttpExchanges/HttpExchangeCapacity.php b/packages/observability/src/HttpExchanges/HttpExchangeCapacity.php new file mode 100644 index 0000000..a893e3e --- /dev/null +++ b/packages/observability/src/HttpExchanges/HttpExchangeCapacity.php @@ -0,0 +1,36 @@ + + */ + public function exchanges(): array; + + /** How many exchanges the ring holds before the oldest is evicted. */ + public function capacity(): int; + + /** + * Total exchanges ever recorded — monotonic, and NOT capped at capacity(). Cross-process for the + * cache-backed recorder, process-local for the in-memory one. `recorded() - count(exchanges())` is how many + * have been evicted, which is the number a dashboard needs to say "showing the last 100 of 41,882". + */ + public function recorded(): int; + + /** 'memory', or 'cache:' — surfaced verbatim in the endpoint payload. */ + public function storage(): string; + + /** + * True when the buffer lives only in the current PHP process, i.e. when a reader in another process (every + * reader, under PHP-FPM) will see nothing this process recorded. + */ + public function processLocal(): bool; +} diff --git a/packages/observability/src/HttpExchanges/InMemoryHttpExchangeRecorder.php b/packages/observability/src/HttpExchanges/InMemoryHttpExchangeRecorder.php new file mode 100644 index 0000000..3dcb0ba --- /dev/null +++ b/packages/observability/src/HttpExchanges/InMemoryHttpExchangeRecorder.php @@ -0,0 +1,77 @@ +ring` is + * always in chronological order with no head index to reason about, so exchanges() is one array_reverse and + * cannot be off by one. + */ +final class InMemoryHttpExchangeRecorder implements HttpExchangeRecorder +{ + /** @var list oldest first */ + private array $ring = []; + + private int $recorded = 0; + + private readonly int $capacity; + + public function __construct(int $capacity = HttpExchangeCapacity::DEFAULT) + { + $this->capacity = HttpExchangeCapacity::clamp($capacity); + } + + public function record(HttpExchange $exchange): void + { + $this->recorded++; + $this->ring[] = $exchange; + + while (count($this->ring) > $this->capacity) { + array_shift($this->ring); + } + } + + /** @return list */ + public function exchanges(): array + { + return array_reverse($this->ring); + } + + public function capacity(): int + { + return $this->capacity; + } + + public function recorded(): int + { + return $this->recorded; + } + + public function storage(): string + { + return 'memory'; + } + + public function processLocal(): bool + { + return true; + } +} diff --git a/packages/observability/src/ObservabilityAutoConfiguration.php b/packages/observability/src/ObservabilityAutoConfiguration.php index 08f295c..a0e18bf 100644 --- a/packages/observability/src/ObservabilityAutoConfiguration.php +++ b/packages/observability/src/ObservabilityAutoConfiguration.php @@ -12,6 +12,10 @@ use Firefly\Context\Condition\Attributes\ConditionalOnProperty; use Firefly\Cqrs\Metrics\CqrsMetrics; use Firefly\Observability\Cqrs\MeterRegistryCqrsMetrics; +use Firefly\Observability\HttpExchanges\CacheHttpExchangeRecorder; +use Firefly\Observability\HttpExchanges\HttpExchangeCapacity; +use Firefly\Observability\HttpExchanges\HttpExchangeRecorder; +use Firefly\Observability\HttpExchanges\InMemoryHttpExchangeRecorder; use Firefly\Observability\Metrics\CacheMeterRegistry; use Firefly\Observability\Metrics\MeterRegistry; use Firefly\Observability\Metrics\MetricsRecorder; @@ -108,6 +112,49 @@ public function tracer(): Tracer return new NoOpTracer; } + /** + * The rolling HTTP exchange buffer behind /actuator/httpexchanges and the request counter in + * /actuator/process. In-memory by default; cache-backed when `firefly.observability.httpexchanges.store` + * names a cache store — the SAME opt-in shape as meterRegistry() above, for the same reason, in a case where + * it matters more. + * + * The PHP process model makes the in-memory default genuinely empty rather than merely stale under PHP-FPM: + * each request is a fresh process, the ring is created empty, and the request that renders the endpoint has + * not been recorded yet because HttpExchangeFilter records on the way out. So the endpoint reports + * `storage`/`processLocal` in its payload rather than leaving an operator to conclude the application is + * serving no traffic. See HttpExchangeRecorder's docblock for the whole failure mode. + * + * DELIBERATELY NOT GATED on any #[ConditionalOnProperty]. `firefly.observability.httpexchanges.enabled` + * gates the FILTER — i.e. whether anything is written — while this bean must stay bound either way, because + * HttpExchangesEndpoint and ProcessEndpoint both depend on it and both have something true and useful to say + * when recording is off ("recording": false, "recorded": 0). Un-binding it would turn a switched-off feature + * into two 404s that explain nothing, which is the opposite of what an operator staring at an empty + * dashboard panel needs. Cost when disabled: one empty array. + */ + #[Bean] + #[ConditionalOnMissingBean(HttpExchangeRecorder::class)] + public function httpExchangeRecorder(Container $container, Config $config): HttpExchangeRecorder + { + $capacity = $config->int('firefly.observability.httpexchanges.capacity', HttpExchangeCapacity::DEFAULT); + $store = $config->string('firefly.observability.httpexchanges.store', ''); + + if ($store === '' || ! $container->bound('cache')) { + return new InMemoryHttpExchangeRecorder($capacity); + } + + /** @var Factory $factory */ + $factory = $container->make('cache'); + $ttl = $config->int('firefly.observability.httpexchanges.ttl', 0); + + return new CacheHttpExchangeRecorder( + $factory->store($store), + $store, + $capacity, + 'firefly:httpexchanges:', + $ttl > 0 ? $ttl : null, + ); + } + #[Bean] #[ConditionalOnMissingBean(CqrsMetrics::class)] #[ConditionalOnProperty(name: 'firefly.observability.metrics.enabled', havingValue: 'true', matchIfMissing: true)] diff --git a/packages/observability/src/Process/RuntimeSnapshot.php b/packages/observability/src/Process/RuntimeSnapshot.php new file mode 100644 index 0000000..5bb43df --- /dev/null +++ b/packages/observability/src/Process/RuntimeSnapshot.php @@ -0,0 +1,140 @@ + memory_get_usage(true), + 'peakBytes' => memory_get_peak_usage(true), + 'limitBytes' => self::parseMemoryLimit($raw), + 'limit' => $raw, + ]; + } + + /** + * Bytes for a PHP shorthand ini value ("512M", "1G", "134217728"), or -1 for unlimited/unparseable. + * + * Not `(int) $limit`: PHP's cast stops at the first non-digit, so "512M" would become 512 and a dashboard + * would report a half-kilobyte memory limit next to a two-megabyte usage figure — a number that looks like + * an emergency and is off by a factor of a million. + */ + public static function parseMemoryLimit(string $limit): int + { + $limit = trim($limit); + + if (preg_match('/^(-?\d+)\s*([KMG])?$/i', $limit, $matches) !== 1) { + return -1; + } + + $value = (int) $matches[1]; + if ($value < 0) { + return -1; + } + + return match (strtoupper($matches[2] ?? '')) { + 'K' => $value * 1024, + 'M' => $value * 1024 * 1024, + 'G' => $value * 1024 * 1024 * 1024, + default => $value, + }; + } + + /** + * The opcache aggregate counters, or null when there is nothing to report. + * + * Null covers three distinct situations that a caller cannot usefully tell apart anyway: the extension is + * not loaded (CLI runs usually), it is loaded but disabled, or `opcache.restrict_api` forbids this script + * from asking. The last one is why the call is wrapped: a blocked `opcache_get_status()` emits an E_WARNING, + * and Laravel's error handler converts warnings into ErrorException — so the naive call would throw out of + * an actuator endpoint and render a 500 for a machine that simply has the API locked down. A hardened + * production box is exactly where an operator opens this endpoint. + * + * `hitRate` is opcache's own `opcache_hit_rate`, a PERCENTAGE (0-100), rounded to two decimals. A rate below + * ~95% on a warm process usually means the cache is too small or is being thrashed by `revalidate_freq`. + * + * @return array{enabled: bool, hits: int, misses: int, hitRate: float, usedMemoryBytes: int, freeMemoryBytes: int, wastedMemoryBytes: int, cachedScripts: int}|null + */ + public function opcache(): ?array + { + if (! function_exists('opcache_get_status')) { + return null; + } + + try { + $status = opcache_get_status(false); + } catch (Throwable) { + return null; + } + + if (! is_array($status)) { + return null; + } + + $statistics = $status['opcache_statistics'] ?? null; + $memory = $status['memory_usage'] ?? null; + + if (! is_array($statistics) || ! is_array($memory)) { + return null; + } + + return [ + 'enabled' => ($status['opcache_enabled'] ?? false) === true, + 'hits' => $this->int($statistics['hits'] ?? null), + 'misses' => $this->int($statistics['misses'] ?? null), + 'hitRate' => round($this->float($statistics['opcache_hit_rate'] ?? null), 2), + 'usedMemoryBytes' => $this->int($memory['used_memory'] ?? null), + 'freeMemoryBytes' => $this->int($memory['free_memory'] ?? null), + 'wastedMemoryBytes' => $this->int($memory['wasted_memory'] ?? null), + 'cachedScripts' => $this->int($statistics['num_cached_scripts'] ?? null), + ]; + } + + /** + * opcache reports large counters as floats once they exceed PHP_INT_MAX on 32-bit builds, and older + * extension versions have shipped strings for one or two of these. Coerce through is_numeric rather than + * asserting a shape the extension does not actually guarantee across versions. + */ + private function int(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } + + private function float(mixed $value): float + { + return is_numeric($value) ? (float) $value : 0.0; + } +} diff --git a/packages/observability/src/Web/HttpExchangeFilter.php b/packages/observability/src/Web/HttpExchangeFilter.php new file mode 100644 index 0000000..bf796ec --- /dev/null +++ b/packages/observability/src/Web/HttpExchangeFilter.php @@ -0,0 +1,212 @@ + */ + private readonly array $excludes; + + public function __construct(private readonly HttpExchangeRecorder $recorder, Config $config) + { + $this->includeHeaders = $config->bool(self::HEADERS_KEY, false); + $this->excludes = $this->configuredExcludes($config); + } + + /** + * Paths that are recorded by nobody. + * + * Defaults to the management base path (and everything under it), because a dashboard is a POLLING client: + * left in, a panel refreshing /actuator/httpexchanges every five seconds would — on a long-lived worker, + * which is the only place the default recorder retains anything at all — evict every genuine request from a + * 100-row ring within minutes and then show the operator nothing but their own polling. The buffer would be + * perfectly accurate and completely useless. + * + * Setting firefly.observability.httpexchanges.exclude REPLACES this default with the given glob list (an + * empty list means record everything, including management traffic). Anyone running the admin UI will want + * to add its base path — firefly.admin.base-path, '/firefly' by default — for exactly the same reason; it is + * not excluded automatically because reaching into another package's configuration key to guess at its + * mount point is the kind of hidden coupling that breaks the day someone changes it. + * + * @return list + */ + private function configuredExcludes(Config $config): array + { + if (! $config->has(self::EXCLUDE_KEY)) { + $base = trim($config->string(self::BASE_PATH_KEY, '/actuator'), '/'); + $base = $base === '' ? 'actuator' : $base; + + return [$base, $base.'/*']; + } + + $patterns = []; + foreach ($config->array(self::EXCLUDE_KEY, []) as $pattern) { + if (is_string($pattern) && $pattern !== '') { + $patterns[] = trim($pattern, '/'); + } + } + + return $patterns; + } + + /** @return list */ + protected function excludes(): array + { + return $this->excludes; + } + + protected function doFilter(Request $request, Closure $next): mixed + { + $start = microtime(true); + + try { + $response = $next($request); + $this->record($request, $start, $response instanceof Response ? $response->getStatusCode() : 200); + + return $response; + } catch (Throwable $e) { + // A request that threw is exactly the one an operator came to the dashboard to find, so record it — + // as the 500 the error renderer is about to produce — before rethrowing so ProblemDetailsRenderer + // still handles it. Mirrors MetricsFilter's SERVER_ERROR path. + $this->record($request, $start, 500); + + throw $e; + } + } + + /** + * Best-effort by construction: a recording failure must never change the response. + * + * With the cache-backed recorder every request performs cache I/O, so a Redis blip would otherwise turn + * every 200 in the application into a 500 — an availability incident caused entirely by the telemetry that + * was supposed to help diagnose one. An exchange row has no effect on the response, so the only correct + * behaviour on failure is to lose the row. (MetricsFilter deliberately does NOT carry this guard today; that + * asymmetry is called out here so it reads as a decision about this filter rather than an oversight in the + * other, and it is worth revisiting there for the same reason.) + */ + private function record(Request $request, float $start, int $status): void + { + try { + $this->recorder->record(new HttpExchange( + HttpExchange::timestampFrom($start), + $request->getMethod(), + $this->uri($request), + $status, + round((microtime(true) - $start) * 1000, 3), + $this->correlationId($request), + $this->includeHeaders ? HeaderMasker::mask($request->headers->all()) : [], + )); + } catch (Throwable) { + // Intentionally swallowed — see the docblock. The row is lost; the response is not. + } + } + + /** + * The ROUTE TEMPLATE ('/users/{id}') whenever the router matched one, which is what makes a 100-row buffer + * readable: a busy endpoint would otherwise fill the whole ring with '/users/41', '/users/42', '/users/43' + * and an operator scrolling it would learn nothing they did not already know. + * + * The fallback for an unmatched route (every 404) is the raw path — NOT MetricsFilter's bounded 'UNKNOWN' + * sentinel, and the difference is not an inconsistency. There, the value becomes a metric TAG and an + * unbounded tag is an unbounded series count, a permanent memory-growth vector under Octane. Here the value + * lands in a ring of fixed size, so cardinality costs nothing — and 'UNKNOWN' would delete the single most + * useful thing this endpoint does, which is telling an operator WHICH url is 404ing. + * + * getPathInfo() is used rather than getRequestUri() so the QUERY STRING never reaches the buffer: + * '?api_key=...', '?token=...' and password-reset links live there, and a recorded url with credentials in + * it is the leak this feature is otherwise carefully designed to avoid. The result is capped at + * MAX_URI_LENGTH. + */ + private function uri(Request $request): string + { + $route = $request->route(); + + $uri = $route !== null + ? '/'.ltrim((string) $route->uri(), '/') + : '/'.ltrim($request->getPathInfo(), '/'); + + return mb_strimwidth($uri, 0, self::MAX_URI_LENGTH, '…'); + } + + /** + * Read off the REQUEST HEADER rather than out of Context, even though CorrelationIdLogProcessor reads the + * Context copy. + * + * CorrelationIdFilter writes both: it mints or accepts the id, calls Context::add('firefly.correlation_id'), + * and sets the header back onto the request — and FilterChainRegistrar PREPENDS it ahead of every discovered + * WebFilter, so by the time this filter runs the header is always populated. The two sources therefore carry + * the same value, and the header is the better one to depend on: Illuminate\Support\Facades\Context throws + * "A facade root has not been set" without a booted application, which would make this filter untestable + * against a bare Request the way MetricsFilterTest already tests its twin. A filter whose only unit test + * needs a full framework boot is a filter whose edge cases stop being tested. + */ + private function correlationId(Request $request): ?string + { + $value = $request->headers->get(CorrelationIdFilter::HEADER); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/packages/observability/tests/CapstoneHttpExchangesDisabledTest.php b/packages/observability/tests/CapstoneHttpExchangesDisabledTest.php new file mode 100644 index 0000000..fbbc3cb --- /dev/null +++ b/packages/observability/tests/CapstoneHttpExchangesDisabledTest.php @@ -0,0 +1,46 @@ +get('/demo/7')->assertStatus(200); + + $this->getJson('/actuator/httpexchanges') + ->assertStatus(200) + ->assertJsonPath('exchanges', []) + ->assertJsonPath('count', 0) + ->assertJsonPath('recorded', 0) + ->assertJsonPath('recording', false); + + expect($this->app()->bound(HttpExchangeRecorder::class))->toBeTrue(); +}); + +it('leaves metrics entirely alone when only http exchanges are switched off', function () { + /** @var ObservabilityHttpExchangesDisabledCapstoneTestCase $this */ + expect($this->app()->make(CqrsMetrics::class))->toBeInstanceOf(MeterRegistryCqrsMetrics::class); + + $this->get('/actuator/prometheus')->assertStatus(200); + $this->getJson('/actuator/metrics')->assertStatus(200); +}); diff --git a/packages/observability/tests/CapstoneHttpExchangesTest.php b/packages/observability/tests/CapstoneHttpExchangesTest.php new file mode 100644 index 0000000..464918b --- /dev/null +++ b/packages/observability/tests/CapstoneHttpExchangesTest.php @@ -0,0 +1,134 @@ + + */ +function capstoneExchangesBody(ObservabilityCapstoneTestCase $case): array +{ + /** @var array $decoded */ + $decoded = json_decode($case->responseBody($case->getJson('/actuator/httpexchanges')), true, 512, JSON_THROW_ON_ERROR); + + return $decoded; +} + +/** + * @param array $body + * @return list> + */ +function capstoneExchangeRows(array $body): array +{ + $value = $body['exchanges'] ?? null; + if (! is_array($value)) { + throw new RuntimeException('Expected [exchanges] to be an array.'); + } + + $rows = []; + foreach ($value as $row) { + if (! is_array($row)) { + throw new RuntimeException('Expected each [exchanges] entry to be an array.'); + } + $rows[] = $row; + } + + return $rows; +} + +it('records a real request through the discovered filter and serves it back from /actuator/httpexchanges', function () { + /** @var ObservabilityCapstoneTestCase $this */ + $this->get('/demo/7')->assertStatus(200); + + $body = capstoneExchangesBody($this); + $rows = capstoneExchangeRows($body); + + expect($rows)->toHaveCount(1) + // The ROUTE TEMPLATE, not '/demo/7' — a busy endpoint must not be able to fill the whole ring with one + // route's concrete paths. + ->and($rows[0]['uri'])->toBe('/demo/{id}') + ->and($rows[0]['method'])->toBe('GET') + ->and($rows[0]['status'])->toBe(200) + ->and($rows[0]['durationMs'])->toBeFloat() + ->and($rows[0]['timestamp'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/') + // No bodies, ever; no headers unless capture is explicitly enabled. + ->and(array_key_exists('requestHeaders', $rows[0]))->toBeFalse() + ->and($rows[0])->not->toHaveKey('requestBody') + ->and($rows[0])->not->toHaveKey('responseBody') + ->and($body['recording'])->toBeTrue() + ->and($body['storage'])->toBe('memory'); +}); + +/** + * The correlation id the framework already generates: web's CorrelationIdFilter is PREPENDED ahead of every + * discovered WebFilter by FilterChainRegistrar, so it has already minted the id and written it back onto the + * request by the time this filter reads it — which is why the filter reads the request header rather than the + * Context copy, and why the recorded id is exactly the one echoed on the response. + */ +it('stamps each exchange with the same correlation id the framework echoes on the response', function () { + /** @var ObservabilityCapstoneTestCase $this */ + $response = $this->get('/demo/9'); + $echoed = $response->headers->get('X-Correlation-Id'); + + $rows = capstoneExchangeRows(capstoneExchangesBody($this)); + + expect($echoed)->not->toBeNull() + ->and($rows[0]['correlationId'])->toBe($echoed); +}); + +/** + * A dashboard is a polling client: if its own /actuator/* requests were recorded, a panel refreshing every few + * seconds would evict every genuine request from the ring and then show the operator nothing but their own + * polling. + */ +it('keeps management traffic out of the buffer', function () { + /** @var ObservabilityCapstoneTestCase $this */ + $this->getJson('/actuator/health')->assertStatus(200); + $this->getJson('/actuator/httpexchanges')->assertStatus(200); + + expect(capstoneExchangeRows(capstoneExchangesBody($this)))->toBe([]); +}); + +it('reports newest-first ordering and the evicted history across several real requests', function () { + /** @var ObservabilityCapstoneTestCase $this */ + $this->get('/demo/1'); + $this->get('/demo/2'); + $this->get('/demo/3'); + + $body = capstoneExchangesBody($this); + + expect($body['count'])->toBe(3) + ->and($body['recorded'])->toBe(3) + ->and($body['capacity'])->toBe(100); + + // ?limit trims the newest-first list so a ten-row panel fetches ten rows. + /** @var array $limited */ + $limited = json_decode($this->responseBody($this->getJson('/actuator/httpexchanges?limit=2')), true, 512, JSON_THROW_ON_ERROR); + expect($limited['count'])->toBe(2); +}); + +it('binds the in-memory recorder by default and mounts /actuator/process alongside it', function () { + /** @var ObservabilityCapstoneTestCase $this */ + expect($this->app()->make(HttpExchangeRecorder::class))->toBeInstanceOf(InMemoryHttpExchangeRecorder::class); + + $this->get('/demo/4'); + + $this->getJson('/actuator/process') + ->assertStatus(200) + ->assertJsonPath('php.sapi', PHP_SAPI) + ->assertJsonPath('requests.recorded', 1) + ->assertJsonPath('requests.capacity', 100) + ->assertJsonStructure(['pid', 'uptimeMs', 'php' => ['version', 'sapi'], 'memory' => ['usedBytes', 'peakBytes', 'limitBytes', 'limit'], 'requests']); +}); diff --git a/packages/observability/tests/Endpoint/HttpExchangesEndpointTest.php b/packages/observability/tests/Endpoint/HttpExchangesEndpointTest.php new file mode 100644 index 0000000..696926e --- /dev/null +++ b/packages/observability/tests/Endpoint/HttpExchangesEndpointTest.php @@ -0,0 +1,164 @@ +|string`; narrow it through real control flow rather than a + * suppressing type-override docblock (the actuator introspectionJsonBody() idiom). Named distinctly so the file + * has no top-level-function collision when Pest loads the whole suite into one process. + * + * @param array $firefly + * @param array $query + * @return array + */ +function httpExchangesBody(HttpExchangeRecorder $recorder, array $firefly = [], array $query = []): array +{ + $endpoint = new HttpExchangesEndpoint($recorder, new Config(new ConfigRepository($firefly))); + $body = $endpoint->handle(new EndpointRequest('GET', [], $query))->body; + + if (! is_array($body)) { + throw new RuntimeException('Expected a JSON (array) response body.'); + } + + return $body; +} + +function httpExchangesFixture(string $uri, int $status, string $timestamp): HttpExchange +{ + return new HttpExchange($timestamp, 'GET', $uri, $status, 4.25, 'corr-'.$status); +} + +/** The payload shape a dashboard renders, asserted verbatim. */ +it('serves the buffered exchanges newest-first with the storage model that explains them', function () { + $recorder = new InMemoryHttpExchangeRecorder(50); + $recorder->record(httpExchangesFixture('/users/{id}', 200, '2026-09-03T10:00:00.000001Z')); + $recorder->record(httpExchangesFixture('/orders', 422, '2026-09-03T10:00:01.000002Z')); + + expect(httpExchangesBody($recorder))->toBe([ + 'exchanges' => [ + [ + 'timestamp' => '2026-09-03T10:00:01.000002Z', + 'method' => 'GET', + 'uri' => '/orders', + 'status' => 422, + 'durationMs' => 4.25, + 'correlationId' => 'corr-422', + ], + [ + 'timestamp' => '2026-09-03T10:00:00.000001Z', + 'method' => 'GET', + 'uri' => '/users/{id}', + 'status' => 200, + 'durationMs' => 4.25, + 'correlationId' => 'corr-200', + ], + ], + 'count' => 2, + 'capacity' => 50, + 'recorded' => 2, + 'storage' => 'memory', + 'processLocal' => true, + 'recording' => true, + ]); +}); + +/** + * The reason this endpoint carries five fields Spring's does not. Under PHP-FPM the default recorder can only + * ever answer with an empty list — each request is a fresh process, and the request rendering this endpoint has + * not been recorded yet because the filter records on the way out. An operator handed `{"exchanges": []}` and + * nothing else concludes their application is serving no traffic and goes hunting a routing bug that does not + * exist. `storage`/`processLocal` name the fix; `recording` names the flag. + */ +it('says WHY the buffer is empty rather than leaving an operator to guess', function () { + $processLocal = httpExchangesBody(new InMemoryHttpExchangeRecorder(50)); + + expect($processLocal)->toBe([ + 'exchanges' => [], + 'count' => 0, + 'capacity' => 50, + 'recorded' => 0, + 'storage' => 'memory', + 'processLocal' => true, + 'recording' => true, + ]); + + $crossProcess = httpExchangesBody( + new CacheHttpExchangeRecorder(new CacheRepository(new ArrayStore), 'redis', 50), + ['firefly.observability.httpexchanges.enabled' => false], + ); + + expect($crossProcess['storage'])->toBe('cache:redis') + ->and($crossProcess['processLocal'])->toBeFalse() + // Recording switched off: the endpoint stays mounted precisely so it can say so. An absent endpoint + // would be a 404 that tells the operator nothing. + ->and($crossProcess['recording'])->toBeFalse(); +}); + +/** + * `recorded` is monotonic and NOT capped at capacity, so `recorded - count` is how much history the ring has + * already evicted — the number behind "showing the last 2 of 5". + */ +it('reports the evicted history through the monotonic recorded total', function () { + $recorder = new InMemoryHttpExchangeRecorder(2); + foreach (['/a', '/b', '/c', '/d', '/e'] as $uri) { + $recorder->record(httpExchangesFixture($uri, 200, '2026-09-03T10:00:00.000001Z')); + } + + $body = httpExchangesBody($recorder); + + expect($body['count'])->toBe(2) + ->and($body['recorded'])->toBe(5) + ->and($body['capacity'])->toBe(2); +}); + +it('trims the newest-first list to ?limit=N and ignores a malformed limit instead of answering 400', function () { + $recorder = new InMemoryHttpExchangeRecorder(50); + foreach (['/a', '/b', '/c'] as $uri) { + $recorder->record(httpExchangesFixture($uri, 200, '2026-09-03T10:00:00.000001Z')); + } + + $limited = httpExchangesBody($recorder, [], ['limit' => '2']); + expect($limited['count'])->toBe(2); + + // A management endpoint answering 400 to a cosmetic client bug turns it into a blank panel, and there is an + // obvious correct answer available: the unfiltered list. + foreach (['nonsense', '0', '-3', ''] as $bad) { + expect(httpExchangesBody($recorder, [], ['limit' => $bad])['count'])->toBe(3); + } +}); + +/** + * `recording` must equal what the FILTER did, not what a permissive bool cast thinks the flag means. + * HttpExchangeFilter is gated by #[ConditionalOnProperty(havingValue: 'true')], and ConditionEvaluator compares + * the stringified value against that literal — so `enabled = 1` (the shape `env('FIREFLY_...')` hands back for + * `FIREFLY_...=1`) drops the filter and records nothing. Reported through Config::bool() that answered + * `"recording": true` next to a permanently empty list, which is the precise dead end this field exists to + * prevent: an operator concluding the application serves no traffic. + */ +it('reports recording exactly as the filter condition reads the flag, not as a loose bool cast', function () { + $recorder = new InMemoryHttpExchangeRecorder(10); + + foreach ([1, '1', 'on', 'yes', 0, 'false', false] as $off) { + expect(httpExchangesBody($recorder, ['firefly.observability.httpexchanges.enabled' => $off])['recording']) + ->toBeFalse(); + } + + foreach ([true, 'true'] as $on) { + expect(httpExchangesBody($recorder, ['firefly.observability.httpexchanges.enabled' => $on])['recording']) + ->toBeTrue(); + } + + // Absent flag: the filter's matchIfMissing=true default is on, so recording is on. + expect(httpExchangesBody($recorder)['recording'])->toBeTrue(); +}); diff --git a/packages/observability/tests/Endpoint/ProcessEndpointTest.php b/packages/observability/tests/Endpoint/ProcessEndpointTest.php new file mode 100644 index 0000000..d87daff --- /dev/null +++ b/packages/observability/tests/Endpoint/ProcessEndpointTest.php @@ -0,0 +1,79 @@ +|string`) through real control flow rather than a suppressing + * override, and named distinctly so the file has no top-level-function collision across the Pest suite. + * + * @return array + */ +function processEndpointBody(ProcessEndpoint $endpoint): array +{ + $body = $endpoint->handle(new EndpointRequest('GET', []))->body; + + if (! is_array($body)) { + throw new RuntimeException('Expected a JSON (array) response body.'); + } + + return $body; +} + +/** + * @param array $body + * @return array + */ +function processEndpointSection(array $body, string $key): array +{ + $value = $body[$key] ?? null; + if (! is_array($value)) { + throw new RuntimeException("Expected [{$key}] to be an array."); + } + + return $value; +} + +it('reports the live runtime numbers a dashboard plots, in a fixed top-level shape', function () { + $recorder = new InMemoryHttpExchangeRecorder(25); + $recorder->record(new HttpExchange('2026-09-03T10:00:00.000001Z', 'GET', '/x', 200, 1.0, null)); + $recorder->record(new HttpExchange('2026-09-03T10:00:01.000002Z', 'GET', '/y', 200, 1.0, null)); + + $body = processEndpointBody(new ProcessEndpoint($recorder, new RuntimeSnapshot, microtime(true) - 1.5)); + + expect(array_keys($body))->toBe(['pid', 'uptimeMs', 'php', 'memory', 'opcache', 'requests']) + ->and($body['pid'])->toBe(getmypid()) + // uptimeMs is measured from the construction of the bean — genuine worker uptime under Octane, the age + // of the current request under PHP-FPM. Injected here so the assertion is deterministic. + ->and($body['uptimeMs'])->toBeGreaterThanOrEqual(1500.0) + ->and(processEndpointSection($body, 'php'))->toBe(['version' => PHP_VERSION, 'sapi' => PHP_SAPI]) + ->and(array_keys(processEndpointSection($body, 'memory')))->toBe(['usedBytes', 'peakBytes', 'limitBytes', 'limit']) + // The request count the framework was ALREADY keeping, not a second counter invented for this endpoint. + // `buffered` is deliberately absent: answering it means a capacity-wide multi-get on the cache-backed + // recorder, and this is the endpoint a dashboard polls to plot memory over time. + ->and(processEndpointSection($body, 'requests'))->toBe(['recorded' => 2, 'capacity' => 25]); +}); + +it('reports opcache as null rather than 500ing on a machine where the API is unavailable', function () { + $body = processEndpointBody(new ProcessEndpoint(new InMemoryHttpExchangeRecorder)); + + // Either shape is legitimate — what must never happen is the endpoint throwing because opcache is absent, + // disabled, or locked down by opcache.restrict_api (which emits an E_WARNING that Laravel's error handler + // turns into an ErrorException). A hardened production box is exactly where an operator opens this. + expect(array_key_exists('opcache', $body))->toBeTrue() + ->and($body['opcache'] === null || is_array($body['opcache']))->toBeTrue(); +}); + +it('keeps the payload non-sensitive: no environment, no include path, no extension inventory', function () { + $body = processEndpointBody(new ProcessEndpoint(new InMemoryHttpExchangeRecorder)); + + expect($body)->not->toHaveKey('env') + ->and($body)->not->toHaveKey('extensions') + ->and($body)->not->toHaveKey('includePath') + ->and(processEndpointSection($body, 'php'))->toBe(['version' => PHP_VERSION, 'sapi' => PHP_SAPI]); +}); diff --git a/packages/observability/tests/HttpExchanges/CacheHttpExchangeRecorderTest.php b/packages/observability/tests/HttpExchanges/CacheHttpExchangeRecorderTest.php new file mode 100644 index 0000000..dd27ca2 --- /dev/null +++ b/packages/observability/tests/HttpExchanges/CacheHttpExchangeRecorderTest.php @@ -0,0 +1,131 @@ + */ +function cachedUris(CacheHttpExchangeRecorder $recorder): array +{ + return array_map(static fn (HttpExchange $e): string => $e->uri, $recorder->exchanges()); +} + +it('lets one worker read the exchanges another worker recorded, newest first', function () { + $store = exchangeStore(); + + (new CacheHttpExchangeRecorder($store, 'redis'))->record(cachedExchange('/first')); + (new CacheHttpExchangeRecorder($store, 'redis'))->record(cachedExchange('/second')); + + // A THIRD process — the one rendering the endpoint — sees both, which is the entire point. + expect(cachedUris(new CacheHttpExchangeRecorder($store, 'redis')))->toBe(['/second', '/first']) + ->and((new CacheHttpExchangeRecorder($store, 'redis'))->recorded())->toBe(2); +}); + +it('rolls over the ring, keeping the newest capacity exchanges across processes', function () { + $store = exchangeStore(); + + foreach (['/a', '/b', '/c', '/d'] as $uri) { + (new CacheHttpExchangeRecorder($store, 'redis', 2))->record(cachedExchange($uri)); + } + + $reader = new CacheHttpExchangeRecorder($store, 'redis', 2); + + expect(cachedUris($reader))->toBe(['/d', '/c']) + ->and($reader->recorded())->toBe(4) + ->and($reader->capacity())->toBe(2); +}); + +it('preserves the full row across the store round trip', function () { + $store = exchangeStore(); + + (new CacheHttpExchangeRecorder($store, 'redis'))->record( + new HttpExchange('2026-09-03T10:11:12.131415Z', 'POST', '/orders/{id}', 422, 33.75, 'corr-77', ['accept' => 'application/json']) + ); + + $exchanges = (new CacheHttpExchangeRecorder($store, 'redis'))->exchanges(); + + expect($exchanges)->toHaveCount(1) + ->and($exchanges[0]->toArray())->toBe([ + 'timestamp' => '2026-09-03T10:11:12.131415Z', + 'method' => 'POST', + 'uri' => '/orders/{id}', + 'status' => 422, + 'durationMs' => 33.75, + 'correlationId' => 'corr-77', + 'requestHeaders' => ['accept' => 'application/json'], + ]); +}); + +/** + * The stale-write guard (documented imprecision 1 on the class): a writer that stalls between taking its + * ordinal and writing its row must not be able to overwrite a NEWER row that landed in the same slot. Modelled + * by writing the later ordinal first, then replaying the straggler — which, with capacity 1, targets the same + * slot. + */ +it('refuses to let a straggling writer overwrite a newer row in the same slot', function () { + $store = exchangeStore(); + + $recorder = new CacheHttpExchangeRecorder($store, 'redis', 1); + $recorder->record(cachedExchange('/older')); // ordinal 1 -> slot 0 + $recorder->record(cachedExchange('/newest')); // ordinal 2 -> slot 0 again; ordinary ring rollover + + // Rewind the shared sequence so the next write takes ordinal 1 again — BELOW the ordinal 2 already sitting + // in that slot. That is the in-process equivalent of a worker that stalled between taking its ordinal and + // writing its row, and being overtaken by a later one. + $store->put('firefly:httpexchanges:seq', 0); + $recorder->record(cachedExchange('/straggler')); + + expect(cachedUris(new CacheHttpExchangeRecorder($store, 'redis', 1)))->toBe(['/newest']); +}); + +/** + * A shared cache store is not a private data structure. Anything under a colliding key must cost one row, not + * the whole endpoint. + */ +it('skips rows the store hands back that are not exchanges', function () { + $store = exchangeStore(); + + // Ordinals start at 1 (the first increment of an absent counter yields 1), so with capacity 3 these two + // rows land in slots 1 and 2 and slot 0 is free to be poisoned with something no one here wrote. + (new CacheHttpExchangeRecorder($store, 'redis', 3))->record(cachedExchange('/real-a')); + (new CacheHttpExchangeRecorder($store, 'redis', 3))->record(cachedExchange('/real-b')); + $store->put('firefly:httpexchanges:slot:0', 'someone else was here'); + + expect(cachedUris(new CacheHttpExchangeRecorder($store, 'redis', 3)))->toBe(['/real-b', '/real-a']); + + // ...and a row that IS an array but is not shaped like an exchange is dropped the same way, rather than + // fataling the endpoint that reads it. + $store->put('firefly:httpexchanges:slot:0', ['seq' => 99, 'exchange' => ['not' => 'an exchange']]); + + expect(cachedUris(new CacheHttpExchangeRecorder($store, 'redis', 3)))->toBe(['/real-b', '/real-a']); +}); + +it('names the store it is backed by and declares itself cross-process', function () { + $recorder = new CacheHttpExchangeRecorder(exchangeStore(), 'redis'); + + expect($recorder->storage())->toBe('cache:redis') + ->and($recorder->processLocal())->toBeFalse(); +}); diff --git a/packages/observability/tests/HttpExchanges/HeaderMaskerTest.php b/packages/observability/tests/HttpExchanges/HeaderMaskerTest.php new file mode 100644 index 0000000..8c54653 --- /dev/null +++ b/packages/observability/tests/HttpExchanges/HeaderMaskerTest.php @@ -0,0 +1,52 @@ +` straight into a buffer that an operator then reads on a dashboard. + */ +it('masks the header-specific credential names the EnvEndpoint config rule cannot see', function () { + $masked = HeaderMasker::mask([ + 'Authorization' => ['Bearer super-secret-jwt'], + 'Cookie' => ['session=abc123'], + 'Proxy-Authorization' => ['Basic Zm9vOmJhcg=='], + ]); + + expect($masked)->toBe([ + 'authorization' => '******', + 'cookie' => '******', + 'proxy-authorization' => '******', + ]); +}); + +it('keeps masking everything the EnvEndpoint rule already masked', function () { + $masked = HeaderMasker::mask([ + 'X-Api-Key' => ['k-123'], + 'X-Csrf-Token' => ['t-456'], + 'X-Client-Secret' => ['s-789'], + ]); + + expect($masked)->toBe([ + 'x-api-key' => '******', + 'x-client-secret' => '******', + 'x-csrf-token' => '******', + ]); +}); + +it('lowercases names, folds multi-valued headers and preserves a present-but-empty header', function () { + $masked = HeaderMasker::mask([ + 'Accept' => ['application/json', 'text/html'], + 'X-Empty' => [null], + ]); + + expect($masked)->toBe([ + 'accept' => 'application/json, text/html', + 'x-empty' => '', + ]); +}); diff --git a/packages/observability/tests/HttpExchanges/HttpExchangeTest.php b/packages/observability/tests/HttpExchanges/HttpExchangeTest.php new file mode 100644 index 0000000..38538e6 --- /dev/null +++ b/packages/observability/tests/HttpExchanges/HttpExchangeTest.php @@ -0,0 +1,86 @@ +toArray())->toBe([ + 'timestamp' => '2026-09-03T10:11:12.131415Z', + 'method' => 'GET', + 'uri' => '/users/{id}', + 'status' => 200, + 'durationMs' => 12.345, + 'correlationId' => 'corr-1', + ]); +}); + +/** + * The key is present ONLY when there is something in it. json_encode renders an empty PHP array as `[]`, not + * `{}`, so emitting the key unconditionally would hand a client an array on exactly the requests that had no + * headers and an object on the rest — the same hazard ActuatorDispatchAction::toResponse() documents for the + * top-level body. + */ +it('includes requestHeaders only when headers were captured', function () { + $exchange = new HttpExchange('2026-09-03T10:11:12.131415Z', 'GET', '/x', 200, 1.0, null, ['accept' => 'application/json']); + + expect($exchange->toArray())->toBe([ + 'timestamp' => '2026-09-03T10:11:12.131415Z', + 'method' => 'GET', + 'uri' => '/x', + 'status' => 200, + 'durationMs' => 1.0, + 'correlationId' => null, + 'requestHeaders' => ['accept' => 'application/json'], + ]); +}); + +/** + * The `@`-epoch DateTimeImmutable constructor truncates to whole seconds, which would collapse every row of a + * traffic burst onto the same timestamp and make the newest-first ordering look arbitrary. This pins the + * microseconds surviving. + */ +it('formats a microtime float as ISO-8601 UTC without losing the microseconds', function () { + // 2021-01-01T00:00:00Z is 1609459200; the .654321 must survive. + expect(HttpExchange::timestampFrom(1609459200.654321))->toBe('2021-01-01T00:00:00.654321Z'); +}); + +it('round-trips through the array form the cache-backed recorder stores', function () { + $original = new HttpExchange('2026-09-03T10:11:12.131415Z', 'POST', '/orders', 201, 42.5, 'corr-9', ['accept' => '*/*']); + + $restored = HttpExchange::fromArray($original->toArray()); + + expect($restored)->not->toBeNull() + ->and($restored?->toArray())->toBe($original->toArray()); +}); + +/** + * A shared cache store is not a private data structure — a key collision, another application on the same Redis, + * or a rolling deploy mid-schema-change can all put something else under these keys. A dashboard panel must lose + * one row, not answer 500 for the whole endpoint. + */ +it('returns null rather than throwing for a row that is not shaped like an exchange', function () { + expect(HttpExchange::fromArray([]))->toBeNull() + ->and(HttpExchange::fromArray(['timestamp' => 1, 'method' => 'GET', 'uri' => '/x', 'status' => 200, 'durationMs' => 1.0]))->toBeNull() + ->and(HttpExchange::fromArray(['timestamp' => 't', 'method' => 'GET', 'uri' => '/x', 'status' => '200', 'durationMs' => 1.0]))->toBeNull(); +}); + +/** + * A cache driver that round-trips through JSON rather than PHP serialize() writes 12.0 and reads back the + * integer 12. Rejecting that row would silently drop every exchange that happened to land on a whole + * millisecond. + */ +it('accepts an integer durationMs from a JSON-serialising cache driver and normalises it to float', function () { + $restored = HttpExchange::fromArray([ + 'timestamp' => '2026-09-03T10:11:12.131415Z', + 'method' => 'GET', + 'uri' => '/x', + 'status' => 200, + 'durationMs' => 12, + 'correlationId' => null, + ]); + + expect($restored?->durationMs)->toBe(12.0); +}); diff --git a/packages/observability/tests/HttpExchanges/InMemoryHttpExchangeRecorderTest.php b/packages/observability/tests/HttpExchanges/InMemoryHttpExchangeRecorderTest.php new file mode 100644 index 0000000..9aa8e31 --- /dev/null +++ b/packages/observability/tests/HttpExchanges/InMemoryHttpExchangeRecorderTest.php @@ -0,0 +1,62 @@ +record(inMemoryExchange('/first')); + $recorder->record(inMemoryExchange('/second')); + $recorder->record(inMemoryExchange('/third')); + + expect(array_map(static fn (HttpExchange $e): string => $e->uri, $recorder->exchanges())) + ->toBe(['/third', '/second', '/first']); +}); + +it('evicts the oldest exchange once the ring is full and keeps the monotonic total', function () { + $recorder = new InMemoryHttpExchangeRecorder(2); + + $recorder->record(inMemoryExchange('/a')); + $recorder->record(inMemoryExchange('/b')); + $recorder->record(inMemoryExchange('/c')); + + expect(array_map(static fn (HttpExchange $e): string => $e->uri, $recorder->exchanges()))->toBe(['/c', '/b']) + // recorded() is NOT capped at capacity: recorded() - count(exchanges()) is how many rows have been + // evicted, which is what lets a dashboard say "showing the last 2 of 3". + ->and($recorder->recorded())->toBe(3) + ->and($recorder->capacity())->toBe(2); +}); + +/** + * capacity=0 is a plausible way for someone to try to switch recording off through the wrong key. Left + * unclamped it makes CacheHttpExchangeRecorder compute `$seq % 0` — a DivisionByZeroError thrown out of a web + * filter, i.e. a config typo that 500s every request in the application. Both recorders clamp through the same + * helper so a configured capacity cannot mean two different things depending on which store is wired. + */ +it('clamps a nonsensical capacity instead of degenerating', function () { + expect((new InMemoryHttpExchangeRecorder(0))->capacity())->toBe(HttpExchangeCapacity::MIN) + ->and((new InMemoryHttpExchangeRecorder(-5))->capacity())->toBe(HttpExchangeCapacity::MIN) + ->and((new InMemoryHttpExchangeRecorder(1_000_000))->capacity())->toBe(HttpExchangeCapacity::MAX); +}); + +/** + * The honesty contract: the endpoint prints these two so an operator staring at an empty panel under PHP-FPM + * learns WHY it is empty instead of concluding their application is serving no traffic. + */ +it('declares itself process-local so the endpoint can explain an empty buffer', function () { + $recorder = new InMemoryHttpExchangeRecorder; + + expect($recorder->storage())->toBe('memory') + ->and($recorder->processLocal())->toBeTrue() + ->and($recorder->capacity())->toBe(HttpExchangeCapacity::DEFAULT); +}); diff --git a/packages/observability/tests/Process/RuntimeSnapshotTest.php b/packages/observability/tests/Process/RuntimeSnapshotTest.php new file mode 100644 index 0000000..041b4fd --- /dev/null +++ b/packages/observability/tests/Process/RuntimeSnapshotTest.php @@ -0,0 +1,62 @@ +toBe(536_870_912) + ->and(RuntimeSnapshot::parseMemoryLimit('128m'))->toBe(134_217_728) + ->and(RuntimeSnapshot::parseMemoryLimit('1G'))->toBe(1_073_741_824) + ->and(RuntimeSnapshot::parseMemoryLimit('64K'))->toBe(65_536) + ->and(RuntimeSnapshot::parseMemoryLimit('134217728'))->toBe(134_217_728); +}); + +it('reports -1 for an unlimited or unparseable limit rather than inventing a number', function () { + expect(RuntimeSnapshot::parseMemoryLimit('-1'))->toBe(-1) + ->and(RuntimeSnapshot::parseMemoryLimit(''))->toBe(-1) + ->and(RuntimeSnapshot::parseMemoryLimit('lots'))->toBe(-1) + ->and(RuntimeSnapshot::parseMemoryLimit('512MB'))->toBe(-1); +}); + +it('reads live memory numbers off the current process', function () { + $memory = (new RuntimeSnapshot)->memory(); + + expect(array_keys($memory))->toBe(['usedBytes', 'peakBytes', 'limitBytes', 'limit']) + ->and($memory['usedBytes'])->toBeGreaterThan(0) + // real_usage=true on both calls, so peak is measured on the same basis as current and can never be the + // smaller of the two. + ->and($memory['peakBytes'])->toBeGreaterThanOrEqual($memory['usedBytes']) + ->and($memory['limit'])->toBe(ini_get('memory_limit')) + ->and($memory['limitBytes'])->toBe(RuntimeSnapshot::parseMemoryLimit((string) ini_get('memory_limit'))); +}); + +/** + * opcache is usually absent from a CLI test run, so this asserts the CONTRACT both ways: null when there is + * nothing to report (extension unloaded, opcache disabled, or `opcache.restrict_api` forbidding the call — + * which emits an E_WARNING that Laravel's handler turns into an ErrorException, hence the guard inside), and + * the exact aggregate-counter shape when there is. + */ +it('reports either null or the exact opcache counter shape, never a half-populated one', function () { + $opcache = (new RuntimeSnapshot)->opcache(); + + if ($opcache === null) { + expect($opcache)->toBeNull(); + + return; + } + + expect(array_keys($opcache))->toBe([ + 'enabled', 'hits', 'misses', 'hitRate', 'usedMemoryBytes', 'freeMemoryBytes', 'wastedMemoryBytes', 'cachedScripts', + ]) + ->and($opcache['enabled'])->toBeBool() + ->and($opcache['hitRate'])->toBeFloat() + // The absolute paths of every compiled script are NOT read (opcache_get_status(false)): they hand a + // reader the deployment layout, the vendor tree and often the OS username. + ->and($opcache)->not->toHaveKey('scripts'); +}); diff --git a/packages/observability/tests/Support/ObservabilityCapstoneTestCase.php b/packages/observability/tests/Support/ObservabilityCapstoneTestCase.php index c5691b1..3ac398d 100644 --- a/packages/observability/tests/Support/ObservabilityCapstoneTestCase.php +++ b/packages/observability/tests/Support/ObservabilityCapstoneTestCase.php @@ -16,6 +16,7 @@ use Firefly\Validation\ValidationServiceProvider; use Firefly\Web\WebServiceProvider; use Illuminate\Foundation\Application; +use Illuminate\Routing\Router; /** * Boots actuator + observability over a real Web layer so /actuator/prometheus scrapes through the HTTP kernel. @@ -62,13 +63,44 @@ protected function configOverrides(): array return [ 'cache.default' => 'array', 'firefly.management.enabled' => true, - 'firefly.management.endpoints.web.exposure.include' => 'health,info,prometheus,metrics', + 'firefly.management.endpoints.web.exposure.include' => 'health,info,prometheus,metrics,httpexchanges,process', 'firefly.management.endpoint.health.db.enabled' => false, 'firefly.observability.metrics.enabled' => $this->metricsEnabled(), + 'firefly.observability.httpexchanges.enabled' => $this->httpExchangesEnabled(), 'firefly.resilience.circuit-breaker.demo' => ['failure-threshold' => 1], ]; } + /** + * The http-exchanges gate, a SEPARATE template method from metricsEnabled() because the two features have + * separate switches on purpose: metrics aggregate, http exchanges retain individual requests, and an + * operator must be able to refuse the second without losing the first. Same boot-time constraint as + * metricsEnabled() — firefly.observability.httpexchanges.enabled is read by HttpExchangeFilter's + * #[ConditionalOnProperty] during condition filtering, so proving "the filter is not registered when the + * flag is off" needs its own boot; flipping it in a test body cannot un-push middleware already on the + * kernel. + */ + protected function httpExchangesEnabled(): bool + { + return true; + } + + /** + * A single ordinary application route, so the capstone can prove the recording path end to end: a real + * request through the real HTTP kernel, recorded by the discovered HttpExchangeFilter, read back out of + * /actuator/httpexchanges. It is templated ('/demo/{id}') because the whole point of the uri field is that + * it carries the ROUTE TEMPLATE and not the concrete path. + * + * The parameter stays untyped: Testbench's HandlesRoutes::defineRoutes() declares it untyped, and narrowing + * a parameter in an override is an LSP violation PHP rejects outright. + * + * @param Router $router + */ + protected function defineRoutes($router): void + { + $router->get('/demo/{id}', static fn (string $id): string => 'demo-'.$id); + } + /** * The master gate under test — enabled here; a disabled sibling overrides this to false to prove the * property-gate takes MeterRegistry/CqrsMetrics/the endpoints down with it (§7 risk 1/4). Mirrors diff --git a/packages/observability/tests/Support/ObservabilityHttpExchangesDisabledCapstoneTestCase.php b/packages/observability/tests/Support/ObservabilityHttpExchangesDisabledCapstoneTestCase.php new file mode 100644 index 0000000..1e46548 --- /dev/null +++ b/packages/observability/tests/Support/ObservabilityHttpExchangesDisabledCapstoneTestCase.php @@ -0,0 +1,22 @@ + $firefly dot-keyed firefly.* config, as an application would set it + */ +function httpExchangeFilter(HttpExchangeRecorder $recorder, array $firefly = []): HttpExchangeFilter +{ + return new HttpExchangeFilter($recorder, new Config(new ConfigRepository($firefly))); +} + +/** @return list> */ +function recordedRows(InMemoryHttpExchangeRecorder $recorder): array +{ + return array_map(static fn (HttpExchange $e): array => $e->toArray(), $recorder->exchanges()); +} + +it('records the route TEMPLATE, not the raw path, so one busy endpoint cannot fill the buffer', function () { + $recorder = new InMemoryHttpExchangeRecorder(10); + + $request = Request::create('/users/42', 'GET'); + $route = new Route('GET', 'users/{id}', []); + $route->bind($request); + $request->setRouteResolver(fn () => $route); + $request->headers->set(CorrelationIdFilter::HEADER, 'corr-abc'); + + httpExchangeFilter($recorder)->handle($request, fn () => new Response('ok', 200)); + + $rows = recordedRows($recorder); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['uri'])->toBe('/users/{id}') + ->and($rows[0]['method'])->toBe('GET') + ->and($rows[0]['status'])->toBe(200) + ->and($rows[0]['correlationId'])->toBe('corr-abc') + ->and($rows[0]['durationMs'])->toBeFloat() + // No headers key at all unless capture is explicitly enabled — this is the security default. + ->and(array_key_exists('requestHeaders', $rows[0]))->toBeFalse() + ->and($rows[0]['timestamp'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/'); +}); + +/** + * MetricsFilter collapses an unmatched route to the bounded 'UNKNOWN' sentinel because there the value becomes + * a metric TAG and unbounded tags are unbounded series. Here the value lands in a fixed-size ring, so + * cardinality costs nothing — and 'UNKNOWN' would delete the single most useful thing this endpoint does, which + * is telling an operator WHICH url is 404ing. The query string is still stripped: '?api_key=...' is exactly the + * leak this feature is otherwise designed to avoid. + */ +it('falls back to the raw path for an unmatched route and strips the query string', function () { + $recorder = new InMemoryHttpExchangeRecorder(10); + + $request = Request::create('/no-such-route/12345?api_key=super-secret&token=leaky', 'GET'); + httpExchangeFilter($recorder)->handle($request, fn () => new Response('not found', 404)); + + $rows = recordedRows($recorder); + + expect($rows[0]['uri'])->toBe('/no-such-route/12345') + ->and($rows[0]['status'])->toBe(404); +}); + +it('caps an attacker-controlled path so one crawler cannot put kilobytes into every ring slot', function () { + $recorder = new InMemoryHttpExchangeRecorder(10); + + $request = Request::create('/'.str_repeat('a', 4096), 'GET'); + httpExchangeFilter($recorder)->handle($request, fn () => new Response('not found', 404)); + + $uri = $recorder->exchanges()[0]->uri; + + expect(mb_strlen($uri))->toBeLessThanOrEqual(256) + ->and($uri)->toEndWith('…'); +}); + +/** + * A dashboard is a POLLING client. Left in, a panel refreshing /actuator/httpexchanges every few seconds would + * — on the long-lived worker that is the only place the default recorder retains anything — evict every genuine + * request from the ring and then show the operator nothing but their own polling. + */ +it('does not record management traffic by default', function () { + $recorder = new InMemoryHttpExchangeRecorder(10); + $filter = httpExchangeFilter($recorder); + + $filter->handle(Request::create('/actuator/httpexchanges', 'GET'), fn () => new Response('{}', 200)); + $filter->handle(Request::create('/actuator', 'GET'), fn () => new Response('{}', 200)); + + expect($recorder->exchanges())->toBe([]); +}); + +it('follows a relocated management base path, and honours an explicit exclude list that replaces the default', function () { + $moved = new InMemoryHttpExchangeRecorder(10); + httpExchangeFilter($moved, ['firefly.management.endpoints.web.base-path' => '/manage']) + ->handle(Request::create('/manage/health', 'GET'), fn () => new Response('{}', 200)); + + expect($moved->exchanges())->toBe([]); + + // An explicit list REPLACES the default, so management traffic is recorded again unless it is named. + $explicit = new InMemoryHttpExchangeRecorder(10); + $filter = httpExchangeFilter($explicit, ['firefly.observability.httpexchanges.exclude' => ['internal/*']]); + $filter->handle(Request::create('/actuator/health', 'GET'), fn () => new Response('{}', 200)); + $filter->handle(Request::create('/internal/ping', 'GET'), fn () => new Response('{}', 200)); + + expect(array_map(static fn (HttpExchange $e): string => $e->uri, $explicit->exchanges()))->toBe(['/actuator/health']); +}); + +/** + * Header capture is opt-in because an exchange log that records headers verbatim is the canonical way one of + * these endpoints leaks credentials — and `Authorization` is the header the EnvEndpoint config rule cannot see, + * which is why HeaderMasker widens it. + */ +it('captures no headers by default and masks the credential-bearing ones when capture is enabled', function () { + $recorder = new InMemoryHttpExchangeRecorder(10); + + $request = Request::create('/thing', 'GET'); + $request->headers->set('Authorization', 'Bearer super-secret-jwt'); + $request->headers->set('X-Api-Key', 'k-123'); + $request->headers->set('Accept', 'application/json'); + + httpExchangeFilter($recorder, ['firefly.observability.httpexchanges.include-headers' => true]) + ->handle($request, fn () => new Response('ok', 200)); + + $headers = $recorder->exchanges()[0]->requestHeaders; + + expect($headers['authorization'])->toBe('******') + ->and($headers['x-api-key'])->toBe('******') + ->and($headers['accept'])->toBe('application/json'); +}); + +it('records a thrown request as a 500 and rethrows so the problem renderer still handles it', function () { + $recorder = new InMemoryHttpExchangeRecorder(10); + $request = Request::create('/boom', 'GET'); + + expect(fn () => httpExchangeFilter($recorder)->handle($request, function () { + throw new PhpRuntimeException('x'); + }))->toThrow(PhpRuntimeException::class); + + expect($recorder->exchanges()[0]->status)->toBe(500) + ->and($recorder->exchanges()[0]->uri)->toBe('/boom'); +}); + +/** + * With the cache-backed recorder every request performs cache I/O, so an unguarded recorder would let a Redis + * blip turn every 200 in the application into a 500 — an availability incident caused entirely by the telemetry + * meant to diagnose one. An exchange row has no effect on the response, so losing the row is the only correct + * failure. + */ +it('never lets a failing recorder change the response', function () { + $exploding = new class implements HttpExchangeRecorder + { + public function record(HttpExchange $exchange): void + { + throw new PhpRuntimeException('cache is down'); + } + + /** @return list */ + public function exchanges(): array + { + return []; + } + + public function capacity(): int + { + return 1; + } + + public function recorded(): int + { + return 0; + } + + public function storage(): string + { + return 'exploding'; + } + + public function processLocal(): bool + { + return true; + } + }; + + // WebFilter::handle() is declared `mixed` (a filter may legitimately return whatever the pipeline passes + // along), so narrow through real control flow rather than a suppressing docblock override or an assert(). + $response = httpExchangeFilter($exploding)->handle(Request::create('/thing', 'GET'), fn () => new Response('ok', 200)); + + if (! $response instanceof Response) { + throw new PhpRuntimeException('The filter must pass the inner response through untouched.'); + } + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('ok'); +}); diff --git a/packages/openapi/cache/firefly-openapi-components.php b/packages/openapi/cache/firefly-openapi-components.php index 9fb4990..7c03d87 100644 --- a/packages/openapi/cache/firefly-openapi-components.php +++ b/packages/openapi/cache/firefly-openapi-components.php @@ -24,6 +24,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 1 => [ 'method' => 'constraintSchemaMapper', @@ -33,6 +36,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 2 => [ 'method' => 'dtoSchemaFactory', @@ -42,6 +47,10 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Validation\\Constraint\\ConstraintManifest', + 1 => 'Firefly\\OpenApi\\Schema\\ConstraintSchemaMapper', + ], ], 3 => [ 'method' => 'operationFactory', @@ -51,6 +60,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\OpenApi\\Schema\\DtoSchemaFactory', + ], ], 4 => [ 'method' => 'openApiGenerator', @@ -60,6 +72,11 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Web\\Route\\RouteManifest', + 1 => 'Firefly\\OpenApi\\OpenApiProperties', + 2 => 'Firefly\\OpenApi\\Generator\\OperationFactory', + ], ], 5 => [ 'method' => 'viewerPage', @@ -69,8 +86,13 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\OpenApi\\OpenApiProperties', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/openapi/composer.json b/packages/openapi/composer.json index 2e7718a..1676d8b 100644 --- a/packages/openapi/composer.json +++ b/packages/openapi/composer.json @@ -1,13 +1,21 @@ { "name": "firefly/openapi", - "description": "LaraFly OpenAPI: generates a valid OpenAPI 3.1 document from the manifests the framework already holds in memory — RouteManifest for paths/operations/parameters, ConstraintManifest for request-body schemas — plus a firefly:openapi artisan command, a spec route, and a zero-dependency, zero-network viewer mounted at a configurable base path.", + "description": "LaraFly OpenAPI: generates a valid OpenAPI 3.1 document from the manifests the framework already holds in memory \u2014 RouteManifest for paths/operations/parameters, ConstraintManifest for request-body schemas \u2014 plus a firefly:openapi artisan command, a spec route, and a zero-dependency, zero-network viewer mounted at a configurable base path.", "type": "library", "license": "Apache-2.0", "homepage": "https://github.com/fireflyframework/fireflyframework-php", "authors": [ - { "name": "Firefly Software Solutions Inc.", "homepage": "https://github.com/fireflyframework" } + { + "name": "Firefly Software Solutions Inc.", + "homepage": "https://github.com/fireflyframework" + } + ], + "keywords": [ + "firefly", + "laravel", + "openapi", + "swagger" ], - "keywords": ["firefly", "laravel", "openapi", "swagger"], "support": { "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/openapi" @@ -26,7 +34,8 @@ "illuminate/contracts": "^13.0", "illuminate/http": "^13.0", "illuminate/routing": "^13.0", - "illuminate/support": "^13.0" + "illuminate/support": "^13.0", + "swagger-api/swagger-ui": "^5.17" }, "extra": { "laravel": { @@ -35,10 +44,22 @@ "Firefly\\OpenApi\\OpenApiWiringProvider" ] }, - "branch-alias": { "dev-main": "26.x-dev" } + "branch-alias": { + "dev-main": "26.x-dev" + } + }, + "autoload": { + "psr-4": { + "Firefly\\OpenApi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Firefly\\OpenApi\\Tests\\": "tests/" + } }, - "autoload": { "psr-4": { "Firefly\\OpenApi\\": "src/" } }, - "autoload-dev": { "psr-4": { "Firefly\\OpenApi\\Tests\\": "tests/" } }, "minimum-stability": "stable", - "config": { "sort-packages": true } + "config": { + "sort-packages": true + } } diff --git a/packages/openapi/src/Boot/OpenApiRouteRegistrar.php b/packages/openapi/src/Boot/OpenApiRouteRegistrar.php index 712b524..e040db0 100644 --- a/packages/openapi/src/Boot/OpenApiRouteRegistrar.php +++ b/packages/openapi/src/Boot/OpenApiRouteRegistrar.php @@ -10,6 +10,7 @@ use Firefly\OpenApi\OpenApiProperties; use Firefly\OpenApi\Web\OpenApiSpecAction; use Firefly\OpenApi\Web\OpenApiViewerAction; +use Firefly\OpenApi\Web\SwaggerAssetAction; use Illuminate\Routing\Router; /** @@ -72,5 +73,13 @@ public function run(BootContext $context): void $router->get($properties->viewerPath, static fn (): mixed => $container->make(OpenApiViewerAction::class)()) ->name('firefly.openapi.viewer'); + + // The official Swagger UI files, served from this application's own origin rather than a CDN. Mounted + // under the viewer path so moving the console moves its assets with it, and constrained to a single + // path segment so the route cannot express a traversal in the first place — SwaggerAssets whitelists + // and realpath-checks the name as well. + $router->get($properties->viewerPath.'/assets/{file}', static fn (string $file): mixed => $container->make(SwaggerAssetAction::class)($file)) + ->where('file', '[A-Za-z0-9._-]+') + ->name('firefly.openapi.assets'); } } diff --git a/packages/openapi/src/OpenApiProperties.php b/packages/openapi/src/OpenApiProperties.php index 7e56c17..bcbd61d 100644 --- a/packages/openapi/src/OpenApiProperties.php +++ b/packages/openapi/src/OpenApiProperties.php @@ -40,6 +40,9 @@ public function __construct( public array $servers, public array $excludePathPrefixes, public bool $includeHtml = false, + // Last, with a default, so every existing positional construction — the fixtures and an + // application's own override bean — keeps compiling. `swagger` is the out-of-the-box console. + public string $viewerStyle = 'swagger', ) {} public static function fromConfig(Config $config): self @@ -56,6 +59,11 @@ public static function fromConfig(Config $config): self servers: self::servers($config), excludePathPrefixes: self::csv($config->string('firefly.openapi.exclude', '')), includeHtml: $config->bool('firefly.openapi.include-html', false), + // `style` is the real switch; `cdn: true` is the older spelling and still forces the CDN page, so + // an application that set it before this option existed keeps the behaviour it configured. + viewerStyle: $config->bool('firefly.openapi.viewer.cdn', false) + ? 'cdn' + : self::style($config->string('firefly.openapi.viewer.style', 'swagger')), ); } @@ -64,6 +72,14 @@ public static function fromConfig(Config $config): self * itself (Route::__construct -> uri = trim($uri, '/')) and a `/`-prefixed literal would otherwise make * every generated link in the viewer disagree with the route it points at by one character. */ + /** Anything unrecognised falls back to the default rather than rendering a blank page. */ + private static function style(string $configured): string + { + $style = strtolower(trim($configured)); + + return in_array($style, ['swagger', 'builtin', 'cdn'], true) ? $style : 'swagger'; + } + private static function path(string $configured, string $fallback): string { $trimmed = trim($configured, '/'); diff --git a/packages/openapi/src/Web/OpenApiViewerAction.php b/packages/openapi/src/Web/OpenApiViewerAction.php index b850edc..b4adf0c 100644 --- a/packages/openapi/src/Web/OpenApiViewerAction.php +++ b/packages/openapi/src/Web/OpenApiViewerAction.php @@ -29,7 +29,11 @@ public function __construct( public function __invoke(): Response { return new Response( - $this->page->render($this->urls->to($this->properties->specPath), $this->properties->viewerCdn), + $this->page->render( + $this->urls->to($this->properties->specPath), + $this->properties->viewerStyle, + $this->urls->to($this->properties->viewerPath.'/assets'), + ), 200, ['Content-Type' => 'text/html; charset=UTF-8'], ); diff --git a/packages/openapi/src/Web/SwaggerAssetAction.php b/packages/openapi/src/Web/SwaggerAssetAction.php new file mode 100644 index 0000000..08d2971 --- /dev/null +++ b/packages/openapi/src/Web/SwaggerAssetAction.php @@ -0,0 +1,44 @@ +assets->path($file); + $type = $this->assets->contentType($file); + + if ($path === null || $type === null) { + return new Response('Not Found', 404, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + + $response = new BinaryFileResponse($path, 200, ['Content-Type' => $type]); + $response->setPublic(); + $response->setMaxAge(31536000); + $response->setImmutable(); + $response->setAutoEtag(); + + return $response; + } +} diff --git a/packages/openapi/src/Web/SwaggerAssets.php b/packages/openapi/src/Web/SwaggerAssets.php new file mode 100644 index 0000000..9a8a4a9 --- /dev/null +++ b/packages/openapi/src/Web/SwaggerAssets.php @@ -0,0 +1,128 @@ + basename => content type + */ + private const ALLOWED = [ + 'swagger-ui.css' => 'text/css; charset=UTF-8', + 'swagger-ui-bundle.js' => 'application/javascript; charset=UTF-8', + 'swagger-ui-standalone-preset.js' => 'application/javascript; charset=UTF-8', + 'oauth2-redirect.html' => 'text/html; charset=UTF-8', + 'favicon-16x16.png' => 'image/png', + 'favicon-32x32.png' => 'image/png', + 'index.css' => 'text/css; charset=UTF-8', + ]; + + public function __construct(private readonly ?string $distPath = null) {} + + /** True when the official distribution is installed and readable. */ + public function available(): bool + { + return $this->dist() !== null; + } + + /** @return list */ + public function servable(): array + { + return array_keys(self::ALLOWED); + } + + public function contentType(string $file): ?string + { + return self::ALLOWED[$file] ?? null; + } + + /** + * The absolute path of one servable asset, or null when the name is not on the whitelist, the + * distribution is absent, or the resolved file escapes the dist directory. + */ + public function path(string $file): ?string + { + $dist = $this->dist(); + if ($dist === null || ! isset(self::ALLOWED[$file])) { + return null; + } + + $resolved = realpath($dist.'/'.$file); + + return $resolved !== false && str_starts_with($resolved, $dist.DIRECTORY_SEPARATOR) && is_file($resolved) + ? $resolved + : null; + } + + /** + * The dist directory, resolved once. + * + * Composer's own autoloader is asked for the package root rather than a path being guessed from __DIR__, + * because the depth from this file to vendor/ differs between an installed package + * (vendor/firefly/openapi/src/Web) and this monorepo (packages/openapi/src/Web) — a relative walk would + * work in exactly one of them. + */ + private function dist(): ?string + { + $candidates = $this->distPath !== null ? [$this->distPath] : $this->discover(); + + foreach ($candidates as $candidate) { + $resolved = realpath($candidate); + if ($resolved !== false && is_dir($resolved) && is_file($resolved.'/swagger-ui-bundle.js')) { + return $resolved; + } + } + + return null; + } + + /** @return list */ + private function discover(): array + { + $paths = []; + + if (class_exists(InstalledVersions::class)) { + try { + $root = InstalledVersions::getInstallPath('swagger-api/swagger-ui'); + if (is_string($root)) { + $paths[] = $root.'/dist'; + } + } catch (\Throwable) { + // Not installed, or an installed.php too old to answer — fall through to the vendor walk. + } + } + + // A plain walk upwards, for an autoloader that cannot answer (a phar, a non-composer runtime). + for ($up = 2; $up <= 6; $up++) { + $paths[] = dirname(__DIR__, $up).'/vendor/swagger-api/swagger-ui/dist'; + } + + return $paths; + } +} diff --git a/packages/openapi/src/Web/ViewerPage.php b/packages/openapi/src/Web/ViewerPage.php index 003d61c..99b5bdd 100644 --- a/packages/openapi/src/Web/ViewerPage.php +++ b/packages/openapi/src/Web/ViewerPage.php @@ -7,16 +7,23 @@ /** * The API reference UI, as a single self-contained HTML document with NO build step and NO network access. * - * WHY NOT SWAGGER UI / REDOC / ELEMENTS. Every off-the-shelf OpenAPI viewer is a bundled JavaScript - * application, which leaves exactly two ways to ship one: vendor a multi-megabyte minified bundle into a PHP - * package (bloating every `composer install`, and pinning the framework to a JS release train it cannot - * audit or patch), or load it from a CDN at request time. The second is worse than it looks: an internal API - * console that silently phones out to a third-party host on every page view is a supply-chain dependency and - * a data-protection question, and it simply does not render in the air-gapped and locked-down-CSP - * environments where an internal API console is most wanted. So the default viewer is hand-written, inline, - * and dependency-free — a few hundred bytes of CSS and a single fetch of the spec route this same package - * serves. A Swagger UI page IS available for teams that want the full feature set, behind - * `firefly.openapi.viewer.cdn`, which defaults to FALSE and is documented as opting into a CDN request. + * THREE STYLES, selected by `firefly.openapi.viewer.style`: + * + * swagger (default) The OFFICIAL Swagger UI, served from the application's own origin out of the + * `swagger-api/swagger-ui` composer package. Byte-for-byte the distribution Swagger + * publishes — the full feature set, deep linking, try-it-out, OAuth2 — with no CDN + * request and no npm step, because composer already fetched and pinned the dist. + * builtin A hand-written, dependency-free reference: one + + + + + HTML, [ + '__TITLE__' => $this->escape($this->title), + '__SPEC_URL__' => $this->json($specUrl), + '__ASSETS__' => $this->escape($assetBase), + ]); } private function builtIn(string $specUrl): string @@ -477,7 +543,7 @@ function wireTryIt(body) { ]); } - private function swaggerUi(string $specUrl): string + private function swaggerUiFromCdn(string $specUrl): string { $title = $this->escape($this->title); $url = $this->json($specUrl); diff --git a/packages/openapi/tests/BeanOverrideTest.php b/packages/openapi/tests/BeanOverrideTest.php index f2b4b09..51380e8 100644 --- a/packages/openapi/tests/BeanOverrideTest.php +++ b/packages/openapi/tests/BeanOverrideTest.php @@ -26,5 +26,5 @@ // The whole override contract: every collaborator is a #[Bean] behind #[ConditionalOnMissingBean], so // replacing one is a five-line #[Configuration] in the app and needs no fork of the package. - expect($page->render('/openapi.json', cdn: false))->toContain('Corporate Console'); + expect($page->render('/openapi.json', 'builtin'))->toContain('Corporate Console'); }); diff --git a/packages/openapi/tests/Web/ViewerPageTest.php b/packages/openapi/tests/Web/ViewerPageTest.php index 0f6387f..c7e334f 100644 --- a/packages/openapi/tests/Web/ViewerPageTest.php +++ b/packages/openapi/tests/Web/ViewerPageTest.php @@ -2,10 +2,11 @@ declare(strict_types=1); +use Firefly\OpenApi\Web\SwaggerAssets; use Firefly\OpenApi\Web\ViewerPage; it('renders a self-contained page that never reaches the network', function () { - $html = new ViewerPage('Orders API')->render('/openapi.json', cdn: false); + $html = new ViewerPage('Orders API')->render('/openapi.json', 'builtin'); // The ONE network call the default viewer makes is to the spec route it was handed, so no element may // FETCH from another origin. Asserting on src/href rather than on the raw substring "http://" is the @@ -22,7 +23,7 @@ }); it('resolves $ref pointers client-side so a reader sees members, not pointers', function () { - $html = new ViewerPage('Orders API')->render('/openapi.json', cdn: false); + $html = new ViewerPage('Orders API')->render('/openapi.json', 'builtin'); expect($html)->toContain('function deref') // The JSON Pointer walk itself: a local "#/a/b" pointer split and followed into the loaded document. @@ -33,28 +34,48 @@ ->and($html)->toContain('deref(content[type].schema)'); }); -it('only reaches a CDN when the opt-in flag is explicitly turned on', function () { - $off = new ViewerPage('Orders API')->render('/openapi.json', cdn: false); - $on = new ViewerPage('Orders API')->render('/openapi.json', cdn: true); +it('only reaches a CDN under the explicit cdn style', function () { + $builtin = new ViewerPage('Orders API')->render('/openapi.json', 'builtin'); + $cdn = new ViewerPage('Orders API')->render('/openapi.json', 'cdn'); - expect($off)->not->toContain('swagger-ui') - ->and($on)->toContain('swagger-ui-bundle.js') + expect($builtin)->not->toContain('swagger-ui') + ->and($cdn)->toContain('swagger-ui-bundle.js') // Pinned by exact version: an unpinned CDN reference is a remote-code-execution channel that // updates itself. - ->and($on)->toMatch('#swagger-ui-dist@\d+\.\d+\.\d+/#'); + ->and($cdn)->toMatch('#swagger-ui-dist@\d+\.\d+\.\d+/#'); +}); + +// The default style is the OFFICIAL Swagger UI served from this application's own origin — the full +// console, with no third-party request at page view. +it('serves the official Swagger UI from local assets by default', function () { + $html = new ViewerPage('Orders API')->render('/openapi.json', 'swagger', '/openapi/assets'); + + expect($html)->toContain('/openapi/assets/swagger-ui-bundle.js') + ->and($html)->toContain('/openapi/assets/swagger-ui.css') + ->and($html)->toContain('SwaggerUIStandalonePreset') + ->and($html)->not->toContain('cdn.jsdelivr.net') + ->and($html)->not->toContain('unpkg.com'); +}); + +// A default that cannot render is worse than a different default: an application without the +// swagger-api/swagger-ui package would otherwise get a console whose assets all 404. +it('falls back to the built-in reference when the Swagger distribution is absent', function () { + $absent = new ViewerPage('Orders API', new SwaggerAssets('/nowhere/at/all')); + + expect($absent->render('/openapi.json', 'swagger', '/openapi/assets'))->toContain('function deref'); }); it('escapes the configured spec path into the inline script', function () { // The path comes from application config, not from a request, so this is defence in depth — but a page // that renders a config value into inline script has no business relying on that distinction. - $html = new ViewerPage('Orders API')->render('/openapi.json"', cdn: false); + $html = new ViewerPage('Orders API')->render('/openapi.json"', 'builtin'); expect($html)->not->toContain('') ->and(substr_count($html, 'toBe(1); }); it('escapes the document title into the page markup', function () { - $html = new ViewerPage('')->render('/openapi.json', cdn: false); + $html = new ViewerPage('')->render('/openapi.json', 'builtin'); expect($html)->not->toContain('and($html)->toContain('<img src=x'); diff --git a/packages/resilience/cache/firefly-resilience-components.php b/packages/resilience/cache/firefly-resilience-components.php index e7492a6..1fea460 100644 --- a/packages/resilience/cache/firefly-resilience-components.php +++ b/packages/resilience/cache/firefly-resilience-components.php @@ -24,6 +24,10 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Contracts\\Cache\\Repository', + 1 => 'Firefly\\Config\\Config', + ], ], 1 => [ 'method' => 'resilienceRegistry', @@ -33,8 +37,14 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Firefly\\Resilience\\Store\\ResilienceStore', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/scheduling-postgres/cache/firefly-scheduling-postgres-components.php b/packages/scheduling-postgres/cache/firefly-scheduling-postgres-components.php index f09102e..ab3652d 100644 --- a/packages/scheduling-postgres/cache/firefly-scheduling-postgres-components.php +++ b/packages/scheduling-postgres/cache/firefly-scheduling-postgres-components.php @@ -24,8 +24,12 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/scheduling/cache/firefly-scheduling-components.php b/packages/scheduling/cache/firefly-scheduling-components.php index d40cfb1..775a72e 100644 --- a/packages/scheduling/cache/firefly-scheduling-components.php +++ b/packages/scheduling/cache/firefly-scheduling-components.php @@ -24,8 +24,14 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Contracts\\Cache\\Repository', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/packages/security/cache/firefly-security-components.php b/packages/security/cache/firefly-security-components.php index 21b903f..1e2c649 100644 --- a/packages/security/cache/firefly-security-components.php +++ b/packages/security/cache/firefly-security-components.php @@ -19,6 +19,10 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Security\\OAuth2\\JwksProvider', + 1 => 'Firefly\\Config\\Config', + ], ], 1 => [ 'class' => 'Firefly\\Security\\SecurityAutoConfiguration', @@ -39,6 +43,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 1 => [ 'method' => 'userDetailsService', @@ -48,6 +54,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 2 => [ 'method' => 'roleHierarchy', @@ -57,6 +66,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 3 => [ 'method' => 'permissionEvaluator', @@ -66,6 +78,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 4 => [ 'method' => 'securityExpressionEvaluator', @@ -75,6 +89,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 5 => [ 'method' => 'authenticationManager', @@ -84,6 +100,10 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Security\\User\\UserDetailsService', + 1 => 'Firefly\\Security\\Password\\PasswordEncoder', + ], ], 6 => [ 'method' => 'authorizationChecker', @@ -93,6 +113,11 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Security\\Access\\Expression\\SecurityExpressionEvaluator', + 1 => 'Firefly\\Security\\Access\\RoleHierarchy', + 2 => 'Firefly\\Security\\Access\\PermissionEvaluator', + ], ], 7 => [ 'method' => 'methodSecurityMessageEnforcer', @@ -102,6 +127,13 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Cqrs\\Handler\\HandlerManifest', + 1 => 'Firefly\\Security\\Access\\Method\\SecurityMethodManifest', + 2 => 'Firefly\\Security\\Access\\Expression\\SecurityExpressionEvaluator', + 3 => 'Firefly\\Security\\Access\\RoleHierarchy', + 4 => 'Firefly\\Security\\Access\\PermissionEvaluator', + ], ], 8 => [ 'method' => 'commandAuthorizer', @@ -111,6 +143,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Security\\Cqrs\\MethodSecurityMessageEnforcer', + ], ], 9 => [ 'method' => 'queryAuthorizer', @@ -120,6 +155,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Security\\Cqrs\\MethodSecurityMessageEnforcer', + ], ], 10 => [ 'method' => 'auditorAware', @@ -129,6 +167,8 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + ], ], 11 => [ 'method' => 'jwtService', @@ -138,6 +178,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 12 => [ 'method' => 'httpSecurity', @@ -147,6 +190,9 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 13 => [ 'method' => 'jwksProvider', @@ -156,9 +202,15 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Container\\Container', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], 2 => [ 'class' => 'Firefly\\Security\\Web\\CsrfFilter', @@ -174,6 +226,9 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 3 => [ 'class' => 'Firefly\\Security\\Web\\HttpSecurityFilter', @@ -189,6 +244,13 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Security\\Access\\HttpSecurity', + 1 => 'Firefly\\Security\\Access\\Expression\\SecurityExpressionEvaluator', + 2 => 'Firefly\\Security\\Access\\RoleHierarchy', + 3 => 'Firefly\\Security\\Access\\PermissionEvaluator', + 4 => 'Firefly\\Config\\Config', + ], ], 4 => [ 'class' => 'Firefly\\Security\\Web\\JwtAuthenticationFilter', @@ -204,6 +266,10 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Security\\Jwt\\JwtService', + 1 => 'Firefly\\Config\\Config', + ], ], 5 => [ 'class' => 'Firefly\\Security\\Web\\SecurityHeadersFilter', @@ -219,5 +285,8 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], ]; diff --git a/packages/validation/cache/firefly-validation-components.php b/packages/validation/cache/firefly-validation-components.php index a2586ef..07fa4a1 100644 --- a/packages/validation/cache/firefly-validation-components.php +++ b/packages/validation/cache/firefly-validation-components.php @@ -24,8 +24,13 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Contracts\\Validation\\Factory', + ], ], ], 'lazy' => false, + 'dependencies' => [ + ], ], ]; diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index ba062c9..768f27b 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -347,6 +347,17 @@ // 'build' => [ // 'path' => base_path('firefly-build.json'), // ], + + /* + | The `runtime` fragment of /actuator/info — PHP version/SAPI/OPcache, Laravel version, LaraFly + | version, current and peak memory. Gated by #[ConditionalOnProperty(matchIfMissing: true)], so + | leaving it unset keeps the contributor; setting it false removes the BEAN, not just the output. + | + | Default: true. + */ + // 'runtime' => [ + // 'enabled' => false, + // ], ], ], @@ -374,6 +385,108 @@ // 'title' => env('APP_NAME', 'LaraFly'), // ], + /* + |-------------------------------------------------------------------------- + | API documentation — firefly/openapi + |-------------------------------------------------------------------------- + | + | The OpenAPI 3.1 document is generated from the same compiled artifacts the dispatcher and the + | validator read — RouteManifest for paths/operations/parameters, ConstraintManifest for request-body + | schemas — so there is no annotation dialect and nothing that can drift. `php artisan firefly:openapi` + | writes the same document to a file or to stdout. + | + | Both routes are mounted natively on the illuminate Router from a BootPass, which is what makes their + | paths configurable at all: an attribute route bakes its literal into a compiled RouteDescriptor. It is + | also why this package's own routes never appear in the document it generates. + | + | SECURING IT. The whole surface is ordinary routes, so `firefly.security.http.rules` above covers it + | with no code edge. A deployment that wants no documentation surface in production sets `enabled` to + | false — which leaves both paths genuinely unrouted, not merely blank — and generates the document in + | CI with `firefly:openapi --output=` instead. + | + | Defaults: enabled true, path '/openapi.json', viewer.enabled true, viewer.path '/openapi', + | viewer.style 'swagger', title 'API', version '0.0.0', description '', servers [], exclude '', + | include-html false. + | + */ + + // 'openapi' => [ + // 'enabled' => true, + // 'path' => '/openapi.json', + // + // 'viewer' => [ + // 'enabled' => true, + // 'path' => '/openapi', + // + // /* + // | Which console /openapi renders. Three values, and only one of them makes a third-party + // | request: + // | + // | 'swagger' — the DEFAULT. The official Swagger UI, served from THIS application's own + // | origin out of the swagger-api/swagger-ui composer package (a hard dependency + // | of firefly/openapi, so it is already on disk). Byte-for-byte the distribution + // | Swagger publishes — try-it-out, deep linking, OAuth2 — with no CDN request and + // | no npm step. Falls back to 'builtin' if the dist is somehow missing, rather + // | than rendering a page whose assets 404. + // | 'builtin' — a hand-written, dependency-free reference: one inline script, no third-party + // | JavaScript at all. Groups operations by tag and resolves $ref client-side. + // | 'cdn' — Swagger UI fetched from cdn.jsdelivr.net at an exactly pinned version. The + // | ONLY style that makes a network request at page view, and therefore the only + // | one that renders nothing in an air-gapped or strict-CSP deployment. No + // | Subresource Integrity hash is claimed: one the framework cannot verify at + // | release time would be security theatre. + // | + // | Anything unrecognised falls back to 'swagger' rather than rendering a blank page. + // | + // | Default: 'swagger'. + // */ + // 'style' => 'swagger', + // + // /* + // | The older spelling of `style => 'cdn'`, kept so an application that set it before `style` + // | existed keeps the behaviour it configured. `cdn => true` still FORCES the CDN page and wins + // | over `style`; prefer `style` in new configuration. + // | + // | Default: false. + // */ + // // 'cdn' => false, + // ], + // + // // Info Object members, written verbatim into the document. + // 'title' => env('APP_NAME', 'API'), + // 'version' => '1.0.0', + // 'description' => '', + // + // /* + // | Server Objects. Both spellings a real config file uses are accepted — a bare URL string, and + // | OpenAPI's own object form with a `description`. An entry that is neither is DROPPED rather than + // | emitted, because a Server Object with no `url` is invalid under the 3.1 schema. + // | + // | Default: []. + // */ + // 'servers' => [ + // 'https://api.example.test', + // // ['url' => 'https://staging.example.test', 'description' => 'Staging'], + // ], + // + // /* + // | CSV of path prefixes left out of the document. Note this only removes them from the SPEC — it + // | does not unroute them; that is what firefly.security.http.rules is for. + // | + // | Default: ''. + // */ + // 'exclude' => '/internal,/admin', + // + // /* + // | Document #[Controller] HTML routes as `text/html` operations. Off by default: an HTML page is + // | not part of a JSON API's contract, and a typed client generated from a document containing one + // | gets a method that returns markup. + // | + // | Default: false. + // */ + // 'include-html' => false, + // ], + /* |-------------------------------------------------------------------------- | Observability — firefly/observability @@ -414,6 +527,79 @@ */ 'ttl' => (int) env('FIREFLY_METRICS_TTL', 0), ], + + /* + | The rolling buffer behind /actuator/httpexchanges (and the request counter in /actuator/process) + | — the last N requests this application answered, newest first. + | + | A SEPARATE SWITCH FROM METRICS, deliberately: metrics aggregate, this retains individual + | requests. An operator happy to publish latency histograms may still want no per-request record + | kept anywhere, and has to be able to say so without losing metrics. + */ + 'httpexchanges' => [ + + /* + | Gates the RECORDING FILTER, not the endpoints — /actuator/httpexchanges and /actuator/process + | stay mounted either way and answer `"recording": false`, because two 404s that explain + | nothing is the opposite of what an operator staring at an empty panel needs. + | + | Compared as a string by #[ConditionalOnProperty], so `1`/`'on'`/`'yes'` read as OFF. Use a + | boolean literal. + | + | Default: true (matchIfMissing). + */ + 'enabled' => true, + + /* + | Ring size, clamped to [1, 10000]. Both ends of the clamp are load-bearing: 0 would divide by + | zero inside the cache-backed recorder (a config typo that 500s every request), and capacity + | is the number of cache keys fetched per endpoint call, so a very large value builds an + | endpoint that times out. + | + | Default: 100. + */ + 'capacity' => 100, + + /* + | Naming a CACHE STORE swaps InMemoryHttpExchangeRecorder for CacheHttpExchangeRecorder. This + | matters more here than it does for metrics: under PHP-FPM the in-memory ring is not merely + | stale but always EMPTY — each request is a fresh process, and the request rendering the + | endpoint has not been recorded yet, because the filter records on the way out. + | + | Default: '' (process-local InMemoryHttpExchangeRecorder). + */ + 'store' => env('FIREFLY_HTTPEXCHANGES_STORE', ''), + + /* + | Expiry in seconds for each cache-backed row. Only consulted when `store` is set; 0 or less + | means no expiry. + | + | Default: 0 (no expiry). + */ + 'ttl' => (int) env('FIREFLY_HTTPEXCHANGES_TTL', 0), + + /* + | Add masked request headers to each row. Request and response BODIES are never recorded, with + | or without this. + | + | Default: false. + */ + 'include-headers' => false, + + /* + | Glob patterns whose requests are recorded by nobody. Setting this REPLACES the default rather + | than adding to it, and an empty list means "record everything, management traffic included". + | + | The default is the management base path and everything under it, because a dashboard is a + | polling client: left in, a panel refreshing /actuator/httpexchanges would evict every genuine + | request from a 100-row ring and then show the operator nothing but their own polling. + | Running firefly/admin? Add its base path here for exactly the same reason — the framework + | does not reach into another package's key to guess at its mount point. + | + | Default: the value of management.endpoints.web.base-path, plus that path with `/*`. + */ + // 'exclude' => ['actuator', 'actuator/*', 'firefly', 'firefly/*'], + ], ], /* From 03b1ee72c1a756855f1d90043d66546a35a61c4d Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 16:33:10 -0700 Subject: [PATCH 15/31] feat: Spring-style API annotations, a management port, and a stale-manifest fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A STALE MANIFEST NO LONGER BRICKS THE APPLICATION Found while testing the rest of this work, and the most serious thing in it. Deleting or renaming a class left the compiled manifest naming it; EagerSingletonsPass then resolved it and the container threw "Target class does not exist". That alone would be bad. What made it catastrophic is that BOTH commands that repair the situation — firefly:cache and firefly:clear — died with the SAME error, because each has to boot the application before it can rewrite or delete the manifest. So: delete one controller, and the application 500s on every route with no way back except `rm -rf bootstrap/cache/firefly` by hand. Reproduced end to end — `/`, `/actuator` and `/firefly` all 500, `firefly:cache` exited 1 without writing anything — then fixed and re-verified: the app serves with the stale manifest in place, and firefly:cache regenerates it cleanly. The pass now skips a definition whose declaring class no longer exists. A stale entry is a cache-invalidation problem, never a reason to take the application down: nothing can inject a class that is gone, and the next compile drops it anyway. Only a MISSING class is tolerated — a broken constructor or an unsatisfiable dependency still fails fast at boot, because those are defects in code that does exist. The guard is on the DECLARING class, not on the binding key and not on whether the container has a binding: both of those answer yes for a stale entry, since the registrar binds straight from the same manifest. One existing test had to change with it — it used a fictitious holder class for #[Bean] entries, a premise that cannot occur in production, since a factory method cannot run without the class declaring it. The holder is now real and the test asserts the same thing. SPRING-STYLE API DOCUMENTATION (packages/openapi) Operations were summarised from the method name and described as "Handled by Class::method()." — a placeholder masquerading as documentation. Now: the method docblock's first sentence becomes the summary and the rest the description, a class docblock becomes the tag description, and @deprecated is honoured. Six attributes override any of it — #[ApiTag], #[ApiOperation], #[ApiResponse], #[ApiParameter], #[ApiProperty], #[ApiIgnore] — with attribute beating docblock beating derived default. Verified against a live application: an un-annotated method took its summary and description from its docblock; an annotated one took summary, description, operationId and deprecated from #[ApiOperation]; the tag carried its #[ApiTag] description; #[ApiResponse] added a documented 404. The reviewer caught that DocumentInfo was never wired into the container, so all eight new document-level config keys gated nothing in a real application while 125 tests passed over it — the tests constructed the object by hand. Now bound behind #[ConditionalOnMissingBean], with wiring tests. A SEPARATE MANAGEMENT PORT (packages/actuator) firefly.management.server.port / .address / .base-path, Spring's management.server.*. PHP cannot bind two sockets in one process, so the package is explicit about the mechanism rather than pretending: a request-time guard refuses actuator traffic that did not arrive on the management port, a serve command runs the second listener in development, and a port equal to the application's is rejected at boot instead of silently doing nothing. X-Forwarded-Port is honoured only from a trusted proxy that the application actually opted into — an attacker-supplied header is not a weaker signal, it is not a signal. THE DASHBOARD NOW OBEYS THAT BOUNDARY TOO, which it did not. This matters more for the dashboard than for the JSON actuator: the actuator withholds sensitive endpoints behind ExposureModel, while the dashboard deliberately bypasses that model to render beans, env and config properties in-process. A dashboard still answering on the public port after an operator moved management traffic would publish exactly the surface they moved. 404, never 403 — a 403 confirms a management surface exists elsewhere. Mutation-tested: neutering the guard fails the boundary tests, and the source restores byte-identical. The welcome page follows suit: with a management port configured it describes the actuator and dashboard instead of linking to them, and says where they went — a card linking to a 404 would teach a developer the feature is broken rather than that it moved. 1872 tests pass, PHPStan max clean, deptrac 0, Pint clean. Verified on a running application: all 19 surfaces serve, Swagger UI boots with zero console errors and same-origin assets only. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- CHANGELOG.md | 83 ++++- README.md | 37 +- book/book.es.yaml | 5 + book/book.yaml | 5 + book/src-es/04a-openapi.md | 137 +++++-- book/src-es/11-observability-actuator.md | 108 +++++- book/src/04a-openapi.md | 137 +++++-- book/src/11-observability-actuator.md | 108 +++++- docs/README.md | 11 +- docs/cli.md | 11 + docs/index.md | 10 +- docs/modules/actuator.md | 9 +- docs/modules/admin.md | 248 +++++++++++++ docs/modules/bean-graph.md | 139 ++++++++ docs/modules/openapi.md | 337 ++++++++++++++++++ docs/publishing.md | 12 +- packages/actuator/README.md | 113 ++++++ .../cache/firefly-actuator-components.php | 24 ++ .../cache/firefly-actuator-context.php | 22 ++ packages/actuator/composer.json | 1 + .../src/ActuatorAutoConfiguration.php | 28 +- .../actuator/src/ActuatorWiringProvider.php | 14 + .../src/Boot/ActuatorRouteRegistrar.php | 23 +- .../src/Command/ManagementServeCommand.php | 129 +++++++ .../src/Server/ManagementPortGuard.php | 92 +++++ .../src/Server/ManagementServerSettings.php | 205 +++++++++++ .../src/Web/ActuatorDispatchAction.php | 6 + .../actuator/src/Web/ActuatorIndexAction.php | 29 +- .../tests/Boot/ActuatorRouteRegistrarTest.php | 64 +++- .../Boot/ManagementPortBootFailureTest.php | 69 ++++ .../tests/CapstoneManagementBasePathTest.php | 32 ++ .../tests/CapstoneManagementPortTest.php | 62 ++++ .../Command/ManagementServeCommandTest.php | 146 ++++++++ .../tests/Server/ManagementPortGuardTest.php | 100 ++++++ .../Server/ManagementServerSettingsTest.php | 112 ++++++ .../tests/Support/ArtisanAssertions.php | 43 +++ .../ManagementBasePathCapstoneTestCase.php | 21 ++ .../ManagementPortCapstoneTestCase.php | 37 ++ .../ManagementServeCapstoneTestCase.php | 12 + .../admin/src/Boot/AdminRouteRegistrar.php | 5 + packages/admin/src/Web/AdminAction.php | 15 + .../tests/ManagementPortBoundaryTest.php | 35 ++ .../tests/Support/ManagementPortTestCase.php | 21 ++ .../context/src/Pass/EagerSingletonsPass.php | 46 +++ .../tests/Pass/EagerSingletonsPassTest.php | 10 +- .../tests/Pass/StaleManifestSurvivalTest.php | 111 ++++++ packages/openapi/README.md | 42 ++- .../cache/firefly-openapi-components.php | 15 +- .../openapi/cache/firefly-openapi-context.php | 13 +- packages/openapi/src/Attributes/ApiIgnore.php | 28 ++ .../openapi/src/Attributes/ApiOperation.php | 48 +++ .../openapi/src/Attributes/ApiParameter.php | 43 +++ .../openapi/src/Attributes/ApiProperty.php | 47 +++ .../openapi/src/Attributes/ApiResponse.php | 45 +++ packages/openapi/src/Attributes/ApiTag.php | 31 ++ packages/openapi/src/Generator/ApiDocs.php | 282 +++++++++++++++ packages/openapi/src/Generator/DocBlock.php | 292 +++++++++++++++ .../openapi/src/Generator/DocumentInfo.php | 143 ++++++++ .../src/Generator/OpenApiGenerator.php | 101 +++++- .../openapi/src/Generator/OperationDoc.php | 40 +++ .../src/Generator/OperationFactory.php | 215 ++++++++--- packages/openapi/src/Generator/TagDoc.php | 21 ++ .../openapi/src/OpenApiAutoConfiguration.php | 19 +- .../openapi/src/Schema/DtoSchemaFactory.php | 93 +++-- packages/openapi/src/Schema/MemberDoc.php | 175 +++++++++ packages/openapi/src/Schema/MemberType.php | 13 +- .../AttributeFixture/AdjustmentRequest.php | 29 ++ .../InternalToolingController.php | 32 ++ .../AttributeFixture/InventoryController.php | 107 ++++++ .../tests/AttributeFixture/StockLevel.php | 18 + .../tests/DocFixture/CatalogController.php | 78 ++++ .../tests/DocFixture/ReservationRequest.php | 38 ++ .../tests/Generator/ApiAttributeTest.php | 189 ++++++++++ .../openapi/tests/Generator/DocBlockTest.php | 102 ++++++ .../tests/Generator/DocumentInfoTest.php | 113 ++++++ .../Generator/DocumentedOperationTest.php | 119 +++++++ packages/openapi/tests/PackageBootTest.php | 45 +++ .../openapi/tests/Support/FixtureDocument.php | 62 +++- skeleton/app/Http/WelcomeController.php | 29 +- skeleton/config/firefly.php | 39 +- skeleton/resources/views/welcome.blade.php | 30 +- 81 files changed, 5547 insertions(+), 213 deletions(-) create mode 100644 docs/modules/admin.md create mode 100644 docs/modules/bean-graph.md create mode 100644 docs/modules/openapi.md create mode 100644 packages/actuator/src/Command/ManagementServeCommand.php create mode 100644 packages/actuator/src/Server/ManagementPortGuard.php create mode 100644 packages/actuator/src/Server/ManagementServerSettings.php create mode 100644 packages/actuator/tests/Boot/ManagementPortBootFailureTest.php create mode 100644 packages/actuator/tests/CapstoneManagementBasePathTest.php create mode 100644 packages/actuator/tests/CapstoneManagementPortTest.php create mode 100644 packages/actuator/tests/Command/ManagementServeCommandTest.php create mode 100644 packages/actuator/tests/Server/ManagementPortGuardTest.php create mode 100644 packages/actuator/tests/Server/ManagementServerSettingsTest.php create mode 100644 packages/actuator/tests/Support/ArtisanAssertions.php create mode 100644 packages/actuator/tests/Support/ManagementBasePathCapstoneTestCase.php create mode 100644 packages/actuator/tests/Support/ManagementPortCapstoneTestCase.php create mode 100644 packages/actuator/tests/Support/ManagementServeCapstoneTestCase.php create mode 100644 packages/admin/tests/ManagementPortBoundaryTest.php create mode 100644 packages/admin/tests/Support/ManagementPortTestCase.php create mode 100644 packages/context/tests/Pass/StaleManifestSurvivalTest.php create mode 100644 packages/openapi/src/Attributes/ApiIgnore.php create mode 100644 packages/openapi/src/Attributes/ApiOperation.php create mode 100644 packages/openapi/src/Attributes/ApiParameter.php create mode 100644 packages/openapi/src/Attributes/ApiProperty.php create mode 100644 packages/openapi/src/Attributes/ApiResponse.php create mode 100644 packages/openapi/src/Attributes/ApiTag.php create mode 100644 packages/openapi/src/Generator/ApiDocs.php create mode 100644 packages/openapi/src/Generator/DocBlock.php create mode 100644 packages/openapi/src/Generator/DocumentInfo.php create mode 100644 packages/openapi/src/Generator/OperationDoc.php create mode 100644 packages/openapi/src/Generator/TagDoc.php create mode 100644 packages/openapi/src/Schema/MemberDoc.php create mode 100644 packages/openapi/tests/AttributeFixture/AdjustmentRequest.php create mode 100644 packages/openapi/tests/AttributeFixture/InternalToolingController.php create mode 100644 packages/openapi/tests/AttributeFixture/InventoryController.php create mode 100644 packages/openapi/tests/AttributeFixture/StockLevel.php create mode 100644 packages/openapi/tests/DocFixture/CatalogController.php create mode 100644 packages/openapi/tests/DocFixture/ReservationRequest.php create mode 100644 packages/openapi/tests/Generator/ApiAttributeTest.php create mode 100644 packages/openapi/tests/Generator/DocBlockTest.php create mode 100644 packages/openapi/tests/Generator/DocumentInfoTest.php create mode 100644 packages/openapi/tests/Generator/DocumentedOperationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ea922f8..31f991a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,13 @@ Cut as `26.09.1` when released: `Firefly\Kernel\Version::VERSION`, this heading, badge move together (see [Versioning](docs/versioning.md)), and `tests/VersionConsistencyTest.php` fails the build if any one of the three drifts. -A correctness release. Several headline features were found not to work at all outside the compiled boot, and -two of the failures were **fail-open** in the security sense — the application kept serving, unguarded, with -nothing logged. Every fix below was reproduced by a failing test first. +A correctness release that also grew two surfaces. Several headline features were found not to work at all +outside the compiled boot, and two of the failures were **fail-open** in the security sense — the application +kept serving, unguarded, with nothing logged; every fix below was reproduced by a failing test first. Alongside +them, LaraFly gained the two things a framework this shape is expected to have and did not: a browser dashboard +over the actuator (`firefly/admin`) and an OpenAPI 3.1 document generated from the manifests it already holds +(`firefly/openapi`). Both are opt-in Composer packages, outside the `firefly/firefly` metapackage, and neither +needs npm or a CDN. ### BREAKING @@ -28,6 +32,63 @@ nothing logged. Every fix below was reproduced by a failing test first. still sees that a bean of that type exists. See [Dependency Injection](docs/modules/dependency-injection.md). ### Added +- **`firefly/openapi` — an OpenAPI 3.1 document that cannot drift from the server.** Generated from the + artifacts the framework already holds in memory: `RouteManifest` for paths, verbs, declared statuses, route + names and the per-parameter binding plan; `ConstraintManifest` for request-body schemas and their `required` + lists; `firefly/kernel`'s `ErrorResponse` for the RFC 9457 problem component. There is no annotation dialect + and no second description of the API, so there is nothing to keep in sync. `#[NotBlank]`, `#[Size]`, + `#[Min]`/`#[Max]`, `#[Email]`, `#[Pattern]`, `#[Percentage]`, `#[Money]` and the rest become JSON Schema + keywords; anything JSON Schema cannot state (`after:now`, a Luhn checksum, a PCRE flag ECMA-262 has no syntax + for) is recorded under the `x-firefly-constraints` specification extension rather than dropped silently. + Nested `#[Valid]` DTOs get their own component, so a self-referential DTO terminates as a `$ref` cycle. Paths, + verbs and components are sorted, so a regenerated document diffs cleanly and stays worth committing. + `php artisan firefly:openapi` writes it to `--output=` (with a summary line) or **raw** to stdout via + Symfony's `OUTPUT_RAW`, so `firefly:openapi | ` gets exactly the document's bytes. Three + routes — spec, console, console assets — are mounted natively from a `BootPass` at configurable paths, which + an attribute route could not be, and which also keeps the package from documenting itself. See + [OpenAPI](docs/modules/openapi.md). +- **`firefly.openapi.viewer.style` — `swagger` (default) | `builtin` | `cdn`.** The default console is the + **official Swagger UI, served from the application's own origin** out of the `swagger-api/swagger-ui` composer + package (a hard dependency, so the files are already on disk): byte-for-byte the distribution Swagger + publishes — full feature set, deep linking, try-it-out, OAuth2 — with **no CDN request and no npm step**, so + it still renders in the air-gapped and strict-CSP deployments where an internal API console is most wanted. + Asset serving is a whitelist of seven basenames, each `realpath()`-checked inside the dist directory, behind a + route whose `{file}` segment cannot express a traversal; the files are immutable for a pinned version and are + sent with a one-year `immutable` cache header and an auto ETag. `builtin` is the hand-written, dependency-free + reference (no third-party JavaScript at all) and is also the automatic fallback when the dist is absent, so a + missing package never renders a console whose assets 404. `cdn` fetches Swagger UI from `cdn.jsdelivr.net` and + is the only style that makes a third-party request at page view. The older boolean `firefly.openapi.viewer.cdn` + (default `false`) still forces the CDN page and wins over `style`, so an application that set it keeps the + behaviour it configured. +- **`firefly/admin` — a browser dashboard over the actuator**, the Spring Boot Admin analogue, mounted at + `firefly.admin.base-path` (default `/firefly`). Thirteen pages in three operator-shaped groups: overview, + health, metrics and HTTP traffic; beans, **bean graph**, conditions, routes and scheduled tasks; environment, + config properties, caches and loggers. It reads each `ActuatorEndpoint` **in-process** from `ActuatorRegistry`, + deliberately bypassing `ExposureModel` — so it renders pages the JSON surface keeps unexposed while that + surface stays secure-by-default — and honours the per-endpoint kill switch + (`firefly.management.endpoint.{id}.enabled`), because that key means "off", not "unpublished". A page whose + endpoint is unregistered or switched off is hidden from the menu rather than linked; a throwing endpoint + degrades its own panel; health details are read from `HealthContributorRegistry` directly rather than through + the endpoint's `show-details` disclosure policy. Plain Blade with inline CSS — no npm step, no CDN — and it + mounts nothing at all when no view factory is bound. See [Admin Dashboard](docs/modules/admin.md). + - **SECURITY — `firefly.admin.enabled` defaults to the value of `app.debug`.** Because the dashboard bypasses + exposure, its own URL is the entire boundary in front of `beans`, `env` and `conditions`. An app already + serving stack traces is a development environment by definition; an app with debug off must opt in + explicitly, and an explicit value wins in both directions. The dashboard ships **no authentication of its + own** and has no code edge to `firefly/security`: an application that enables it outside debug **must put + the route behind its own auth middleware** (`firefly.security.http.rules` covers `firefly` and `firefly/*` + with no code change). +- **The bean graph (`/firefly/graph`)** — a drawn, layered dependency diagram, not another table. + `ComponentScanner` now records each component's constructor class/interface types at **scan** time + (`ComponentDescriptor::$dependencies`, declared last with a default so an older compiled manifest still + rehydrates), and `BeansCatalog` publishes them, so answering "what depends on what" costs no request-time + reflection. `BeanGraph` resolves every dependency through an interface index first — a constructor asks for + `EventPublisher`, the bean that satisfies it is `PostgresEventPublisher` — and marks the edge `via` so the + indirection is visible rather than silently substituted; layering is a longest-path assignment so arrows read + downward; a cycle terminates the walk and is **reported** rather than hanging the page, which turns "the app + died at boot with no message" into a named pair of classes. Past 220 nodes the diagram is suppressed in favour + of the filterable relations table, and constructor types satisfied by a Laravel binding rather than a bean are + listed as "provided outside the container" rather than dropped. See [Bean Graph](docs/modules/bean-graph.md). - **`Firefly\Context\Scan\AppScan`** — the seam every capability package uses to resolve its own manifest: compiled artifact, else an in-process scan of `firefly.scan.paths`, else empty. Routes, `#[ControllerAdvice]` handlers, CQRS handlers, event/message listeners, scheduled tasks, validation constraints, method-security @@ -57,7 +118,10 @@ nothing logged. Every fix below was reproduced by a failing test first. budget. - **`skeleton/config/firefly.php` is now a full configuration reference** — every `firefly.*` key the framework reads, grouped by capability, with its real default and what it does; advanced keys stay commented out at - their defaults. `skeleton/.env.example` carries the ones that usually vary per environment. The skeleton + their defaults. This release adds the `firefly.openapi.*` block (including `viewer.style` and the legacy + `viewer.cdn`), the `firefly.observability.httpexchanges.*` block (`enabled`, `capacity`, `store`, `ttl`, + `include-headers`, `exclude`) and `firefly.management.info.runtime.enabled`, and the file was re-derived + mechanically against the keys the source actually reads, in both directions. `skeleton/.env.example` carries the ones that usually vary per environment. The skeleton also gains a `#[Controller]` welcome page (nothing on it hard-coded — real bean/condition counts, the real route table, the real actuator registry) and its first test suite. @@ -78,6 +142,17 @@ nothing logged. Every fix below was reproduced by a failing test first. than `[]` when empty. - The skeleton drops `app/Support/CachedTransactionalConfiguration.php`, the hand-written workaround every application needed while `DataAutoConfiguration` bound an empty `TransactionalManifest`. +- **Docs, book and README cover the two new packages.** New module guides + [OpenAPI](docs/modules/openapi.md), [Admin Dashboard](docs/modules/admin.md) and + [Bean Graph](docs/modules/bean-graph.md), wired into `docs/README.md` and `docs/index.md`; the actuator guide + gains `/actuator/httpexchanges` + `/actuator/process` and a pointer to the dashboard's access model; the CLI + reference gains a table of commands contributed by other packages (`firefly:openapi`, `firefly:eda:consume`, + `firefly:outbox:relay`). *LaraFly by Example* is updated in **both** languages: Chapter 11 gains the + thirteen-page dashboard table and a full bean-graph section (interface resolution, longest-path layering, + cycle reporting, the 220-node ceiling), and Chapter 4A's "CDN flag" section is replaced by the three viewer + styles, the whitelisted asset route and the honest cost of `cdn`. Every fenced PHP listing still passes + `php -l` (219 per language). Package counts corrected from 25/26 to **27 packages / 28 shippable units** in + the README and the publishing runbook. - Docs corrected against source throughout: the CLI's cached-vs-uncached boot, the resilience circuit-breaker and bulkhead tables and their state prose, configuration's relaxed binding and profile gating, the web layer's HTML rendering, security's fail-open note and full config table, observability's cross-process diff --git a/README.md b/README.md index 2b94b72..d19dfe6 100644 --- a/README.md +++ b/README.md @@ -623,6 +623,27 @@ built-in indicators, `firefly:health`/`firefly:metrics` actuator-over-CLI — se --- +### Two browser surfaces, neither of which needs npm or a CDN + +`composer require firefly/admin` mounts a server-rendered dashboard at `/firefly`: thirteen pages over the +actuator's own endpoints — health, metrics, HTTP traffic, beans, a drawn +[**bean graph**](docs/modules/bean-graph.md), conditions, routes, scheduled tasks, environment, config +properties, caches and loggers. It reads those endpoints **in-process** rather than over HTTP, so it renders +pages the JSON surface deliberately keeps unexposed — which makes its own URL the entire security boundary. +`firefly.admin.enabled` therefore defaults to `app.debug`, and an application that enables it with debug off +**must put the route behind its own auth middleware**. Read +[the access model](docs/modules/admin.md#access-the-whole-security-boundary) before you do. + +`composer require firefly/openapi` mounts `GET /openapi.json` and a console at `/openapi`, both generated from +the same `RouteManifest` the dispatcher dispatches from and the same `ConstraintManifest` the validator +validates with — no annotation dialect, and nothing that can drift. `php artisan firefly:openapi --output=` +makes the document a committable build artifact a CI job can diff. The default console is the **official +Swagger UI, served from your own origin** out of the `swagger-api/swagger-ui` composer package: full feature +set, no CDN request, no npm step, and it still renders in an air-gapped or strict-CSP deployment. See +[OpenAPI](docs/modules/openapi.md). + +--- + ## Installation **Requirements:** PHP 8.3+ (8.4 recommended), Composer 2, and an existing (or new) Laravel 13 application — @@ -650,9 +671,14 @@ composer require firefly/firefly ``` The broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`), the browser dashboard -(`firefly/admin`) and the test kit +(`firefly/admin`), the API-documentation package (`firefly/openapi`) and the test kit (`firefly/testing`) stay separate — require them only if you use them. +```bash +composer require firefly/admin # /firefly — the dashboard over the actuator (see its access model first) +composer require firefly/openapi # /openapi.json + /openapi — a spec that cannot drift, and Swagger UI +``` + Point LaraFly at your app's classes and compile it: ```php @@ -695,7 +721,7 @@ Full flag reference and generated-file contents: [CLI](docs/cli.md). ## Modules -25 packages under `packages/*` (plus `firefly/skeleton` at the top level — 26 shippable units in total), each +27 packages under `packages/*` (plus `firefly/skeleton` at the top level — 28 shippable units in total), each its own installable Composer package with its own tests and its own [module guide](docs/modules/): | Group | Module | Package(s) | @@ -708,6 +734,7 @@ its own installable Composer package with its own tests and its own [module guid | Foundation | [Validation](docs/modules/validation.md) — constraint attributes, `#[Valid]`, structured 422s | `firefly/validation` | | Web & API | [Web Layer](docs/modules/web.md) — `#[RestController]`/`#[Controller]` routing, `RouteManifest`, JSON + HTML negotiation | `firefly/web` | | Web & API | [Web Filters](docs/modules/web-filters.md) — the ordered filter chain onto Laravel middleware | `firefly/web` | +| Web & API | [OpenAPI](docs/modules/openapi.md) — OpenAPI 3.1 generated from the compiled manifests, `firefly:openapi`, official Swagger UI from your own origin | `firefly/openapi` | | Resilience & Scheduling | [Resilience](docs/modules/resilience.md) — retry, circuit breaker, bulkhead, timeout, rate limiter, fallback | `firefly/resilience` | | Resilience & Scheduling | [Scheduling](docs/modules/scheduling.md) — `#[Scheduled]` + distributed locks (cache or Postgres advisory) | `firefly/scheduling`, `firefly/scheduling-postgres` | | Data & Domain | [Domain (DDD)](docs/modules/domain.md) — `Entity`, `ValueObject`, `AggregateRoot`, `DomainEvent` | `firefly/domain` | @@ -721,13 +748,15 @@ its own installable Composer package with its own tests and its own [module guid | Security | [Security](docs/modules/security.md) — principal model, `HttpSecurity`, `#[PreAuthorize]`, JWT/OAuth2 | `firefly/security` | | Operations | [Actuator](docs/modules/actuator.md) — health, info, env, beans, conditions, mappings | `firefly/actuator` | | Operations | [Observability](docs/modules/observability.md) — Prometheus-format metrics, `/actuator/prometheus` | `firefly/observability` | +| Operations | [Admin Dashboard](docs/modules/admin.md) — the browser dashboard over the actuator, read in-process | `firefly/admin` | +| Operations | [Bean Graph](docs/modules/bean-graph.md) — the dashboard's drawn dependency graph, with cycle reporting | `firefly/admin` | | Testing | [Testing](docs/modules/testing.md) — `FireflyTestCase`, recording doubles, Pest expectations | `firefly/testing` | | Testing | [Integration Testing](docs/modules/integration-testing.md) — `@group integration`, testcontainers | `firefly/testing` | | Tooling | [Installer](docs/modules/installer.md) — the global `firefly new` scaffolding tool | `firefly/installer` | `firefly/firefly` (the runtime metapackage) and `firefly/cli` (the dev-console — see -[CLI & Project Scaffolding](#cli--project-scaffolding) above) round out the 25 packages; `firefly/skeleton` -is the 26th unit, a `type: project` create-project template at the top level. +[CLI & Project Scaffolding](#cli--project-scaffolding) above) round out the 27 packages; `firefly/skeleton` +is the 28th unit, a `type: project` create-project template at the top level. --- diff --git a/book/book.es.yaml b/book/book.es.yaml index dd3595c..878aada 100644 --- a/book/book.es.yaml +++ b/book/book.es.yaml @@ -24,6 +24,11 @@ labels: # `num` es una etiqueta libre (el Apéndice A ya lo demuestra), así que intercalar un capítulo # aquí no exige renumerar los nueve siguientes — y renumerarlos habría invalidado cada # referencia cruzada "Capítulo N" de ambos manuscritos y de book/README.md. +# Tarea B7: ningún capítulo nuevo. El explorador del grafo de beans amplía el Capítulo 11 (es una página +# de firefly/admin, que ese capítulo ya presenta) y el visor con el Swagger UI oficial amplía el Capítulo +# 4A (sustituye por completo la antigua sección de "la bandera de CDN", ya que `viewer.style` reemplaza a +# `viewer.cdn`). Separar cualquiera de los dos en un capítulo propio habría desligado una funcionalidad +# del paquete al que pertenece, y habría movido una referencia "Capítulo N" sin beneficio para el lector. front: - {id: title, file: 00-front/00-title.md, nav: false} - {id: copyright, file: 00-front/00-copyright.md, nav: false} diff --git a/book/book.yaml b/book/book.yaml index d6d0838..a73d5d2 100644 --- a/book/book.yaml +++ b/book/book.yaml @@ -23,6 +23,11 @@ labels: # (Appendix A already proves that), so slotting a chapter in here needs no renumbering of the # nine chapters that follow — and renumbering them would have invalidated every "Chapter N" # cross-reference in both manuscripts plus book/README.md, which is not this task's to edit. +# Task B7: no new chapter. The bean-graph explorer extends Chapter 11 (it is a page of firefly/admin, +# which that chapter already introduces) and the official-Swagger-UI viewer extends Chapter 4A (it +# replaces the old "CDN flag" section outright, since `viewer.style` supersedes `viewer.cdn`). Splitting +# either into a chapter of its own would have separated a feature from the package it belongs to, and +# would have moved a "Chapter N" reference for no reader benefit. front: - {id: title, file: 00-front/00-title.md, nav: false} - {id: copyright, file: 00-front/00-copyright.md, nav: false} diff --git a/book/src-es/04a-openapi.md b/book/src-es/04a-openapi.md index e6864c1..50a24c3 100644 --- a/book/src-es/04a-openapi.md +++ b/book/src-es/04a-openapi.md @@ -2,7 +2,7 @@ # Documentar la API: OpenAPI 3.1 desde los Manifiestos {.chtitle} -Al terminar este capítulo sabrás cómo `firefly/openapi` convierte el `RouteManifest` y el `ConstraintManifest` que el Capítulo 4 acaba de construir en un documento OpenAPI 3.1 válido **sin ningún dialecto de anotaciones propio** — cómo `firefly:openapi` convierte ese documento en un artefacto de compilación que un job de CI puede diferenciar, cómo cada `kind` de enlace del plan de ruta compilado se convierte en un Parameter Object o en un Request Body, cómo un `#[NotBlank]` o un `#[Positive]` que ya escribiste se convierte en un `pattern` o en un `exclusiveMinimum`, por qué cada DTO se registra una sola vez y se alcanza por `$ref` en lugar de incrustarse, por qué cada operación lleva el mismo componente de error `problem+json` que produce el renderizador del Capítulo 4, y por qué una ruta HTML `#[Controller]` queda fuera del documento por defecto. +Al terminar este capítulo sabrás cómo `firefly/openapi` convierte el `RouteManifest` y el `ConstraintManifest` que el Capítulo 4 acaba de construir en un documento OpenAPI 3.1 válido **sin ningún dialecto de anotaciones propio** — cómo `firefly:openapi` convierte ese documento en un artefacto de compilación que un job de CI puede diferenciar, cómo cada `kind` de enlace del plan de ruta compilado se convierte en un Parameter Object o en un Request Body, cómo un `#[NotBlank]` o un `#[Positive]` que ya escribiste se convierte en un `pattern` o en un `exclusiveMinimum`, por qué cada DTO se registra una sola vez y se alcanza por `$ref` en lugar de incrustarse, por qué cada operación lleva el mismo componente de error `problem+json` que produce el renderizador del Capítulo 4, y por qué una ruta HTML `#[Controller]` queda fuera del documento por defecto. Cierra con la consola de navegador que el paquete sirve sobre ese documento — tres estilos, de los cuales el predeterminado es el **Swagger UI oficial servido desde tu propio origen**, sin paso de npm y sin petición a CDN, y de los cuales solo uno habla alguna vez con un tercero. !!! note "Término nuevo: extensión de especificación" OpenAPI 3.1 permite que un documento lleve miembros cuyos nombres empiezan por `x-`, llamados **extensiones de especificación**. Las herramientas conformes deben ignorarlas, de modo que una extensión puede registrar algo que el vocabulario estándar no sabe expresar sin invalidar el documento. `firefly/openapi` usa exactamente una, `x-firefly-constraints`, y este capítulo muestra qué acaba en ella y por qué nunca se descarta nada en silencio. @@ -23,7 +23,7 @@ Esa es toda la instalación. Arranca la app y `GET /openapi.json` queda servido; --- -## `firefly:openapi`, y dos rutas que no son rutas por atributo +## `firefly:openapi`, y rutas que no son rutas por atributo El documento también es un fichero que puedes versionar: @@ -36,7 +36,7 @@ El comando existe para que el documento pueda ser un **artefacto de compilación El modo stdout está escrito con la bandera `OUTPUT_RAW` de Symfony, y ese detalle importa más de lo que parece: la salida de consola pasa normalmente por el formateador de Symfony, que trata `<...>` como marcado. Una `description` que mencione un tipo genérico — cualquier cosa que lleve un ángulo y haya llegado al documento desde un valor de configuración — sería o bien engullida o bien lanzaría una excepción ante una etiqueta desconocida. El sentido del modo stdout es canalizar directamente hacia un generador de clientes, así que los bytes deben ser exactamente los bytes del documento. Por eso también la línea de confirmación se imprime **solo** en modo `--output`, donde stdout no es el documento. -Las dos rutas HTTP se montan de forma nativa sobre el `Router` de Illuminate desde un `BootPass`, no se declaran con `#[GetMapping]`: +Las rutas HTTP — la especificación, la consola y los propios assets de la consola — se montan de forma nativa sobre el `Router` de Illuminate desde un `BootPass`, no se declaran con `#[GetMapping]`: ```php final class OpenApiRouteRegistrar implements BootPass @@ -74,13 +74,21 @@ final class OpenApiRouteRegistrar implements BootPass $router->get($properties->viewerPath, static fn (): mixed => $container->make(OpenApiViewerAction::class)()) ->name('firefly.openapi.viewer'); + + // The official Swagger UI files, served from this application's own origin rather than a CDN. Mounted + // under the viewer path so moving the console moves its assets with it, and constrained to a single + // path segment so the route cannot express a traversal in the first place — SwaggerAssets whitelists + // and realpath-checks the name as well. + $router->get($properties->viewerPath.'/assets/{file}', static fn (string $file): mixed => $container->make(SwaggerAssetAction::class)($file)) + ->where('file', '[A-Za-z0-9._-]+') + ->name('firefly.openapi.assets'); } } ``` -Esta es la misma forma — y el mismo idiom de `BootPass` — que el Capítulo 11 te mostrará para las rutas propias del actuator, y se elige por dos razones independientes. Primera, **una ruta por atributo no puede ser configurable**: `#[GetMapping('/openapi.json')]` hornea su literal dentro de un `RouteDescriptor` compilado en tiempo de `firefly:cache`, de modo que un operador nunca podría mover la especificación de una ruta que choca con una suya, ni podría quitarla de una superficie pública sin borrar el paquete. Segunda, una ruta por atributo entraría en el `RouteManifest` de la aplicación — y el generador lee ese manifiesto, así que **el paquete se documentaría a sí mismo**. Registrarlas de forma nativa deja ambos problemas fuera de la existencia: las dos rutas salen de la configuración en el arranque, y nunca aparecen en la especificación que sirven. +Esta es la misma forma — y el mismo idiom de `BootPass` — que el Capítulo 11 te mostrará para las rutas propias del actuator, y se elige por dos razones independientes. Primera, **una ruta por atributo no puede ser configurable**: `#[GetMapping('/openapi.json')]` hornea su literal dentro de un `RouteDescriptor` compilado en tiempo de `firefly:cache`, de modo que un operador nunca podría mover la especificación de una ruta que choca con una suya, ni podría quitarla de una superficie pública sin borrar el paquete. Segunda, una ruta por atributo entraría en el `RouteManifest` de la aplicación — y el generador lee ese manifiesto, así que **el paquete se documentaría a sí mismo**. Registrarlas de forma nativa deja ambos problemas fuera de la existencia: las rutas salen de la configuración en el arranque, y nunca aparecen en la especificación que sirven. Fíjate en que la ruta de assets se monta *bajo* la ruta del visor, así que mover la consola mueve consigo su hoja de estilos y sus scripts. -`firefly.openapi.enabled` (por defecto `true`) se aplica *aquí*, sobre las rutas, y no sobre los beans — el generador y sus colaboradores son inertes sin rutas, así que cerrar las rutas es todo el interruptor. Apagarlo deja ambas rutas genuinamente sin enrutar, de modo que devuelven 404 a través de la propia `NotFoundHttpException` del router, que el `ProblemDetailsRenderer` del Capítulo 4 renderiza entonces como un cuerpo de problem-details `404` en condiciones, no como un `500`. +`firefly.openapi.enabled` (por defecto `true`) se aplica *aquí*, sobre las rutas, y no sobre los beans — el generador y sus colaboradores son inertes sin rutas, así que cerrar las rutas es todo el interruptor. Apagarlo deja todas esas rutas genuinamente sin enrutar, de modo que devuelven 404 a través de la propia `NotFoundHttpException` del router, que el `ProblemDetailsRenderer` del Capítulo 4 renderiza entonces como un cuerpo de problem-details `404` en condiciones, no como un `500`. !!! note "Las acciones se resuelven por petición, dentro de la clausura" Construir una `OpenApiSpecAction` en el arranque y capturarla en la ruta congelaría un `OpenApiGenerator` dentro de la ruta durante toda la vida del proceso — exactamente la forma que se rompe bajo Octane, donde el contenedor de una petición posterior es un sandbox distinto. `$container->make(...)` *dentro* de la clausura es la regla, aquí y en cualquier otro paquete del framework que monte una ruta nativa. @@ -156,12 +164,7 @@ final class OperationFactory } } - $operation = [ - 'operationId' => $operationId, - 'summary' => $this->summary($route), - 'description' => 'Handled by '.$route->controllerClass.'::'.$route->methodName.'().', - 'tags' => [$this->tag($route)], - ]; + // …the operation's own prose (operationId, summary, description, tags) is assembled here… if ($parameters !== []) { $operation['parameters'] = $parameters; @@ -180,7 +183,7 @@ final class OperationFactory } ``` -Leer el plan en lugar de releer la firma del método es lo que hace inequívoco el mapeo. `#[PathVariable]`, `#[QueryParam]` y `#[RequestHeader]` se convierten en Parameter Objects; `#[UploadedFile]` se convierte en una parte `multipart/form-data` tipada como `format: binary`; `#[RequestBody]` se convierte en el Request Body Object; y el sexto `kind`, `service` — el colaborador inyectado por el contenedor sin atributo que presentó el Capítulo 4 — no forma parte del contrato HTTP en absoluto y nunca aparece en el documento. Derivar esa lista de forma independiente tendría que volver a decidir cada uno de esos casos y podría discrepar del dispatcher; leer el plan no puede. +El único bloque elidido es donde se compone la prosa de cara al humano de la operación; todo lo que se muestra es lo que decide el *plan de enlace*. Leer el plan en lugar de releer la firma del método es lo que hace inequívoco el mapeo. `#[PathVariable]`, `#[QueryParam]` y `#[RequestHeader]` se convierten en Parameter Objects; `#[UploadedFile]` se convierte en una parte `multipart/form-data` tipada como `format: binary`; `#[RequestBody]` se convierte en el Request Body Object; y el sexto `kind`, `service` — el colaborador inyectado por el contenedor sin atributo que presentó el Capítulo 4 — no forma parte del contrato HTTP en absoluto y nunca aparece en el documento. Derivar esa lista de forma independiente tendría que volver a decidir cada uno de esos casos y podría discrepar del dispatcher; leer el plan no puede. Cuatro decisiones menores rematan una operación: @@ -476,23 +479,101 @@ La segunda mitad de ese método es el instrumento romo para todo lo demás: `fir --- -## El visor, y la bandera de CDN +## El visor: tres estilos, y solo uno de ellos llama fuera + +`GET /openapi` renderiza una consola de navegador sobre el documento. Cuál de ellas la decide `firefly.openapi.viewer.style`, y la elección es una decisión de cadena de suministro disfrazada de preferencia: + +| `style` | Se sirve desde | ¿Petición a un tercero en cada visita? | +|---|---|---| +| `swagger` **(por defecto)** | tu propio origen, desde el paquete de composer `swagger-api/swagger-ui` | **no** | +| `builtin` | en línea en la respuesta | **no** | +| `cdn` | `cdn.jsdelivr.net` | **sí, en cada visita** | + +Un valor no reconocido cae de vuelta a `swagger` en lugar de renderizar una página en blanco — una errata en un fichero de configuración no debería costarte nada. + +### Por qué el valor por defecto es el Swagger UI oficial, desde tu propio origen + +Todos los visores de estantería — Swagger UI, Redoc, Elements — son aplicaciones JavaScript empaquetadas, y durante años eso dejaba a un paquete PHP exactamente dos opciones. Incrustar un bundle minificado de varios megabytes en el historial git del propio paquete, de modo que cada clon de cada proyecto dependiente lo pague para siempre y el framework quede atado a un tren de releases que no puede parchear sin publicar una versión propia. O traerlo de una CDN en cada visita, lo que es una dependencia de cadena de suministro y una cuestión de protección de datos, y lo que *no renderiza en absoluto* en los entornos aislados y de CSP estricta donde más se quiere una consola de API interna. + +Hay una tercera opción, y este paquete la toma. `swagger-api/swagger-ui` publica su `dist` en Packagist bajo Apache-2.0, así que **composer** puede traerlo y fijarlo — es un `require` duro de `firefly/openapi`, de modo que los ficheros ya están en disco en `vendor/` cuando llegas por primera vez a la ruta — y `SwaggerAssetAction` sirve esos ficheros desde el propio origen de la aplicación: + +```php +final class SwaggerAssetAction +{ + public function __construct(private readonly SwaggerAssets $assets) {} + + public function __invoke(string $file): SymfonyResponse + { + $path = $this->assets->path($file); + $type = $this->assets->contentType($file); + + if ($path === null || $type === null) { + return new Response('Not Found', 404, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + + $response = new BinaryFileResponse($path, 200, ['Content-Type' => $type]); + $response->setPublic(); + $response->setMaxAge(31536000); + $response->setImmutable(); + $response->setAutoEtag(); + + return $response; + } +} +``` + +Obtienes la consola byte a byte tal y como la publica Swagger — el conjunto completo de funciones, deep linking, try-it-out, el popup de redirección OAuth2 — sin petición a CDN, sin paso de npm, y sin nada en el historial de este repositorio que un `composer update` no pueda sustituir. La cabecera de caché larga es segura precisamente porque los bytes son inmutables para una versión fijada: composer solo los cambia cuando cambia la versión fijada, y el ETag cambia con ellos. -`GET /openapi` renderiza una consola de referencia: una única página HTML autocontenida **sin compilación npm en la instalación y sin acceso a red en tiempo de petición**. Agrupa las operaciones por etiqueta y resuelve los punteros `$ref` en el cliente, de modo que quien lee ve los miembros de un DTO y no un puntero a `#/components/schemas`. +!!! note "El path traversal se defiende con una lista blanca, no con un saneador" + Solo siete nombres de fichero son servibles siquiera — `swagger-ui.css`, `swagger-ui-bundle.js`, `swagger-ui-standalone-preset.js`, `oauth2-redirect.html`, dos favicons e `index.css` — y cada ruta resuelta se comprueba con `realpath()` para estar dentro del directorio dist. La propia ruta restringe `{file}` a `[A-Za-z0-9._-]+`, así que ni siquiera puede *expresar* un traversal. Cotejar contra una lista fija en lugar de limpiar la entrada es la elección deliberada: una lista blanca no puede vencerse con un truco de codificación que se le escapó a un saneador. Cualquier otra cosa es un 404 `text/plain` simple — no problem+json, porque quien llama aquí es un navegador pidiendo una hoja de estilos, no un cliente de API. -Todos los visores de estantería — Swagger UI, Redoc, Elements — son aplicaciones JavaScript empaquetadas, lo que deja exactamente dos formas de entregar uno: incrustar un bundle de varios megabytes dentro de un paquete PHP, o traerlo de una CDN en cada visita. Lo segundo es una dependencia de cadena de suministro y una cuestión de protección de datos, y sencillamente no renderiza en los entornos aislados y de CSP estricta donde más se quiere una consola de API interna. De ahí un visor por defecto escrito a mano y sin dependencias. +El directorio dist se localiza preguntando a **los metadatos de versiones instaladas del propio Composer** por la raíz del paquete, en lugar de subir directorios desde `__DIR__`. La profundidad de `src/Web/` a `vendor/` difiere entre un paquete instalado (`vendor/firefly/openapi/src/Web`) y este monorepo (`packages/openapi/src/Web`), así que un recorrido relativo funcionaría exactamente en uno de los dos; ese recorrido se conserva solo como respaldo para un runtime cuyo autoloader no pueda responder. -Swagger UI está disponible para equipos que quieren el conjunto completo de funciones, con una versión fijada exactamente: +Y si la distribución falta de verdad — un `vendor/` recortado, un phar, un runtime sin composer — `render()` cae de vuelta en lugar de servir una página cuyos assets dan 404: + +```php +final class ViewerPage +{ + public function render(string $specUrl, string $style, string $assetBase = ''): string + { + return match (true) { + $style === 'cdn' => $this->swaggerUiFromCdn($specUrl), + // Falling back rather than rendering a broken page: `swagger` is the DEFAULT, so an + // application that has not installed swagger-api/swagger-ui would otherwise get a console + // referencing assets that 404. The built-in reference needs nothing and is always available. + $style === 'swagger' && $this->assets->available() => $this->swaggerUi($specUrl, $assetBase), + default => $this->builtIn($specUrl), + }; + } +} +``` + +### Para qué sirve `builtin` + +Una referencia escrita a mano y sin dependencias: un script en línea, unos cientos de bytes de CSS, un solo `fetch` a la ruta de la especificación, y una paleta que sigue a `prefers-color-scheme`. Hace las dos cosas que quien lee realmente necesita de una especificación generada y que el JSON en crudo no le da — agrupa las operaciones por etiqueta con verbos y rutas visibles de un vistazo, y **resuelve los punteros `$ref` en el cliente**, de modo que quien lee ve los miembros de un DTO y no un puntero a `#/components/schemas`. Try-it-out, flujos OAuth y ejemplos de código están deliberadamente ausentes; para eso está `swagger`. + +Elígelo cuando la regla del despliegue sea *nada de JavaScript de terceros en la respuesta*, y no meramente *nada de hosts de terceros*. + +!!! note "Por qué esa página es un nowdoc" + El visor integrado incrusta una aplicación JavaScript, y un **heredoc** de PHP interpola variables. Cada `$ref`, `$schema` y `$1` de ese script se leía por tanto como una variable PHP — `$ref` se convertía calladamente en la cadena vacía, y la resolución de `$ref`, que es todo el sentido de la página, dejaba de funcionar. Un nowdoc toma el script literalmente y las dos sustituciones reales se hacen explícitamente después. Es la clase de bug que no produce ningún error en ninguna parte: la página renderiza, y sencillamente muestra punteros en lugar de esquemas. + +### Lo que cuesta `cdn` ```php // config/firefly.php return [ - 'openapi' => ['viewer' => ['cdn' => true]], + 'openapi' => ['viewer' => ['style' => 'cdn']], ]; ``` -!!! warning "Activar la bandera de CDN significa que el navegador descarga código de un tercero" - `firefly.openapi.viewer.cdn` vale `false` por defecto. Con ella activada, cada visita carga Swagger UI desde `cdn.jsdelivr.net`. No se declara ningún hash de Subresource Integrity, y es deliberado: un hash que el framework no puede verificar en el momento de publicar es teatro de seguridad, y uno equivocado simplemente rompería la página. La afirmación honesta es la del README del paquete — esto es una petición a un tercero en cada visita. +Cada visita carga entonces Swagger UI desde `cdn.jsdelivr.net`. La versión está fijada exactamente, y **no se declara ningún hash de Subresource Integrity** — deliberadamente: un hash que el framework no puede verificar en el momento de publicar es teatro de seguridad, y uno equivocado simplemente rompería la página. + +Sopesa el intercambio con honestidad. A cambio de una petición a un tercero en cada visita, de una Content-Security-Policy que tiene que permitir ese host, y de una consola que no renderiza nada en un despliegue aislado, obtienes… el mismo Swagger UI que `swagger` ya te servía desde tu propio origen. El estilo se mantiene porque es lo que muestran la mayoría de los tutoriales, y porque algunas organizaciones prefieren genuinamente que sus bytes vengan de una caché en la que ya confían — no porque sea el mejor valor por defecto. + +!!! warning "`viewer.cdn` sigue ganando sobre `viewer.style`" + `firefly.openapi.viewer.cdn` (por defecto `false`) es la grafía booleana antigua de esta opción, de antes de que `style` existiera. Sigue **forzando** la página de CDN y anula a `style`, de modo que una aplicación que la fijó conserva el comportamiento que configuró en lugar de que una actualización del framework la mueva calladamente a otra consola. Prefiere `style` en configuración nueva; borra `cdn` cuando lo adoptes. + +El visor trae la especificación desde la ruta hermana en lugar de tener el documento incrustado en la página, de modo que una especificación regenerada aparece con un simple refresco del navegador, y de modo que las dos rutas puedan exponerse de forma independiente — un despliegue puede muy bien querer el documento legible por máquina público y la consola apagada, o al revés. La URL de la especificación se resuelve a través del `UrlGenerator` en lugar de concatenarse, porque una app montada bajo un subdirectorio o detrás de `APP_URL` obtendría si no un enlace que da 404 desde cualquier página que no sea la raíz, y un visor cuya única llamada de red es incorrecta es un visor que no muestra nada en absoluto. --- @@ -507,18 +588,19 @@ declare(strict_types=1); return [ 'openapi' => [ - 'enabled' => true, // master gate: off means both routes are genuinely unrouted + 'enabled' => true, // master gate: off means every route is genuinely unrouted 'path' => '/openapi.json', // spec route 'viewer' => [ 'enabled' => true, - 'path' => '/openapi', - 'cdn' => false, // opt in to Swagger UI over a CDN — see above + 'path' => '/openapi', // assets are mounted under {path}/assets/{file} + 'style' => 'swagger', // swagger (default) | builtin | cdn — see above ], 'title' => 'Lumen Wallet API', 'version' => '1.0.0', 'description' => '', 'servers' => ['https://api.example.test'], // bare URLs or OpenAPI Server Objects 'exclude' => '/internal,/admin', // CSV of path prefixes to leave out + 'include-html' => false, // document #[Controller] routes as text/html ], ]; ``` @@ -539,6 +621,7 @@ return [ 'enabled' => true, 'rules' => [ ['pattern' => 'openapi', 'access' => 'hasRole:DEVELOPER'], + ['pattern' => 'openapi/*', 'access' => 'hasRole:DEVELOPER'], ['pattern' => 'openapi.json', 'access' => 'hasRole:DEVELOPER'], ], ], @@ -546,6 +629,8 @@ return [ ]; ``` +Fíjate en los tres patrones. `openapi` a secas no casa con `openapi/assets/swagger-ui.css`, y `openapi.json` es un literal aparte — un conjunto de reglas que cubre la consola pero no sus assets produce una página autenticada cuya hoja de estilos responde 401, que es peor resultado que cualquiera de los dos extremos. + La alternativa, para un despliegue que no quiere ninguna superficie de documentación en producción, es `enabled => false` más un paso `firefly:openapi --output=` en CI. --- @@ -580,7 +665,7 @@ final class ApiDocsConfiguration |---|---| | `OpenApiGenerator` | Ensambla un documento OpenAPI 3.1 desde `RouteManifest` + `ConstraintManifest`; memoizado por instancia, ordenado de forma determinista para que las regeneraciones diferencien limpiamente | | `firefly:openapi` | Escribe el documento en `--output=` o en crudo a stdout, convirtiendo la especificación en un artefacto versionable que un job de CI puede diferenciar | -| `OpenApiRouteRegistrar` | Monta `/openapi.json` y `/openapi` nativamente desde un `BootPass` — una ruta configurable que una ruta por atributo nunca habría podido tener, y sin autodocumentación | +| `OpenApiRouteRegistrar` | Monta `/openapi.json`, `/openapi` y `/openapi/assets/{file}` nativamente desde un `BootPass` — rutas configurables que una ruta por atributo nunca habría podido tener, y sin autodocumentación | | `OperationFactory` | Mapea cada `kind` de enlace — `path`/`query`/`header`/`file`/`body` — a su forma OpenAPI; los enlaces `service` nunca aparecen | | `SchemaRegistry` | Un componente por DTO, alcanzado por `$ref`: sin tipos generados duplicados, y un nombre reservado cierra un ciclo recursivo de `$ref` | | `DtoSchemaFactory` | Fusiona los tipos declarados del constructor con las restricciones compiladas; no emite `additionalProperties: false`, porque el servidor ignora las claves extra | @@ -589,6 +674,9 @@ final class ApiDocsConfiguration | `ProblemSchema` | La única respuesta compartida `application/problem+json`; documenta `code`/`category`/`severity`/`errors` de Firefly, con los enums leídos de los propios casos del kernel | | Conjunto de errores derivado | `400` solo cuando algo es rechazable antes de que corra el controlador, `422` solo bajo `#[Valid]`, `default` siempre | | `$route->html` | Las rutas HTML `#[Controller]` quedan excluidas por defecto; `firefly.openapi.include-html` las documenta como `text/html`, nunca como JSON | +| `firefly.openapi.viewer.style` | `swagger` (por defecto) \| `builtin` \| `cdn`. Solo `cdn` hace una petición a un tercero en cada visita; un valor no reconocido cae de vuelta a `swagger` | +| `SwaggerAssets` | Sirve el Swagger UI OFICIAL desde tu propio origen, desde el paquete de composer `swagger-api/swagger-ui` — siete nombres de fichero en lista blanca, cada uno comprobado con `realpath()` dentro del directorio dist | +| `ViewerPage::render()` | Cae de vuelta a `builtin` cuando falta la dist de Swagger, en lugar de renderizar una consola cuyos assets dan 404 | --- @@ -596,4 +684,5 @@ final class ApiDocsConfiguration 1. **Genera el documento de Lumen y léelo.** Ejecuta `php artisan firefly:openapi --output=openapi.json` en el sample y abre `/openapi` en un navegador. Busca `walletBalance` y confirma que no tiene respuesta `400`; luego busca `walletDeposit` y confirma que tiene tanto un `400` como un `422` — y convéncete, con las reglas de este capítulo, de por qué difieren. 2. **Convierte la especificación en una puerta de CI.** Versiona el fichero generado y añade un job que lo regenere y ejecute `git diff --exit-code` sobre él. Cambia un DTO — añade un `#[Size(max: 32)]` a `OpenWalletRequest::$owner_id` — y observa al job fallar con un diff que nombra la palabra clave de esquema exacta que cambió. -3. **Observa a una restricción caer hasta la extensión.** Añade `#[Future]` a una propiedad `string` de un DTO de petición, regenera, y encuentra el array `x-firefly-constraints` de la propiedad llevando `after:now` junto a un `format: date-time` perfectamente corriente. Luego añade `#[Pattern('/^[a-z]+$/i')]` a otra propiedad y compara: el patrón *sí* se publica, y la regla original se registra a su lado porque la bandera `i` no pudo sobrevivir a la traducción. +3. **Demuestra que la consola por defecto no hace ninguna petición saliente.** Abre `/openapi` en el sample con el panel de red del navegador grabando, y confirma que todas las peticiones son del mismo origen: la página, `openapi/assets/swagger-ui.css`, los dos bundles y `openapi.json`. Luego pon `firefly.openapi.viewer.style` a `cdn`, recarga, y observa aparecer `cdn.jsdelivr.net` en ese mismo panel — esa petición es toda la diferencia, y es lo que una CSP estricta o un host aislado bloquearía. +4. **Observa a una restricción caer hasta la extensión.** Añade `#[Future]` a una propiedad `string` de un DTO de petición, regenera, y encuentra el array `x-firefly-constraints` de la propiedad llevando `after:now` junto a un `format: date-time` perfectamente corriente. Luego añade `#[Pattern('/^[a-z]+$/i')]` a otra propiedad y compara: el patrón *sí* se publica, y la regla original se registra a su lado porque la bandera `i` no pudo sobrevivir a la traducción. diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index f1e1b14..00c7710 100644 --- a/book/src-es/11-observability-actuator.md +++ b/book/src-es/11-observability-actuator.md @@ -2,7 +2,7 @@ # Observabilidad: Salud, Métricas y el Actuator {.chtitle} -Al terminar este capítulo conocerás el SPI `HealthIndicator` de `firefly/actuator` y los indicadores integrados `Ping`/`DiskSpace`/`Db`, cómo `HealthEndpoint` los agrega en una única respuesta `/actuator/health` — y cómo un **grupo** de sondeo (el mecanismo que hay detrás de "liveness" y "readiness") no es más que un subconjunto de indicadores con nombre y configurado, cómo toda la superficie de gestión está **sin exponer por defecto** de modo que un endpoint olvidado falla cerrado como un 404 en lugar de una fuga de información, y el `MeterRegistry` en PHP puro de `firefly/observability`, su exportador Prometheus a prueba de locale, y el truco exacto de precedencia `#[Order(500)]` — el mismo que el Capítulo 10 te mostró para la seguridad — que permite a `MeterRegistryCqrsMetrics` reemplazar el `NoOpCqrsMetrics` del bus de CQRS sin ningún cambio de código en `firefly/cqrs`. El capítulo cierra con `firefly/admin`, el panel de administración renderizado en el servidor sobre esos mismos endpoints — los lee **en proceso**, de modo que renderiza páginas que la superficie JSON deliberadamente mantiene sin exponer, lo que convierte a su propia URL en toda la frontera de seguridad y a su valor por defecto (`app.debug`) en la línea más importante del paquete. +Al terminar este capítulo conocerás el SPI `HealthIndicator` de `firefly/actuator` y los indicadores integrados `Ping`/`DiskSpace`/`Db`, cómo `HealthEndpoint` los agrega en una única respuesta `/actuator/health` — y cómo un **grupo** de sondeo (el mecanismo que hay detrás de "liveness" y "readiness") no es más que un subconjunto de indicadores con nombre y configurado, cómo toda la superficie de gestión está **sin exponer por defecto** de modo que un endpoint olvidado falla cerrado como un 404 en lugar de una fuga de información, y el `MeterRegistry` en PHP puro de `firefly/observability`, su exportador Prometheus a prueba de locale, y el truco exacto de precedencia `#[Order(500)]` — el mismo que el Capítulo 10 te mostró para la seguridad — que permite a `MeterRegistryCqrsMetrics` reemplazar el `NoOpCqrsMetrics` del bus de CQRS sin ningún cambio de código en `firefly/cqrs`. El capítulo cierra con `firefly/admin`, el panel de administración renderizado en el servidor sobre esos mismos endpoints — trece páginas, incluido un **grafo de beans** dibujado que resuelve cada dependencia de constructor a través de la interfaz por la que está cableada y reporta los ciclos con los que, si no, un arranque moriría sin mensaje. Lee esos endpoints **en proceso**, de modo que renderiza páginas que la superficie JSON deliberadamente mantiene sin exponer, lo que convierte a su propia URL en toda la frontera de seguridad y a su valor por defecto (`app.debug`) en la línea más importante del paquete. !!! note "Término nuevo: actuator" Un **actuator** es un endpoint de gestión que informa sobre el *proceso en ejecución en sí* — si está sano, con qué arrancó, cuán rápidas son sus peticiones — en lugar de sobre el dominio de negocio al que sirve el proceso. El término y la forma provienen ambos de Spring Boot Actuator; `firefly/actuator` es un análogo PHP de primera parte y con pocas dependencias: endpoints de framework montados directamente sobre el mismo `Router` de Illuminate que usan tus propios controladores, no un proceso de administración separado. @@ -660,7 +660,7 @@ composer require firefly/admin Después abre `/firefly`. No hay paso de npm en la instalación ni CDN en tiempo de petición — las vistas son Blade puro con CSS en línea y tipografías del sistema, porque un paquete de Composer no puede dar por hecho que npm se ha ejecutado, y un panel que necesita la red es inútil precisamente en los entornos aislados donde más quieres mirar uno. -Cada página es una vista sobre la carga útil de un endpoint, y el menú las agrupa como piensa un operador y no como están dispuestos los paquetes — qué está haciendo ahora mismo, qué cableó en el arranque, y cómo está configurado: +Trece páginas, cada una una vista sobre la carga útil de un endpoint. El menú las agrupa como piensa un operador y no como están dispuestos los paquetes — qué está haciendo ahora mismo, qué cableó en el arranque, y cómo está configurado — porque una lista plana de trece enlaces es peor menú que tres cortas: | Grupo | Página | Lee | Responde | |---|---|---|---| @@ -669,6 +669,7 @@ Cada página es una vista sobre la carga útil de un endpoint, y el menú las ag | Runtime | Métricas | `metrics` | Contadores, cronómetros y medidores, con sus mediciones actuales | | Runtime | Tráfico HTTP | `httpexchanges` | Las peticiones más recientes que sirvió esta aplicación | | Cableado | Beans | `beans` | Cada bean que registró el contenedor, con el estereotipo que lo declaró | +| Cableado | Grafo de beans | `beans` | Cómo dependen tus beans unos de otros, resueltos a través de las interfaces por las que están cableados | | Cableado | Condiciones | `conditions` | Qué auto-configuraciones se aplicaron, y cuáles se echaron atrás porque aportaste la tuya | | Cableado | Rutas | `mappings` | La tabla de rutas compilada desde la que sirve el dispatcher | | Cableado | Programadas | `scheduledtasks` | Métodos registrados por `#[Scheduled]`, con el cron o intervalo que los dispara | @@ -736,6 +737,99 @@ Un endpoint que lanza se captura y se reporta como `null` en vez de dejar que se --- +### El grafo de beans + +Doce de las trece páginas son tablas. La decimotercera dibuja una imagen, y es la que amortiza el paquete el día en que algo está mal cableado. + +`/actuator/beans` te dice *qué* beans existen. No puede decirte a qué está **cableado** cada uno, que es lo que realmente quieres cuando un `#[ConditionalOnMissingBean]` no se disparó como esperabas, cuando un ciclo entre singletons ansiosos ha colgado un arranque sin mensaje alguno, o cuando intentas averiguar a qué se enganchó el paquete que acabas de instalar. `/firefly/graph` responde a eso, como un diagrama SVG por capas más una tabla de relaciones filtrable. + +No se refleja nada para construirlo. `ComponentScanner` ya registra, en tiempo de **escaneo**, los tipos de clase e interfaz que pide el constructor de cada componente, y esa lista viaja en el manifiesto compilado igual que cualquier otro hecho escaneado (abreviado): + +```php +final class ComponentDescriptor +{ + public function __construct( + public string $class, + public string $stereotype, + public array $interfaces, + /** + * The class types this component's constructor asks for — the edges of the bean graph. + * + * Recorded at scan time, where reflection is already sanctioned, because the alternative is + * reflecting at request time to answer "what depends on what", which the reflection-free boot + * contract forbids. Only CLASS and INTERFACE types are kept: a scalar or a builtin is + * configuration, not a wiring edge, and putting it in the graph would drown the edges that matter. + */ + public array $dependencies = [], + ) {} +} +``` + +Esa última frase es una decisión de diseño en la que merece la pena detenerse. Un parámetro de constructor tipado `string $name` es configuración; dibujarlo como una arista enterraría las relaciones que importan bajo ruido de `string`/`int`. Un parámetro de **clase anulable o con valor por defecto** *sí* se conserva, porque un colaborador opcional sigue siendo una relación. + +#### Lo difícil no es dibujar, es resolver + +Un constructor pide un **tipo**, y ese tipo es muy a menudo una interfaz — `EventPublisher`, `HealthIndicator`, `Cache` — mientras que el bean que lo satisface es una clase concreta que meramente la implementa. Una lista de aristas construida ingenuamente a partir de los tipos del constructor apunta entonces a nodos que no existen, y el grafo sale como un campo de puntos desconectados. Pregúntate a qué debería dibujar una flecha la dependencia de `WalletService` sobre `WalletRepository`: no al puerto, que es una interfaz sin bean propio, sino a `EloquentWalletRepository`, que es lo que de verdad se va a construir. + +Así que cada dependencia se resuelve a través de un índice de interfaces antes de convertirse en arista: + +```php +foreach ($rows as $class => $row) { + foreach ($row['dependencies'] as $dependency) { + $target = isset($rows[$dependency]) ? $dependency : ($byInterface[$dependency] ?? null); + + if ($target === null || $target === $class) { + // A type nothing in the container provides: a framework contract satisfied by a binding + // rather than a bean, or a class the scan never saw. Reported, not silently dropped — + // "why is my bean not in the graph" is exactly the question this page has to answer. + if ($target === null) { + $unresolved[] = $dependency; + } + + continue; + } + + $edges[] = ['from' => $class, 'to' => $target, 'via' => $target === $dependency ? null : $dependency]; + } +} +``` + +El miembro `via` es la honestidad de ese bucle. Cuando la arista pasó por una interfaz, el diagrama la marca y la columna **Wired by** de la tabla de relaciones nombra la interfaz, de modo que quien lee ve la indirección en lugar de que se le muestre calladamente una relación que nunca escribió. Cuando el constructor nombró la clase concreta, la columna simplemente dice `class`. + +El índice se construye en orden de catálogo y **gana el primer implementador**, de forma determinista — el catálogo se emite en orden de escaneo, así que la misma aplicación dibuja siempre el mismo grafo en lugar de rebarajarse entre máquinas. Una interfaz con varios implementadores es una ambigüedad real que el contenedor resuelve con `#[Primary]`/`#[Qualifier]`, y el grafo lo dice listando la arista como `via` en lugar de fingir que la elección era obvia. + +#### Capas, ciclos y el techo de nodos + +Los niveles salen de un recorrido de **camino más largo** sobre las aristas resueltas: la profundidad de un nodo es uno más que la de lo más profundo de lo que depende, y después los niveles se invierten para que el nivel 0 contenga aquello de lo que nada depende. El efecto es que un nodo siempre queda por debajo de todo lo que depende de él, las flechas se leen consistentemente hacia abajo, y la vista puede seguir una cadena desde un controlador hasta el repositorio que hay al final. La vista solo posiciona; los niveles vienen del modelo. + +La profundidad se memoiza y el recorrido lleva su propio conjunto de visitados, así que un ciclo termina en lugar de recursar para siempre — y la arista que lo cerró se *reporta*: + +```php +foreach ($out[$node] ?? [] as $next) { + if (isset($path[$next])) { + $cycles[] = ['from' => $node, 'to' => $next]; + + continue; + } + $deepest = max($deepest, $walk($next, $path) + 1); +} +``` + +Ese reporte vale más de lo que parece. El contenedor no tiene detección de ciclos propia, así que un ciclo entre singletons ansiosos no produce un error útil — agota la memoria en el arranque. Una página que nombra las dos clases implicadas convierte "la app murió sin mensaje" en un diagnóstico de cinco segundos, y el consejo del propio panel es el correcto: rompe una de estas aristas, normalmente inyectando una interfaz y dejando que el otro lado dependa de ella. + +Dos límites se declaran en la página en lugar de ocultarse: + +* **Pasados los `firefly.admin.graph.max-nodes` — 220 por defecto — el diagrama se suprime** y la tabla de Relaciones de abajo lleva la misma información como una lista filtrable. Un diagrama de más de un par de centenares de nodos es una maraña, no algo que una persona pueda leer, y renderizarlo de todos modos sería peor respuesta que negarse. Es una clave de configuración y no una constante porque "ilegible" depende de la pantalla y de la aplicación. +* **"Provided outside the container" no es una advertencia.** Esas etiquetas son tipos de constructor satisfechos por un binding del contenedor de Laravel y no por un bean escaneado — la `Request`, el repositorio de configuración, una conexión. Se listan en lugar de descartarse en silencio precisamente porque *"¿por qué no está mi bean en el grafo?"* es la pregunta que la página tiene que responder. Un tipo que aparezca ahí y que esperabas que fuera un bean *tuyo* significa que tu escaneo no lo vio, y `firefly.scan.paths` es lo primero que hay que revisar. + +!!! tip "Léelo junto a la página de Condiciones" + Las dos responden mitades complementarias de toda sorpresa de auto-configuración. **Condiciones** dice *si* un bean del framework se registró o se echó atrás, y sobre qué condición. **El grafo** dice a qué está cableado el bean que sí ganó, y a través de qué interfaz. Una arista `EventPublisher` apuntando a `InMemoryEventPublisher` cuando configuraste `firefly.eda.provider=rabbitmq` se ve de un vistazo en el grafo; Condiciones nombra entonces el `#[ConditionalOnProperty]` que no casó. + +!!! note "Lo que el grafo todavía no dibuja" + Las aristas salen únicamente de las `dependencies` del constructor. `BeansCatalog` publica además los parámetros propios de cada método fábrica `#[Bean]` (bajo `produces`), pero `BeanGraph` no los lee, así que una clase `#[Configuration]` aparece con las aristas que declara *su propio constructor* y el cableado que hacen sus métodos `#[Bean]` no se dibuja. Eso sub-dibuja específicamente las clases de auto-configuración del framework; tus beans `#[Service]`/`#[Repository]`, que cablean por constructor, se dibujan completos. + +--- + ### El modelo de acceso es toda la frontera de seguridad Como el panel sortea la exposición, su propia URL es lo único que se interpone delante de `beans`, `env` y `conditions`. Por eso no debe estar encendido por defecto en producción, y por eso la bandera de activación está escrita como está: @@ -747,6 +841,7 @@ final readonly class AdminSettings public bool $enabled, public string $basePath, public string $title, + // ... mas las opciones de presentacion: refreshSeconds, theme, graphMaxNodes, excludedPages. ) {} public static function fromConfig(Config $config): self @@ -757,6 +852,8 @@ final readonly class AdminSettings enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)), basePath: $base === '' ? 'firefly' : $base, title: $config->string('firefly.admin.title', $config->string('app.name', 'LaraFly')), + // ... firefly.admin.refresh-seconds (10, con suelo en 2), .theme (auto|light|dark), + // .graph.max-nodes (220) y .pages.exclude ('') tambien se leen aqui. ); } } @@ -818,8 +915,10 @@ También se echa atrás en silencio en un caso más, fácil de pasar por alto. B | `PrometheusTextFormat` | Exposición a prueba de locale — `number_format()`, nunca `sprintf('%f')` | | `MetricsFilter` | Filtro de cronometraje más externo `#[Order(-100)]`; etiqueta por la **plantilla** de la ruta, nunca la ruta en bruto — cardinalidad acotada | | `ObservabilityAutoConfiguration` `#[Order(500)]` | El mismo truco de precedencia que la costura de seguridad del Capítulo 10: registra `cqrsMetrics()` antes de que `CqrsAutoConfiguration` evalúe su `#[ConditionalOnMissingBean]` | -| `firefly/admin` | Un panel Blade renderizado en el servidor en `/firefly`; una página cuyo endpoint no está registrado o está apagado se oculta del menú en lugar de enlazarse | +| `firefly/admin` | Un panel Blade renderizado en el servidor en `/firefly`; trece páginas, y una cuyo endpoint no está registrado o está apagado se oculta del menú en lugar de enlazarse | | `AdminEndpointReader` | Invoca cada `ActuatorEndpoint` **en proceso** desde el `ActuatorRegistry`, sorteando `ExposureModel` — así el panel muestra lo que la superficie HTTP no expone, y un endpoint que lanza degrada un solo panel | +| `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado: aristas de constructor resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y el diagrama suprimido pasados `firefly.admin.graph.max-nodes` (220) | +| `ComponentDescriptor::$dependencies` | Las aristas del grafo, registradas por `ComponentScanner` en tiempo de **escaneo** — solo tipos de clase e interfaz, porque un parámetro escalar es configuración, no cableado | | `firefly.admin.enabled` | Toma por defecto `app.debug`; un valor explícito gana en ambas direcciones, y encenderlo con debug apagado te obliga a poner tu propio middleware de autenticación delante de la ruta | --- @@ -830,4 +929,5 @@ También se echa atrás en silencio en un caso más, fácil de pasar por alto. B 2. **Configura una división liveness/readiness real.** Añade `firefly.management.endpoint.health.group.liveness.include = 'ping'` y `...readiness.include = 'ping,db'` (con el indicador de BD habilitado) a la configuración de un proyecto de pruebas, y confirma que `GET /actuator/health/liveness` y `GET /actuator/health/readiness` divergen en el momento en que dejas la base de datos inalcanzable. 3. **Observa a la costura de métricas de CQRS ganar la carrera.** Instala `firefly/observability` en un proyecto de pruebas que ya use `firefly/cqrs`, envía un puñado de comandos, e inspecciona `GET /actuator/prometheus` en busca de muestras de `cqrs_commands_seconds` — luego comenta temporalmente el atributo `#[Order(500)]` de `ObservabilityAutoConfiguration` (revirtiendo al valor por defecto de la clase) y confirma si la métrica todavía aparece, para ver el truco de ordenamiento importar de verdad en lugar de solo leer sobre él. 4. **Demuéstrate a ti mismo el sorteo de la exposición.** Instala `firefly/admin` en el sample, deja `firefly.management.endpoints.web.exposure.include` en su valor por defecto, y confirma que `GET /actuator/beans` devuelve un `404` mientras `/firefly/beans` renderiza la lista completa de beans en el mismo proceso. Luego pon `firefly.management.endpoint.beans.enabled` a `false` y confirma que la entrada Beans desaparece del menú del panel — el interruptor de apagado se honra allí donde la exposición no, y la diferencia entre ambas claves es todo el diseño. -5. **Lee el valor por defecto de acceso como una decisión de seguridad.** Pon `app.debug` a `false` en un proyecto de pruebas con `firefly/admin` instalado y confirma que `/firefly` está genuinamente sin enrutar y no simplemente sin enlazar (`php artisan route:list` no debería listarla). Luego pon `firefly.admin.enabled` a `true` sin añadir ninguna regla de `HttpSecurity`, y mira qué divulga ahora un `GET /firefly/env` sin autenticar — esa es exactamente la brecha que este capítulo te dijo que cerraras con tu propio middleware de autenticación. +5. **Dibuja tu propio cableado y luego rómpelo.** Abre `/firefly/graph` en el sample y encuentra la flecha de `WalletService` a `EloquentWalletRepository` — fíjate en que la columna *Wired by* dice `WalletRepository`, el puerto, y no `class`. Después introduce un ciclo deliberado (haz que un `#[Service]` tome un parámetro de constructor tipado como otro `#[Service]` que ya depende de él), recarga la página, y confirma que la estadística **Cycles** se pone en rojo y nombra ambas clases. Ahora arranca la app de cero sin abrir el panel, y compara lo que PHP te cuenta sobre ese mismo ciclo. +6. **Lee el valor por defecto de acceso como una decisión de seguridad.** Pon `app.debug` a `false` en un proyecto de pruebas con `firefly/admin` instalado y confirma que `/firefly` está genuinamente sin enrutar y no simplemente sin enlazar (`php artisan route:list` no debería listarla). Luego pon `firefly.admin.enabled` a `true` sin añadir ninguna regla de `HttpSecurity`, y mira qué divulga ahora un `GET /firefly/env` sin autenticar — esa es exactamente la brecha que este capítulo te dijo que cerraras con tu propio middleware de autenticación. diff --git a/book/src/04a-openapi.md b/book/src/04a-openapi.md index 47d9a16..56c1c6e 100644 --- a/book/src/04a-openapi.md +++ b/book/src/04a-openapi.md @@ -2,7 +2,7 @@ # Documenting the API: OpenAPI 3.1 from the Manifests {.chtitle} -By the end of this chapter you will know how `firefly/openapi` turns the `RouteManifest` and `ConstraintManifest` Chapter 4 just built into a valid OpenAPI 3.1 document with **no annotation dialect of its own** — how `firefly:openapi` makes that document a build artifact a CI job can diff, how each binding `kind` in the compiled route plan becomes a Parameter Object or a Request Body, how a `#[NotBlank]` or a `#[Positive]` you already wrote becomes a `pattern` or an `exclusiveMinimum`, why every DTO is registered once and reached by `$ref` rather than inlined, why every operation carries the same `problem+json` error component Chapter 4's renderer produces, and why a `#[Controller]` HTML route is left out of the document by default. +By the end of this chapter you will know how `firefly/openapi` turns the `RouteManifest` and `ConstraintManifest` Chapter 4 just built into a valid OpenAPI 3.1 document with **no annotation dialect of its own** — how `firefly:openapi` makes that document a build artifact a CI job can diff, how each binding `kind` in the compiled route plan becomes a Parameter Object or a Request Body, how a `#[NotBlank]` or a `#[Positive]` you already wrote becomes a `pattern` or an `exclusiveMinimum`, why every DTO is registered once and reached by `$ref` rather than inlined, why every operation carries the same `problem+json` error component Chapter 4's renderer produces, and why a `#[Controller]` HTML route is left out of the document by default. It closes on the browser console the package serves over that document — three styles, of which the default is the **official Swagger UI served from your own origin**, with no npm step and no CDN request, and only one of the three ever talks to a third party. !!! note "New term: specification extension" OpenAPI 3.1 lets a document carry members whose names begin with `x-`, called **specification extensions**. Conforming tools must ignore them, so an extension can record something the standard vocabulary cannot express without making the document invalid. `firefly/openapi` uses exactly one, `x-firefly-constraints`, and this chapter shows what lands in it and why nothing is ever dropped silently instead. @@ -23,7 +23,7 @@ That is the whole installation. Boot the app and `GET /openapi.json` is served; --- -## `firefly:openapi`, and two routes that are not attribute routes +## `firefly:openapi`, and routes that are not attribute routes The document is also a file you can commit: @@ -36,7 +36,7 @@ The command exists so the document can be a **build artifact** rather than only Stdout mode is written with Symfony's `OUTPUT_RAW` flag, and that detail matters more than it looks: console output normally goes through Symfony's formatter, which treats `<...>` as markup. A `description` mentioning a generic type — anything carrying an angle bracket that reached the document from a config value — would either be swallowed or would throw on an unknown tag. The point of stdout mode is to pipe straight into a client generator, so the bytes must be exactly the bytes of the document. That is also why the confirmation line is printed **only** in `--output` mode, where stdout is not the document. -The two HTTP routes are mounted natively on the Illuminate `Router` from a `BootPass`, not declared with `#[GetMapping]`: +The HTTP routes — the spec, the console, and the console's own assets — are mounted natively on the Illuminate `Router` from a `BootPass`, not declared with `#[GetMapping]`: ```php final class OpenApiRouteRegistrar implements BootPass @@ -74,13 +74,21 @@ final class OpenApiRouteRegistrar implements BootPass $router->get($properties->viewerPath, static fn (): mixed => $container->make(OpenApiViewerAction::class)()) ->name('firefly.openapi.viewer'); + + // The official Swagger UI files, served from this application's own origin rather than a CDN. Mounted + // under the viewer path so moving the console moves its assets with it, and constrained to a single + // path segment so the route cannot express a traversal in the first place — SwaggerAssets whitelists + // and realpath-checks the name as well. + $router->get($properties->viewerPath.'/assets/{file}', static fn (string $file): mixed => $container->make(SwaggerAssetAction::class)($file)) + ->where('file', '[A-Za-z0-9._-]+') + ->name('firefly.openapi.assets'); } } ``` -This is the same shape — and the same `BootPass` idiom — Chapter 11 will show you for the actuator's own routes, and it is chosen for two independent reasons. First, **an attribute route cannot be configurable**: `#[GetMapping('/openapi.json')]` bakes its literal into a compiled `RouteDescriptor` at `firefly:cache` time, so an operator could never move the spec off a path that collides with one of their own, and could never take it off a public surface without deleting the package. Second, an attribute route would enter the application's `RouteManifest` — and the generator reads that manifest, so **the package would document itself**. Registering natively keeps both problems out of existence: the two paths come from config at boot, and they never appear in the spec they serve. +This is the same shape — and the same `BootPass` idiom — Chapter 11 will show you for the actuator's own routes, and it is chosen for two independent reasons. First, **an attribute route cannot be configurable**: `#[GetMapping('/openapi.json')]` bakes its literal into a compiled `RouteDescriptor` at `firefly:cache` time, so an operator could never move the spec off a path that collides with one of their own, and could never take it off a public surface without deleting the package. Second, an attribute route would enter the application's `RouteManifest` — and the generator reads that manifest, so **the package would document itself**. Registering natively keeps both problems out of existence: the paths come from config at boot, and they never appear in the spec they serve. Note that the assets route is mounted *under* the viewer path, so moving the console moves its stylesheet and scripts with it. -`firefly.openapi.enabled` (default `true`) is enforced *here*, on the routes, rather than on the beans — the generator and its collaborators are inert without routes, so gating the routes is the whole of the switch. Turning it off leaves both paths genuinely unrouted, so they 404 through the router's own `NotFoundHttpException`, which Chapter 4's `ProblemDetailsRenderer` then renders as a proper `404` problem-details body rather than a `500`. +`firefly.openapi.enabled` (default `true`) is enforced *here*, on the routes, rather than on the beans — the generator and its collaborators are inert without routes, so gating the routes is the whole of the switch. Turning it off leaves every one of those paths genuinely unrouted, so they 404 through the router's own `NotFoundHttpException`, which Chapter 4's `ProblemDetailsRenderer` then renders as a proper `404` problem-details body rather than a `500`. !!! note "The actions are resolved per request, inside the closure" Building an `OpenApiSpecAction` at boot and capturing it in the route would freeze one `OpenApiGenerator` into the route for the process's lifetime — exactly the shape that breaks under Octane, where a later request's container is a different sandbox. `$container->make(...)` *inside* the closure is the rule, here and in every other framework package that mounts a native route. @@ -156,12 +164,7 @@ final class OperationFactory } } - $operation = [ - 'operationId' => $operationId, - 'summary' => $this->summary($route), - 'description' => 'Handled by '.$route->controllerClass.'::'.$route->methodName.'().', - 'tags' => [$this->tag($route)], - ]; + // …the operation's own prose (operationId, summary, description, tags) is assembled here… if ($parameters !== []) { $operation['parameters'] = $parameters; @@ -180,7 +183,7 @@ final class OperationFactory } ``` -Reading the plan rather than re-reading the method signature is what makes the mapping unambiguous. `#[PathVariable]`, `#[QueryParam]` and `#[RequestHeader]` become Parameter Objects; `#[UploadedFile]` becomes a `multipart/form-data` part typed `format: binary`; `#[RequestBody]` becomes the Request Body Object; and the sixth `kind`, `service` — the no-attribute container-injected collaborator Chapter 4 introduced — is not part of the HTTP contract at all and never appears in the document. Deriving that list independently would have to re-decide every one of those cases and could disagree with the dispatcher; reading the plan cannot. +The one elided block is where the operation's human-facing prose is put together; everything shown is what the *binding plan* decides. Reading the plan rather than re-reading the method signature is what makes the mapping unambiguous. `#[PathVariable]`, `#[QueryParam]` and `#[RequestHeader]` become Parameter Objects; `#[UploadedFile]` becomes a `multipart/form-data` part typed `format: binary`; `#[RequestBody]` becomes the Request Body Object; and the sixth `kind`, `service` — the no-attribute container-injected collaborator Chapter 4 introduced — is not part of the HTTP contract at all and never appears in the document. Deriving that list independently would have to re-decide every one of those cases and could disagree with the dispatcher; reading the plan cannot. Four smaller decisions finish an operation: @@ -476,23 +479,101 @@ The second half of that method is the blunt instrument for everything else: `fir --- -## The viewer, and the CDN flag +## The viewer: three styles, and only one of them phones out + +`GET /openapi` renders a browser console over the document. Which console is `firefly.openapi.viewer.style`, and the choice is a supply-chain decision dressed up as a preference: + +| `style` | Ships from | Third-party request at page view? | +|---|---|---| +| `swagger` **(default)** | your own origin, out of the `swagger-api/swagger-ui` composer package | **no** | +| `builtin` | inline in the response | **no** | +| `cdn` | `cdn.jsdelivr.net` | **yes, on every view** | + +An unrecognised value falls back to `swagger` rather than rendering a blank page — a typo in a config file should cost you nothing. + +### Why the default is the official Swagger UI, from your own origin + +Every off-the-shelf viewer — Swagger UI, Redoc, Elements — is a bundled JavaScript application, and for years that left a PHP package exactly two options. Vendor a multi-megabyte minified bundle into the package's own git history, so every clone of every dependent project pays for it forever and the framework is pinned to a release train it cannot patch without cutting a release of its own. Or fetch it from a CDN on every page view, which is a supply-chain dependency and a data-protection question, and which renders *nothing at all* in the air-gapped and strict-CSP environments where an internal API console is most wanted. + +There is a third option, and this package takes it. `swagger-api/swagger-ui` publishes its `dist` on Packagist under Apache-2.0, so **composer** can fetch and pin it — it is a hard `require` of `firefly/openapi`, so the files are already on disk in `vendor/` by the time you first hit the route — and `SwaggerAssetAction` serves those files from the application's own origin: + +```php +final class SwaggerAssetAction +{ + public function __construct(private readonly SwaggerAssets $assets) {} + + public function __invoke(string $file): SymfonyResponse + { + $path = $this->assets->path($file); + $type = $this->assets->contentType($file); + + if ($path === null || $type === null) { + return new Response('Not Found', 404, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + + $response = new BinaryFileResponse($path, 200, ['Content-Type' => $type]); + $response->setPublic(); + $response->setMaxAge(31536000); + $response->setImmutable(); + $response->setAutoEtag(); + + return $response; + } +} +``` + +You get the console byte-for-byte as Swagger publishes it — the full feature set, deep linking, try-it-out, the OAuth2 redirect popup — with no CDN request, no npm step, and nothing in this repository's history that a `composer update` could not replace. The long cache header is safe precisely because the bytes are immutable for a pinned version: composer changes them only when the pinned version changes, and the ETag changes with them. -`GET /openapi` renders a reference console: a single self-contained HTML page with **no npm build at install time and no network access at request time**. It groups operations by tag and resolves `$ref` pointers client-side, so a reader sees a DTO's members rather than a pointer into `#/components/schemas`. +!!! note "Path traversal is defended by a whitelist, not a sanitiser" + Only seven basenames are servable at all — `swagger-ui.css`, `swagger-ui-bundle.js`, `swagger-ui-standalone-preset.js`, `oauth2-redirect.html`, two favicons and `index.css` — and each resolved path is `realpath()`-checked to be inside the dist directory. The route itself constrains `{file}` to `[A-Za-z0-9._-]+`, so it cannot even *express* a traversal. Matching against a fixed list rather than scrubbing the input is the deliberate choice: a whitelist cannot be defeated by an encoding trick that a sanitiser missed. Anything else is a plain `text/plain` 404 — not problem+json, because the caller here is a browser fetching a stylesheet, not an API client. -Every off-the-shelf viewer — Swagger UI, Redoc, Elements — is a bundled JavaScript application, which leaves exactly two ways to ship one: vendor a multi-megabyte bundle into a PHP package, or fetch it from a CDN on every page view. The second is a supply-chain dependency and a data-protection question, and it does not render at all in the air-gapped, strict-CSP environments where an internal API console is most wanted. Hence a hand-written, dependency-free default. +The dist directory is located by asking **Composer's own installed-versions metadata** for the package root, rather than walking up from `__DIR__`. The depth from `src/Web/` to `vendor/` differs between an installed package (`vendor/firefly/openapi/src/Web`) and this monorepo (`packages/openapi/src/Web`), so a relative walk would work in exactly one of them; the walk is kept only as a fallback for a runtime whose autoloader cannot answer. -Swagger UI is available for teams that want the full feature set, at an exactly pinned version: +And if the distribution is genuinely absent — a stripped `vendor/`, a phar, a non-composer runtime — `render()` falls back rather than serving a page whose assets 404: + +```php +final class ViewerPage +{ + public function render(string $specUrl, string $style, string $assetBase = ''): string + { + return match (true) { + $style === 'cdn' => $this->swaggerUiFromCdn($specUrl), + // Falling back rather than rendering a broken page: `swagger` is the DEFAULT, so an + // application that has not installed swagger-api/swagger-ui would otherwise get a console + // referencing assets that 404. The built-in reference needs nothing and is always available. + $style === 'swagger' && $this->assets->available() => $this->swaggerUi($specUrl, $assetBase), + default => $this->builtIn($specUrl), + }; + } +} +``` + +### What `builtin` is for + +A hand-written, dependency-free reference: one inline script, a few hundred bytes of CSS, one `fetch` of the spec route, and a palette that follows `prefers-color-scheme`. It does the two things a reader actually needs from a generated spec and that raw JSON does not give them — it groups operations by tag with verbs and paths visible at a glance, and it **resolves `$ref` pointers client-side**, so the reader sees a DTO's members rather than a pointer into `#/components/schemas`. Try-it-out, OAuth flows and code samples are deliberately absent; that is what `swagger` is for. + +Choose it when the deployment's rule is *no third-party JavaScript in the response at all*, rather than merely *no third-party host*. + +!!! note "Why that page is a nowdoc" + The built-in viewer embeds a JavaScript application, and a PHP **heredoc** interpolates variables. Every `$ref`, `$schema` and `$1` in that script was therefore read as a PHP variable — `$ref` silently became the empty string, and `$ref` resolution, the whole point of the page, stopped working. A nowdoc takes the script verbatim and the two real substitutions are made explicitly afterwards. It is the kind of bug that produces no error anywhere: the page renders, and simply shows pointers instead of schemas. + +### What `cdn` costs ```php // config/firefly.php return [ - 'openapi' => ['viewer' => ['cdn' => true]], + 'openapi' => ['viewer' => ['style' => 'cdn']], ]; ``` -!!! warning "Turning the CDN flag on means the browser fetches code from a third party" - `firefly.openapi.viewer.cdn` defaults to `false`. With it on, every page view loads Swagger UI from `cdn.jsdelivr.net`. No Subresource Integrity hash is claimed, deliberately: a hash the framework cannot verify at release time is security theatre, and a wrong one would simply break the page. The honest statement is the one in the package README — this is a request to a third party on every view. +Every page view then loads Swagger UI from `cdn.jsdelivr.net`. The version is pinned exactly, and **no Subresource Integrity hash is claimed** — deliberately: a hash the framework cannot verify at release time is security theatre, and a wrong one would simply break the page. + +Weigh the trade honestly. In exchange for a request to a third party on every view, a Content-Security-Policy that has to allow that host, and a console that renders nothing in an air-gapped deployment, you get… the same Swagger UI that `swagger` already served you from your own origin. The style is kept because it is what most tutorials show, and because some organisations genuinely prefer their bytes to come from a cache they already trust — not because it is the better default. + +!!! warning "`viewer.cdn` still wins over `viewer.style`" + `firefly.openapi.viewer.cdn` (default `false`) is the older boolean spelling of this option, from before `style` existed. It still **forces** the CDN page and overrides `style`, so an application that set it keeps the behaviour it configured rather than being silently moved onto a different console by a framework upgrade. Prefer `style` in new configuration; delete `cdn` when you adopt it. + +The viewer fetches the spec from the sibling route rather than having the document inlined into the page, so a regenerated spec shows up on a plain browser refresh, and so the two routes can be exposed independently — a deployment may well want the machine-readable document public and the console off, or the reverse. The spec URL is resolved through the `UrlGenerator` rather than concatenated, because an app mounted under a subdirectory or behind `APP_URL` would otherwise get a link that 404s from every page but the root, and a viewer whose only network call is wrong is a viewer that shows nothing at all. --- @@ -507,18 +588,19 @@ declare(strict_types=1); return [ 'openapi' => [ - 'enabled' => true, // master gate: off means both routes are genuinely unrouted + 'enabled' => true, // master gate: off means every route is genuinely unrouted 'path' => '/openapi.json', // spec route 'viewer' => [ 'enabled' => true, - 'path' => '/openapi', - 'cdn' => false, // opt in to Swagger UI over a CDN — see above + 'path' => '/openapi', // assets are mounted under {path}/assets/{file} + 'style' => 'swagger', // swagger (default) | builtin | cdn — see above ], 'title' => 'Lumen Wallet API', 'version' => '1.0.0', 'description' => '', 'servers' => ['https://api.example.test'], // bare URLs or OpenAPI Server Objects 'exclude' => '/internal,/admin', // CSV of path prefixes to leave out + 'include-html' => false, // document #[Controller] routes as text/html ], ]; ``` @@ -539,6 +621,7 @@ return [ 'enabled' => true, 'rules' => [ ['pattern' => 'openapi', 'access' => 'hasRole:DEVELOPER'], + ['pattern' => 'openapi/*', 'access' => 'hasRole:DEVELOPER'], ['pattern' => 'openapi.json', 'access' => 'hasRole:DEVELOPER'], ], ], @@ -546,6 +629,8 @@ return [ ]; ``` +Note the three patterns. `openapi` alone does not match `openapi/assets/swagger-ui.css`, and `openapi.json` is a separate literal — a rule set that covers the console but not its assets produces an authenticated page whose stylesheet answers 401, which is a worse outcome than either extreme. + The alternative, for a deployment that wants no documentation surface at all in production, is `enabled => false` plus a `firefly:openapi --output=` step in CI. --- @@ -580,7 +665,7 @@ final class ApiDocsConfiguration |---|---| | `OpenApiGenerator` | Assembles an OpenAPI 3.1 document from `RouteManifest` + `ConstraintManifest`; memoised per instance, deterministically ordered so regenerations diff cleanly | | `firefly:openapi` | Writes the document to `--output=` or raw to stdout, making the spec a committable build artifact a CI job can diff | -| `OpenApiRouteRegistrar` | Mounts `/openapi.json` and `/openapi` natively from a `BootPass` — a configurable path an attribute route could never have, and no self-documentation | +| `OpenApiRouteRegistrar` | Mounts `/openapi.json`, `/openapi` and `/openapi/assets/{file}` natively from a `BootPass` — configurable paths an attribute route could never have, and no self-documentation | | `OperationFactory` | Maps each binding `kind` — `path`/`query`/`header`/`file`/`body` — to its OpenAPI shape; `service` bindings never appear | | `SchemaRegistry` | One component per DTO, reached by `$ref`: no duplicate generated types, and a reserved name closes a recursive `$ref` cycle | | `DtoSchemaFactory` | Merges declared constructor types with compiled constraints; emits no `additionalProperties: false`, because the server ignores extra keys | @@ -589,6 +674,9 @@ final class ApiDocsConfiguration | `ProblemSchema` | The one shared `application/problem+json` response; documents Firefly's `code`/`category`/`severity`/`errors`, with the enums read off the kernel's own cases | | Derived error set | `400` only when something is rejectable before the controller runs, `422` only under `#[Valid]`, `default` always | | `$route->html` | `#[Controller]` HTML routes are excluded by default; `firefly.openapi.include-html` documents them as `text/html`, never as JSON | +| `firefly.openapi.viewer.style` | `swagger` (default) \| `builtin` \| `cdn`. Only `cdn` makes a third-party request at page view; an unrecognised value falls back to `swagger` | +| `SwaggerAssets` | Serves the OFFICIAL Swagger UI from your own origin out of the `swagger-api/swagger-ui` composer package — seven whitelisted basenames, each `realpath()`-checked inside the dist directory | +| `ViewerPage::render()` | Falls back to `builtin` when the Swagger dist is absent, rather than rendering a console whose assets 404 | --- @@ -596,4 +684,5 @@ final class ApiDocsConfiguration 1. **Generate Lumen's document and read it.** Run `php artisan firefly:openapi --output=openapi.json` in the sample, then open `/openapi` in a browser. Find `walletBalance` and confirm it has no `400` response, then find `walletDeposit` and confirm it has both a `400` and a `422` — and satisfy yourself, from this chapter's rules, why the two differ. 2. **Make the spec a CI gate.** Commit the generated file, then add a job that regenerates it and runs `git diff --exit-code` over it. Change a DTO — add a `#[Size(max: 32)]` to `OpenWalletRequest::$owner_id` — and watch the job fail with a diff that names the exact schema keyword that changed. -3. **Watch a constraint fall through to the extension.** Add `#[Future]` to a `string` property on a request DTO, regenerate, and find the property's `x-firefly-constraints` array carrying `after:now` beside a perfectly ordinary `format: date-time`. Then add `#[Pattern('/^[a-z]+$/i')]` to another property and compare: the pattern *is* published, and the original rule is recorded beside it because the `i` flag could not survive the translation. +3. **Prove the default console makes no outbound request.** Open `/openapi` in the sample with the browser's network panel recording, and confirm every request is same-origin: the page, `openapi/assets/swagger-ui.css`, the two bundles, and `openapi.json`. Then set `firefly.openapi.viewer.style` to `cdn`, reload, and watch `cdn.jsdelivr.net` appear in the same panel — that request is the entire difference, and it is what a strict CSP or an air-gapped host would block. +4. **Watch a constraint fall through to the extension.** Add `#[Future]` to a `string` property on a request DTO, regenerate, and find the property's `x-firefly-constraints` array carrying `after:now` beside a perfectly ordinary `format: date-time`. Then add `#[Pattern('/^[a-z]+$/i')]` to another property and compare: the pattern *is* published, and the original rule is recorded beside it because the `i` flag could not survive the translation. diff --git a/book/src/11-observability-actuator.md b/book/src/11-observability-actuator.md index bf59c14..b894fce 100644 --- a/book/src/11-observability-actuator.md +++ b/book/src/11-observability-actuator.md @@ -2,7 +2,7 @@ # Observability: Health, Metrics, and the Actuator {.chtitle} -By the end of this chapter you will know `firefly/actuator`'s `HealthIndicator` SPI and the built-in `Ping`/`DiskSpace`/`Db` indicators, how `HealthEndpoint` aggregates them into a single `/actuator/health` response — and how a probe **group** (the mechanism behind "liveness" and "readiness") is nothing more than a named, configured subset of indicators, how the whole management surface is **unexposed by default** so a forgotten endpoint fails closed as a 404 rather than an information leak, and `firefly/observability`'s pure-PHP `MeterRegistry`, its locale-safe Prometheus exporter, and the exact `#[Order(500)]` precedence trick — the same one Chapter 10 showed you for security — that lets `MeterRegistryCqrsMetrics` replace the CQRS bus's `NoOpCqrsMetrics` with no code change to `firefly/cqrs` at all. The chapter closes on `firefly/admin`, the server-rendered browser dashboard over those same endpoints — it reads them **in-process**, so it renders pages the JSON surface deliberately keeps unexposed, which makes its own URL the entire security boundary and its default (`app.debug`) the most important line in the package. +By the end of this chapter you will know `firefly/actuator`'s `HealthIndicator` SPI and the built-in `Ping`/`DiskSpace`/`Db` indicators, how `HealthEndpoint` aggregates them into a single `/actuator/health` response — and how a probe **group** (the mechanism behind "liveness" and "readiness") is nothing more than a named, configured subset of indicators, how the whole management surface is **unexposed by default** so a forgotten endpoint fails closed as a 404 rather than an information leak, and `firefly/observability`'s pure-PHP `MeterRegistry`, its locale-safe Prometheus exporter, and the exact `#[Order(500)]` precedence trick — the same one Chapter 10 showed you for security — that lets `MeterRegistryCqrsMetrics` replace the CQRS bus's `NoOpCqrsMetrics` with no code change to `firefly/cqrs` at all. The chapter closes on `firefly/admin`, the server-rendered browser dashboard over those same endpoints — thirteen pages including a drawn **bean graph** that resolves every constructor dependency through the interface it is wired by and reports the cycles a boot would otherwise die on with no message. It reads those endpoints **in-process**, so it renders pages the JSON surface deliberately keeps unexposed, which makes its own URL the entire security boundary and its default (`app.debug`) the most important line in the package. !!! note "New term: actuator" An **actuator** is a management endpoint that reports on the *running process itself* — is it healthy, what did it boot with, how fast are its requests — rather than on the business domain the process serves. The term and the shape both come from Spring Boot Actuator; `firefly/actuator` is a first-party, dependency-light PHP analogue: framework endpoints mounted directly on the same Illuminate `Router` your own controllers use, not a separate admin process. @@ -660,7 +660,7 @@ composer require firefly/admin Then open `/firefly`. There is no npm step at install time and no CDN at request time — the views are plain Blade with inline CSS and system fonts, because a Composer package cannot assume npm has run, and a dashboard that needs the network is useless in exactly the isolated environments where you most want to look at one. -Each page is a view over one endpoint's payload, and the menu groups them the way an operator thinks rather than the way the packages are laid out — what is it doing right now, what did it wire at boot, and how is it configured: +Thirteen pages, each a view over one endpoint's payload. The menu groups them the way an operator thinks rather than the way the packages are laid out — what is it doing right now, what did it wire at boot, and how is it configured — because a flat list of thirteen links is a worse menu than three short ones: | Group | Page | Reads | Answers | |---|---|---|---| @@ -669,6 +669,7 @@ Each page is a view over one endpoint's payload, and the menu groups them the wa | Runtime | Metrics | `metrics` | Counters, timers and gauges, with their current measurements | | Runtime | HTTP traffic | `httpexchanges` | The most recent requests this application served | | Wiring | Beans | `beans` | Every bean the container registered, with the stereotype that declared it | +| Wiring | Bean graph | `beans` | How your beans depend on one another, resolved through the interfaces they are wired by | | Wiring | Conditions | `conditions` | Which auto-configurations applied, and which backed off because you supplied your own | | Wiring | Routes | `mappings` | The compiled route table the dispatcher serves from | | Wiring | Scheduled | `scheduledtasks` | Methods registered by `#[Scheduled]`, with the cron or interval that drives them | @@ -736,6 +737,99 @@ A throwing endpoint is caught and reported as `null` rather than allowed to take --- +### The bean graph + +Twelve of the thirteen pages are tables. The thirteenth draws a picture, and it is the one that pays for the package on the day something is wired wrongly. + +`/actuator/beans` tells you *which* beans exist. It cannot tell you what each one is **wired to**, which is what you actually want when a `#[ConditionalOnMissingBean]` did not fire the way you expected, when an eager singleton cycle has hung a boot with no message, or when you are trying to work out what a package you just installed attached itself to. `/firefly/graph` answers that, as a layered SVG diagram plus a filterable relations table. + +Nothing is reflected to build it. `ComponentScanner` already records, at **scan** time, the class and interface types each component's constructor asks for, and that list rides the compiled manifest exactly like every other scanned fact (abridged): + +```php +final class ComponentDescriptor +{ + public function __construct( + public string $class, + public string $stereotype, + public array $interfaces, + /** + * The class types this component's constructor asks for — the edges of the bean graph. + * + * Recorded at scan time, where reflection is already sanctioned, because the alternative is + * reflecting at request time to answer "what depends on what", which the reflection-free boot + * contract forbids. Only CLASS and INTERFACE types are kept: a scalar or a builtin is + * configuration, not a wiring edge, and putting it in the graph would drown the edges that matter. + */ + public array $dependencies = [], + ) {} +} +``` + +That last sentence is a design decision worth pausing on. A constructor parameter typed `string $name` is configuration; drawing it as an edge would bury the relationships that matter under `string`/`int` noise. A **nullable or defaulted class** parameter *is* kept, because an optional collaborator is still a relationship. + +#### The hard part is not drawing, it is resolving + +A constructor asks for a **type**, and that type is very often an interface — `EventPublisher`, `HealthIndicator`, `Cache` — while the bean that satisfies it is a concrete class that merely implements it. An edge list built naively from constructor types therefore points at nodes that do not exist, and the graph comes out as a field of disconnected dots. Ask yourself what `WalletService`'s dependency on `WalletRepository` should draw an arrow *to*: not to the port, which is an interface with no bean of its own, but to `EloquentWalletRepository`, which is the thing that will actually be constructed. + +So every dependency is resolved through an interface index before it becomes an edge: + +```php +foreach ($rows as $class => $row) { + foreach ($row['dependencies'] as $dependency) { + $target = isset($rows[$dependency]) ? $dependency : ($byInterface[$dependency] ?? null); + + if ($target === null || $target === $class) { + // A type nothing in the container provides: a framework contract satisfied by a binding + // rather than a bean, or a class the scan never saw. Reported, not silently dropped — + // "why is my bean not in the graph" is exactly the question this page has to answer. + if ($target === null) { + $unresolved[] = $dependency; + } + + continue; + } + + $edges[] = ['from' => $class, 'to' => $target, 'via' => $target === $dependency ? null : $dependency]; + } +} +``` + +The `via` member is the honesty in that loop. When the edge went through an interface, the diagram marks it and the Relations table's **Wired by** column names the interface, so a reader can see the indirection rather than being quietly shown a relationship they never wrote. When the constructor named the concrete class, the column just says `class`. + +The index is built in catalogue order and **first implementor wins**, deterministically — the catalogue is emitted in scan order, so the same application always draws the same graph rather than reshuffling between machines. An interface with several implementors is a real ambiguity that the container resolves with `#[Primary]`/`#[Qualifier]`, and the graph says so by listing the edge as `via` rather than pretending the choice was obvious. + +#### Layers, cycles, and the node ceiling + +Levels come from a **longest-path** walk over the resolved edges: a node's depth is one more than the deepest thing it depends on, and the levels are then flipped so level 0 holds the things nothing depends on. The effect is that a node always sits below everything that depends on it, arrows read consistently downward, and the eye can follow a chain from a controller to the repository at the bottom of it. The view only positions; the levels come from the model. + +Depth is memoised and the walk carries its own visited set, so a cycle terminates instead of recursing forever — and the edge that closed it is *reported*: + +```php +foreach ($out[$node] ?? [] as $next) { + if (isset($path[$next])) { + $cycles[] = ['from' => $node, 'to' => $next]; + + continue; + } + $deepest = max($deepest, $walk($next, $path) + 1); +} +``` + +That reporting is worth more than it looks. The container has no cycle detection of its own, so a cycle among eager singletons does not produce a helpful error — it exhausts memory at boot. A page that names the two classes involved turns "the app died with no message" into a five-second diagnosis, and the panel's own advice is the right one: break one of these edges, usually by injecting an interface and letting the other side depend on that. + +Two limits are stated in the page rather than hidden: + +* **Past `firefly.admin.graph.max-nodes` — 220 by default — the diagram is suppressed** and the Relations table below carries the same information as a filterable list. A diagram past a couple of hundred nodes is a hairball, not something a person can read, and rendering one anyway would be a worse answer than declining to. It is a config key rather than a constant because "unreadable" depends on the screen and the application. +* **"Provided outside the container" is not a warning.** Those chips are constructor types satisfied by a Laravel container binding rather than a scanned bean — the `Request`, the config repository, a connection. They are listed rather than silently dropped precisely because *"why is my bean not in the graph"* is the question the page has to answer. A type appearing there that you expected to be a bean of *yours* means your scan did not see it, and `firefly.scan.paths` is the first thing to check. + +!!! tip "Read it next to the Conditions page" + The two answer complementary halves of every auto-configuration surprise. **Conditions** says *whether* a framework bean was registered or backed off, and on which condition. **The graph** says what the bean that did win is wired to, and through which interface. An `EventPublisher` edge pointing at `InMemoryEventPublisher` when you configured `firefly.eda.provider=rabbitmq` is one glance on the graph; Conditions then names the `#[ConditionalOnProperty]` that did not match. + +!!! note "What the graph does not draw yet" + Edges come from constructor `dependencies` only. `BeansCatalog` also publishes each `#[Bean]` factory method's own parameters (under `produces`), but `BeanGraph` does not read them, so a `#[Configuration]` class appears with the edges *its own constructor* declares and the wiring its `#[Bean]` methods perform is not drawn. That under-draws framework auto-configuration classes specifically; your `#[Service]`/`#[Repository]` beans, which wire through constructors, are drawn in full. + +--- + ### The access model is the whole security boundary Because the dashboard bypasses exposure, its own URL is the only thing standing in front of `beans`, `env` and `conditions`. That is why it must not be on by default in production, and why the enable flag is written the way it is: @@ -747,6 +841,7 @@ final readonly class AdminSettings public bool $enabled, public string $basePath, public string $title, + // ... plus the presentation options: refreshSeconds, theme, graphMaxNodes, excludedPages. ) {} public static function fromConfig(Config $config): self @@ -757,6 +852,8 @@ final readonly class AdminSettings enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)), basePath: $base === '' ? 'firefly' : $base, title: $config->string('firefly.admin.title', $config->string('app.name', 'LaraFly')), + // ... firefly.admin.refresh-seconds (10, floored at 2), .theme (auto|light|dark), + // .graph.max-nodes (220) and .pages.exclude ('') are read here too. ); } } @@ -818,8 +915,10 @@ It also backs off silently in one more case that is easy to miss. Blade is requi | `PrometheusTextFormat` | Locale-safe exposition — `number_format()`, never `sprintf('%f')` | | `MetricsFilter` | `#[Order(-100)]` outermost timing filter; tags by route **template**, never raw path — bounded cardinality | | `ObservabilityAutoConfiguration` `#[Order(500)]` | The same precedence trick as Chapter 10's security seam: registers `cqrsMetrics()` before `CqrsAutoConfiguration` evaluates its `#[ConditionalOnMissingBean]` | -| `firefly/admin` | A server-rendered Blade dashboard at `/firefly`; a page whose endpoint is unregistered or switched off is hidden from the menu rather than linked | +| `firefly/admin` | A server-rendered Blade dashboard at `/firefly`; thirteen pages, and one whose endpoint is unregistered or switched off is hidden from the menu rather than linked | | `AdminEndpointReader` | Invokes each `ActuatorEndpoint` **in-process** from `ActuatorRegistry`, bypassing `ExposureModel` — so the dashboard shows what the HTTP surface does not expose, and a throwing endpoint degrades one panel | +| `BeanGraph` | Turns the beans catalogue into a drawn dependency graph: constructor edges resolved through an interface index (marked `via`), longest-path layering, cycles reported rather than hung on, and the diagram suppressed past `firefly.admin.graph.max-nodes` (220) | +| `ComponentDescriptor::$dependencies` | The graph's edges, recorded by `ComponentScanner` at **scan** time — class and interface types only, because a scalar parameter is configuration, not wiring | | `firefly.admin.enabled` | Defaults to `app.debug`; an explicit value wins in both directions, and turning it on with debug off obliges you to put your own auth middleware in front of the route | --- @@ -830,4 +929,5 @@ It also backs off silently in one more case that is easy to miss. Blade is requi 2. **Configure a real liveness/readiness split.** Add `firefly.management.endpoint.health.group.liveness.include = 'ping'` and `...readiness.include = 'ping,db'` (with the DB indicator enabled) to a scratch project's config, and confirm `GET /actuator/health/liveness` and `GET /actuator/health/readiness` diverge the moment you make the database unreachable. 3. **Watch the CQRS metrics seam win the race.** Install `firefly/observability` into a scratch project already using `firefly/cqrs`, send a handful of commands, and inspect `GET /actuator/prometheus` for `cqrs_commands_seconds` samples — then temporarily comment out `ObservabilityAutoConfiguration`'s `#[Order(500)]` attribute (reverting to the class default) and confirm whether the metric still appears, to see the ordering trick actually matter rather than just reading about it. 4. **Prove the dashboard's exposure bypass to yourself.** Install `firefly/admin` in the sample, leave `firefly.management.endpoints.web.exposure.include` at its default, and confirm that `GET /actuator/beans` returns a `404` while `/firefly/beans` renders the full bean list in the same process. Then set `firefly.management.endpoint.beans.enabled` to `false` and confirm the Beans entry vanishes from the dashboard's menu — the kill switch is honoured where exposure is not, and the difference between the two keys is the whole design. -5. **Read the access default as a security decision.** Set `app.debug` to `false` in a scratch project with `firefly/admin` installed and confirm `/firefly` is genuinely unrouted rather than merely unlinked (`php artisan route:list` should not list it). Then set `firefly.admin.enabled` to `true` without adding any `HttpSecurity` rule, and look at what an unauthenticated `GET /firefly/env` now discloses — that is precisely the gap this chapter told you to close with your own auth middleware. +5. **Draw your own wiring, then break it.** Open `/firefly/graph` in the sample and find the arrow from `WalletService` to `EloquentWalletRepository` — note that the *Wired by* column says `WalletRepository`, the port, not `class`. Then introduce a deliberate cycle (have a `#[Service]` take a constructor parameter typed as another `#[Service]` that already depends on it), reload the page, and confirm the **Cycles** stat turns red and names both classes. Now boot the app fresh without opening the dashboard, and compare what PHP tells you about the same cycle. +6. **Read the access default as a security decision.** Set `app.debug` to `false` in a scratch project with `firefly/admin` installed and confirm `/firefly` is genuinely unrouted rather than merely unlinked (`php artisan route:list` should not list it). Then set `firefly.admin.enabled` to `true` without adding any `HttpSecurity` rule, and look at what an unauthenticated `GET /firefly/env` now discloses — that is precisely the gap this chapter told you to close with your own auth middleware. diff --git a/docs/README.md b/docs/README.md index 78e2e2d..51e57b2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa |-------|-------------| | [Web Layer](modules/web.md) | `firefly/web` — `#[RestController]`/`#[Controller]` routing, parameter binding, JSON + HTML negotiation, RFC-7807 error rendering | | [Web Filters](modules/web-filters.md) | An ordered `WebFilter` chain bridged onto Laravel's own middleware pipeline | +| [OpenAPI](modules/openapi.md) | `firefly/openapi` — an OpenAPI 3.1 document generated from the compiled manifests, `firefly:openapi`, and the official Swagger UI served from your own origin | ### Resilience & Scheduling @@ -89,6 +90,8 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa |-------|-------------| | [Actuator](modules/actuator.md) | `firefly/actuator` — health/info/beans endpoints, the Spring-Boot-Actuator analogue | | [Observability](modules/observability.md) | `firefly/observability` — the `MeterRegistry`, Prometheus/Micrometer-JSON exposition, CQRS metrics | +| [Admin Dashboard](modules/admin.md) | `firefly/admin` — the browser dashboard over the actuator; reads its endpoints in-process, so its own URL is the security boundary | +| [Bean Graph](modules/bean-graph.md) | The dashboard's drawn dependency graph — interface-resolved edges, longest-path layering, cycle reporting | ### Testing @@ -113,7 +116,7 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa | [Laravel Comparison](laravel-comparison.md) | Side-by-side concept mapping for developers coming from plain Laravel | | [Versioning](versioning.md) | CalVer (`YY.MM.Patch`), no `version` field, how Packagist derives releases from tags | | [Contributing](contributing.md) | Monorepo layout, local setup, conventions, how to add a package | -| [Publishing](publishing.md) | The release/split runbook — one CalVer tag, 26 shippable units | +| [Publishing](publishing.md) | The release/split runbook — one CalVer tag, 28 shippable units | --- @@ -131,7 +134,11 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa - **Writing commands/queries?** See [CQRS](modules/cqrs.md). - **Securing an app?** See [Security](modules/security.md). - **Shipping to production?** See [Actuator](modules/actuator.md), [Observability](modules/observability.md), - and [Resilience](modules/resilience.md). + and [Resilience](modules/resilience.md) — then [Admin Dashboard](modules/admin.md) for the browser view over + all three, and read its [access model](modules/admin.md#access-the-whole-security-boundary) before enabling it + outside `app.debug`. +- **Publishing an API?** See [OpenAPI](modules/openapi.md) — the spec is generated from the same manifests the + dispatcher and validator use, so it cannot drift. - **Writing tests?** See [Testing](modules/testing.md) and [Integration Testing](modules/integration-testing.md). - **Releasing a version?** See [Versioning](versioning.md) and [Publishing](publishing.md), and check the [`CHANGELOG.md`](../CHANGELOG.md) at the repo root. diff --git a/docs/cli.md b/docs/cli.md index bfd9240..ef66372 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -183,3 +183,14 @@ php artisan firefly:db {action=migrate} Delegates to Laravel's own database commands: `migrate` (default), `db:seed` (`firefly:db seed`), or `migrate:fresh` (`firefly:db fresh`). Neither command reimplements any Laravel behavior — both are thin `$this->call(...)` passthroughs. + +## Commands contributed by other packages + +`firefly/cli` is not the only package that registers Artisan commands; a capability package ships its own where the +command is part of that capability rather than of the console. + +| Command | Package | What it does | +|---|---|---| +| `firefly:openapi` | `firefly/openapi` | Writes the generated OpenAPI 3.1 document to `--output=` (parent directories are created, and a summary line is printed) or **raw** to stdout. Stdout is written with Symfony's `OUTPUT_RAW` so the bytes are exactly the document's — `php artisan firefly:openapi \| ` is the intended use — which is also why the confirmation line prints only in `--output` mode. See [OpenAPI](modules/openapi.md#php-artisan-fireflyopenapi). | +| `firefly:eda:consume` | `firefly/eda` | Binds the configured broker destinations and runs the consumer loop. See [EDA](modules/eda.md). | +| `firefly:outbox:relay` | `firefly/eda-postgres` | Forwards committed outbox rows to a second broker. See [EDA Brokers](modules/eda-brokers.md). | diff --git a/docs/index.md b/docs/index.md index 26d0d39..320e172 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,11 @@ request after that runs against plain PHP arrays — no runtime reflection on th - **Secure by default** — a Spring-Security-6-shaped principal model, deny-by-default `HttpSecurity` URL DSL, and method security (`#[PreAuthorize]`) enforced with no proxy magic. - **Production-ready out of the box** — an Actuator surface (health/info/beans) and a Prometheus/Micrometer-style - metrics core, both secured by the same config as everything else. + metrics core, both secured by the same config as everything else, plus a server-rendered + [admin dashboard](modules/admin.md) over them with a drawn [bean graph](modules/bean-graph.md). +- **An API document that cannot drift** — [`firefly/openapi`](modules/openapi.md) generates OpenAPI 3.1 from the + same compiled manifests the dispatcher and the validator read, and serves the official Swagger UI from your own + origin — no annotation dialect, no npm, no CDN. - **A first-party test kit** — a boot harness, recording doubles for every port, and web/data test-slice builders, dogfooded across the framework's own test suite. @@ -58,13 +62,13 @@ Module guides are grouped by concern under [`modules/`](modules/error-handling.m | Group | Guides | |---|---| | **Foundation** | [Error Handling](modules/error-handling.md) · [Dependency Injection](modules/dependency-injection.md) · [Configuration](modules/configuration.md) · [Application Context](modules/context.md) · [Auto-Configuration](modules/starters.md) · [Validation](modules/validation.md) | -| **Web & API** | [Web Layer](modules/web.md) · [Web Filters](modules/web-filters.md) | +| **Web & API** | [Web Layer](modules/web.md) · [Web Filters](modules/web-filters.md) · [OpenAPI](modules/openapi.md) | | **Resilience & Scheduling** | [Resilience](modules/resilience.md) · [Scheduling](modules/scheduling.md) | | **Data & Domain** | [Domain (DDD)](modules/domain.md) · [Data & Repositories](modules/data.md) · [Relational Data](modules/data-relational.md) · [Transactions](modules/transactional.md) | | **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [Messaging](modules/messaging.md) | | **CQRS** | [Command/Query](modules/cqrs.md) | | **Security** | [Security](modules/security.md) | -| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) | +| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) | | **Testing** | [Testing](modules/testing.md) · [Integration Testing](modules/integration-testing.md) | | **Tooling** | [Installer](modules/installer.md) | diff --git a/docs/modules/actuator.md b/docs/modules/actuator.md index 1bbe328..cec9a23 100644 --- a/docs/modules/actuator.md +++ b/docs/modules/actuator.md @@ -14,7 +14,14 @@ always-on, and secured entirely by M11 config with zero code edge to `firefly/se - `/actuator/configprops` — every `#[ConfigProperties]` DTO, with the values it actually resolved off the bound instance, masked - `/actuator/caches` (+ `/actuator/caches/{name}`) — the configured `cache.stores` (name/driver/default only); read-only, no eviction - `/actuator/beans`, `/actuator/conditions`, `/actuator/mappings`, `/actuator/loggers` (GET/POST), `/actuator/scheduledtasks` -- `/actuator/metrics`, `/actuator/prometheus` — supplied by `firefly/observability` when installed +- `/actuator/metrics`, `/actuator/prometheus`, `/actuator/httpexchanges`, `/actuator/process` — supplied by + `firefly/observability` when installed + +!!! tip "A browser view over all of this" + `firefly/admin` renders these same endpoints as a server-side dashboard, reading them **in-process** rather + than over HTTP — so it shows pages the exposure model below deliberately keeps unpublished. That inversion is + the whole of its security model: see [Admin Dashboard](admin.md), and read + [its access model](admin.md#access-the-whole-security-boundary) before enabling it outside `app.debug`. ## Health diff --git a/docs/modules/admin.md b/docs/modules/admin.md new file mode 100644 index 0000000..eb60e2b --- /dev/null +++ b/docs/modules/admin.md @@ -0,0 +1,248 @@ +# Admin Dashboard + +`firefly/admin` is a server-rendered browser dashboard over the actuator — the Spring Boot Admin analogue, with one +structural difference: it is not a separate monitoring application you deploy and register instances with. It is +Blade views *inside* the application it reports on, which is why it can read the endpoint registry directly, and +why its access model matters as much as it does. + +```bash +composer require firefly/admin +``` + +Then open `/firefly`. It is not part of the `firefly/firefly` metapackage — like `firefly/openapi` and the broker +adapters, it is an opt-in dependency. + +!!! warning "The access model is the whole security model" + `firefly.admin.enabled` defaults to `app.debug`, because the dashboard bypasses the actuator's + exposure model and its own URL is therefore the only thing in front of `beans`, `env` and + `conditions`. It ships **no authentication of its own**. Read + [Access: the whole security boundary](#access-the-whole-security-boundary) before enabling it + outside debug. + +## The thirteen pages + +Every page is a view over one `ActuatorEndpoint`'s payload. The menu groups them the way an operator thinks rather +than the way the packages are laid out — *what is it doing right now*, *what did it wire at boot*, *how is it +configured* — because a flat list of thirteen links is a worse menu than three short ones. + +| Group | Page | Path | Endpoint | Answers | +|---|---|---|---|---| +| Runtime | Overview | `/firefly` | several | Is it healthy, what is it doing, and what did it wire? | +| Runtime | Health | `/firefly/health` | `health` | Every indicator this process registered, with its own status and details | +| Runtime | Metrics | `/firefly/metrics` | `metrics` | Counters, timers and gauges, with their current measurements | +| Runtime | HTTP traffic | `/firefly/http` | `httpexchanges` | The most recent requests this application served | +| Wiring | Beans | `/firefly/beans` | `beans` | Every bean the container registered, with the stereotype that declared it | +| Wiring | **Bean graph** | `/firefly/graph` | `beans` | How your beans depend on one another — see [Bean Graph](bean-graph.md) | +| Wiring | Conditions | `/firefly/conditions` | `conditions` | Which auto-configurations applied, and which backed off because you supplied your own | +| Wiring | Routes | `/firefly/mappings` | `mappings` | The compiled route table the dispatcher serves from | +| Wiring | Scheduled | `/firefly/scheduled` | `scheduledtasks` | Methods registered by `#[Scheduled]`, with the cron or interval that drives them | +| Configuration | Environment | `/firefly/env` | `env` | Resolved `firefly.*` configuration, flattened to dotted keys, with secrets masked | +| Configuration | Config properties | `/firefly/configprops` | `configprops` | Every `#[ConfigProperties]` DTO the application bound, with the values it resolved | +| Configuration | Caches | `/firefly/caches` | `caches` | The cache stores this application has configured | +| Configuration | Loggers | `/firefly/loggers` | `loggers` | Log channels and their levels, with a control to change one | + +A page whose endpoint is **not registered in this process** — or is switched off — is *hidden from the menu* rather +than offered as a link that lands on an apology, and requesting it directly answers 404 with a page saying which +endpoint it needed. That matters because the actuator's endpoints are conditional: `metrics` disappears when +`firefly.observability.metrics.enabled` is false, and `configprops`, `caches` and `httpexchanges` exist only if the +package contributing them is installed. The menu has to be built from what this process actually registered, so it +is. + +The Overview is the page an operator leaves open, so it answers the three questions that matter without a click: the +aggregate health status with every indicator beside it, the `/actuator/info` runtime fragment flattened to one row +per fact (with byte-ish keys formatted as sizes rather than printed as raw JSON), bean/route/condition/task counts, +the current metrics, the last eight HTTP exchanges, and whether this process booted **compiled** or **scanned** — +read from `AppScan::cachedFile(...)`, not from configuration. + +Values are formatted for reading, not for scraping: `2.0 MB` rather than `2097152`, `31.2 ms` rather than `0.0312`. +That formatting lives in the dashboard, never in the endpoint, because the JSON surface has to keep returning +machine-readable numbers — Prometheus scrapes it. + +## It reads endpoints in-process, not over HTTP + +`AdminEndpointReader` holds the `ActuatorRegistry` and invokes each `ActuatorEndpoint` bean directly: + +```php +public function read(string $id, array $subPath = [], array $query = []): ?array +{ + $endpoint = $this->registry->get($id); + if ($endpoint === null || ! $this->has($id)) { + return null; + } + + try { + $response = $endpoint->handle(new EndpointRequest('GET', $subPath, $query)); + } catch (Throwable) { + return null; + } + + return $response === null || is_string($response->body) ? null : $response->body; +} +``` + +What is **not** in that method is any mention of `ExposureModel`, and that is the single most important thing about +this package. `firefly.management.endpoints.web.exposure.include` defaults to `health,info`, so fetching +`/actuator/beans` or `/actuator/env` over HTTP 404s — [as it should](actuator.md). +The dashboard needs none of that. It renders what the process already knows, in-process, so **it shows pages the +HTTP surface deliberately does not expose**, and the JSON surface stays secure-by-default. Exposing `beans`, +`conditions` and `env` to every anonymous caller just so a browser could read them would be exactly the wrong trade. + +The per-endpoint kill switch **is** honoured, and the asymmetry is the design: + +| Key | Means | Dashboard | +|---|---|---| +| `firefly.management.endpoint.{id}.enabled` | "this endpoint is off" — a statement about the endpoint | honoured; the page disappears from the menu | +| `firefly.management.endpoints.web.exposure.include` | "this endpoint is unpublished" — a statement about the HTTP surface | **bypassed**; the dashboard is not the HTTP surface | + +A throwing endpoint is caught and reported as `null` rather than allowed to take the page down with it — the same +fail-safe discipline `HealthEndpoint` applies to indicators, for the same reason: one broken contributor should +degrade its own panel, not the dashboard. + +### Health details are read from the contributor registry + +`firefly.management.endpoint.health.show-details` defaults to `never`, and that default is right: it stops an +anonymous HTTP caller learning your database host from a failed connection. Applying that *HTTP disclosure policy* +to the dashboard, though, produced a Health panel whose entire content was an apology telling the operator to go +and change a config key. + +The dashboard reads `HealthContributorRegistry` directly instead, calling each indicator in isolation so one that +throws is reported `DOWN` with its exception class and message and nothing else is affected — exactly what +`HealthEndpoint`'s own fail-safe read does. The JSON `/actuator/health` response is unchanged and still withholds +components until `show-details` is `always`. + +## Access: the whole security boundary + +Because the dashboard bypasses exposure, **its own URL is the only thing standing in front of `beans`, `env` and +`conditions`.** That is why it must not be on by default in production, and why the enable flag is written the way +it is: + +```php +enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)), +``` + +`firefly.admin.enabled` **defaults to the value of `app.debug`**. An application already running with debug on is +already serving stack traces to whoever asks and is a development environment by definition, so a dashboard there +discloses nothing that was not already disclosed. An application with debug off has made the opposite statement +about itself and must opt in explicitly. **Setting the key always wins over the debug default, in both directions** +— you can turn the dashboard off in a debug environment, and on in a production one. + +!!! warning "Turning it on outside debug is only half the job" + `firefly.admin.enabled = true` with `app.debug = false` mounts a dashboard that renders your bean graph, your + resolved configuration and your route table at a known URL, to anyone who can reach it. **The dashboard ships + no authentication of its own** — it has no code dependency on `firefly/security` at all, exactly as + `firefly/actuator` does not. An application that enables it outside debug **must put the route behind its own + auth middleware.** + +`firefly/security`'s `HttpSecurityFilter` is a global middleware pushed onto Laravel's HTTP-kernel stack, so it runs +for the dashboard's natively-registered routes exactly as it runs for your controllers. Locking it down is pure +configuration: + +```php +'firefly' => [ + 'admin' => [ + 'enabled' => true, // explicit: this deployment wants the dashboard with app.debug off + 'base-path' => '/firefly', + ], + 'security' => [ + 'enabled' => true, + 'http' => [ + 'enabled' => true, + 'rules' => [ + ['pattern' => 'firefly', 'access' => 'hasRole:ADMIN'], + ['pattern' => 'firefly/*', 'access' => 'hasRole:ADMIN'], + ], + ], + ], +], +``` + +Both patterns are needed: `firefly` alone does not match `firefly/env`. Any other middleware works equally well — +a VPN-only route group, basic auth, an SSO gateway — the requirement is that *something* stands in front of the +path, not that it be `firefly/security`. + +When the dashboard is disabled, `AdminRouteRegistrar` registers **nothing at all**: there is no route to guess at +and no handler to reach, and `php artisan route:list` does not list one. + +## How it is mounted + +`AdminRouteRegistrar` is a `BootPass` at `BootPhase::WiringPasses`, order **60** — one step after +`ActuatorRouteRegistrar`'s 50, because it reads the registry that pass populates. It mounts two routes: + +``` +GET {base} name: firefly.admin.index +GET|POST {base}/{page} name: firefly.admin.page where page: [A-Za-z0-9\-_/]* +``` + +They are registered natively on the illuminate `Router`, not declared with `#[GetMapping]`, for the same reason the +actuator's and [OpenAPI's](openapi.md#the-routes-are-not-attribute-routes) are: `firefly.admin.base-path` has to be +settable per application, and an attribute route bakes its literal path into a compiled `RouteDescriptor`. Leading +and trailing slashes on the configured base path are optional, and an empty one falls back to `firefly`. + +It also **backs off silently in one more case that is easy to miss.** Blade is required to render the dashboard and +is *not* a dependency of the package, so a JSON-only deployment with no `view` binding gets no routes rather than +routes that would fatal on first request; the JSON actuator remains the management surface there. + +## No build step + +The views are plain Blade with inline CSS and system fonts. There is no npm step at install time and no CDN at +request time — a Composer package cannot assume npm has run, and a dashboard that needs the network is useless in +exactly the isolated environments where you most want to look at one. (The one other browser surface LaraFly ships, +[`firefly/openapi`](openapi.md)'s console, reaches the same conclusion by a different route: it serves the official +Swagger UI from the application's own origin out of a composer package.) + +## Three things it can only tell you about *this* process + +Under PHP-FPM every request is a different process, and three pages inherit that. + +- **Changing a log level affects this process only.** The control calls the same endpoint + `POST /actuator/loggers/{name}` does, which mutates the current process's Monolog handlers. The next request is a + different process and reverts to the configured level. Change `logging.channels` for anything that must persist — + the page says so, in place, rather than letting anyone believe they have changed production logging. +- **Metrics are only as durable as your registry.** The default `SimpleMeterRegistry` keeps meters in process + memory, so the dashboard sees only its own request. Set + [`firefly.observability.metrics.store`](observability.md#configuration-fireflyobservability-kebab-case) to a cache + store to accumulate across workers. +- **HTTP traffic has the same shape, more sharply.** The in-memory exchange ring under PHP-FPM is not merely stale + but always empty, because the request rendering the page has not been recorded yet — the filter records on the way + out. `firefly.observability.httpexchanges.store` is what makes that panel non-empty. While you are there, add the + dashboard's own base path to `firefly.observability.httpexchanges.exclude`: a polling dashboard will otherwise + evict every genuine request from a 100-row ring and show you nothing but itself. The framework does not add it for + you, because reaching into another package's configuration key to guess at its mount point is the kind of hidden + coupling that breaks the day somebody changes it. + +## Configuration (`firefly.admin.*`) + +| Key | Default | Meaning | +|---|---|---| +| `firefly.admin.enabled` | **`app.debug`** | Mount the dashboard at all. An explicit value wins in both directions. | +| `firefly.admin.base-path` | `'/firefly'` | Where it is mounted. Leading and trailing slashes optional; empty falls back to `firefly`. | +| `firefly.admin.title` | `app.name` (else `'LaraFly'`) | The name shown in the sidebar and the page title. | +| `firefly.admin.refresh-seconds` | `10` | How often a live page reloads itself. **Floored at 2**: a shorter interval reloads faster than the page renders, so the countdown would never finish and the dashboard would hammer the application it is meant to be observing. | +| `firefly.admin.theme` | `'auto'` | `auto` \| `light` \| `dark`. Anything unrecognised falls back to `auto` (follow the operating system) rather than rendering unstyled. | +| `firefly.admin.graph.max-nodes` | `220` | The ceiling past which the [bean graph](bean-graph.md) lists relations instead of drawing them. Clamped to a minimum of `0`, which suppresses the diagram entirely. | +| `firefly.admin.pages.exclude` | `''` | CSV of page slugs to refuse. This is a **refusal, not a menu preference**: an excluded page is hidden *and* its URL 404s — hiding `env` from the menu achieves nothing if the URL still answers. Use `overview` for the index page. | + +## Laravel comparison + +| Concern | Plain Laravel | LaraFly (`firefly/admin`) | +|---|---|---| +| A management UI | none first-party; Telescope is a *request* debugger, Horizon a *queue* dashboard — neither reports on wiring or configuration | one dashboard over the actuator's own endpoints | +| Where it runs | Telescope/Horizon each add tables, a service provider and a middleware group | Blade views over beans that already exist; no storage of its own, nothing recorded | +| Data source | a recorder writing to the database | the live `ActuatorRegistry`, read in-process at render time | +| Enabling it safely | `TelescopeServiceProvider::gate()` — a closure you write | `firefly.admin.enabled` defaulting to `app.debug`, plus your own middleware when you override it | + +## Known-latent + +- **No instance registry.** Spring Boot Admin is a separate server that many applications register *with*, giving + one console across a fleet. This is a per-instance dashboard, which is what makes the in-process read possible; + a fleet view would need a different design and is not planned. +- **No write operations besides the log level.** `/caches` is read-only for the same reason it is read-only on the + JSON surface — `firefly/actuator` carries no code edge to `firefly/security` and so cannot say who asked. +- **`when-authorized` health details** degrade to `never` on the JSON surface (see + [Actuator](actuator.md#known-latent)); the dashboard sidesteps it entirely by reading the contributor registry. + +--- + +See also: [Actuator](actuator.md) for the endpoints themselves, [Observability](observability.md) for the metrics +and HTTP-exchange stores the dashboard renders, and [Bean Graph](bean-graph.md) for the one page that is more than +a table. diff --git a/docs/modules/bean-graph.md b/docs/modules/bean-graph.md new file mode 100644 index 0000000..f4e39dc --- /dev/null +++ b/docs/modules/bean-graph.md @@ -0,0 +1,139 @@ +# Bean Graph + +The bean graph is the one page of the [admin dashboard](admin.md) that is more than a table: a layered, drawn +diagram of how your beans depend on one another, at `/firefly/graph`. + +It answers a question `/actuator/beans` cannot. That endpoint tells you *which* beans exist; the graph tells you +what each one is **wired to**, which is what you actually want when a `#[ConditionalOnMissingBean]` did not fire the +way you expected, when a cycle has hung a boot, or when you are trying to work out what a package you just +installed attached itself to. + +``` +composer require firefly/admin # the graph is a page of the dashboard, not a package of its own +``` + +## Where the edges come from + +Nothing is reflected at request time. `ComponentScanner` records, at **scan** time, the class and interface types +each component's constructor asks for, and `ComponentDescriptor::$dependencies` carries them through the compiled +manifest exactly like every other scanned fact: + +```php +/** + * The class types this component's constructor asks for — the edges of the bean graph. + * + * Recorded at scan time, where reflection is already sanctioned, because the alternative is + * reflecting at request time to answer "what depends on what", which the reflection-free boot + * contract forbids. Only CLASS and INTERFACE types are kept: a scalar or a builtin is configuration, + * not a wiring edge, and putting it in the graph would drown the edges that matter. + */ +public array $dependencies = [], +``` + +`ActuatorRouteRegistrar` snapshots the condition-filtered registry into `BeansCatalog` at boot, and `BeanGraph` +turns that catalogue into nodes and edges. A `string $name` parameter is configuration, not wiring, and is not an +edge. A **nullable or defaulted** class parameter *is* an edge — an optional collaborator is still a relationship. + +The field is declared last with a default, so a manifest compiled before it existed still rehydrates; an app +running on an old `bootstrap/cache/firefly/` gets a graph of nodes with no edges until the next `firefly:cache`. + +## The hard part is not drawing, it is resolving + +A constructor asks for a **type**, and that type is very often an interface — `EventPublisher`, `HealthIndicator`, +`Cache` — while the bean that satisfies it is a concrete class that merely implements it. An edge list built +naively from constructor types therefore points at nodes that do not exist, and the graph comes out as a field of +disconnected dots. + +Every dependency is resolved through an interface index first, so `PostgresEventPublisher` is what `EventPublisher` +actually links to. The edge is then marked **`via`** with the interface it went through, so the reader can see the +indirection rather than being quietly shown something they did not write. The **Relations** table under the diagram +has a *Wired by* column that spells it out for every edge: the interface name, or the literal `class` when the +constructor named the concrete type. + +The index is built in catalogue order and **first implementor wins**, deterministically — the catalogue is emitted +in scan order, so the same application always draws the same graph. An interface with several implementors is a +real ambiguity that the container resolves with `#[Primary]`/`#[Qualifier]`, and the graph says so by listing the +edge as `via` rather than pretending the choice was obvious. + +## Layers, and why arrows read downward + +Level assignment is a **longest-path** walk over the resolved edges: a node's depth is one more than the deepest +thing it depends on, and the levels are then flipped so that level 0 holds the things nothing depends on. The +result is that a node always sits below everything that depends on it, arrows flow consistently downward, and the +eye can follow a chain from a controller to the repository at the bottom of it. + +Depth is memoised, and the walk carries its own visited set, so a **cycle terminates instead of recursing +forever** — and the edge that closed it is reported rather than swallowed. + +## Cycles are reported as a fact about your application + +When the walk finds one, a *Circular dependencies* panel appears above the diagram listing every closing edge, and +the **Cycles** stat turns red. + +This is worth more than it looks. The container has no cycle detection of its own, so a cycle among eager +singletons does not produce a helpful error — it exhausts memory at boot. A page that names the two classes +involved turns "the app died with no message" into a five-second diagnosis. The page's own advice is the right +one: break one of the edges, usually by injecting an interface and letting the other side depend on that. + +## The panels + +| Panel | Shows | Notes | +|---|---|---| +| Stats | Beans, Relations, Layers, Cycles, Unresolved | Cycles renders as a red chip when non-zero | +| Circular dependencies | Every closing edge, `from` → `depends on` | Only rendered when there is at least one | +| Wiring | The layered SVG diagram | Filter box highlights a bean by name | +| Relations | Every edge as `Bean` / `Depends on` / `Wired by` | Always rendered, filterable — this is the fallback when the diagram is suppressed | +| Provided outside the container | Constructor types nothing in the container provides | Chips, with the full type as a tooltip | + +Each node is a rounded box carrying the bean's short class name, its stereotype, and its in/out degree (`3↑ 1↓`); +its `` carries the fully-qualified class, scope and both degrees, so hovering identifies it exactly. Edges +are cubic Bézier curves with an arrow marker; an edge that went through an interface carries a `<title>` reading +`via <Interface>`. + +The diagram is plain inline SVG generated server-side — no JavaScript graph library, no layout engine, no network +request. It is the same "no npm step, no CDN" rule the [rest of the dashboard](admin.md#no-build-step) follows. + +## Two honest limits + +**Past 220 nodes the diagram is suppressed.** The ceiling is `firefly.admin.graph.max-nodes`, default `220`; above +it the Wiring panel says so and the Relations table below carries the same information as a filterable list. A +diagram past a couple of hundred nodes is a hairball, not something a person can read, and rendering one anyway +would be a worse answer than declining to. It is configurable because "unreadable" depends on the screen and the +application — raise it to draw a bigger graph anyway, or set it to `0` to always get the list. + +**"Provided outside the container" is not a warning.** Those are constructor types satisfied by a Laravel container +binding rather than a scanned bean — the `Request`, the config repository, a database connection, a framework +contract. They are listed rather than silently dropped precisely because *"why is my bean not in the graph"* is the +question this page has to be able to answer. A type appearing there is usually correct; a type appearing there that +you expected to be a bean of yours means your scan did not see it, and `firefly.scan.paths` is the first thing to +check. + +## Reading it against the Conditions page + +The graph and [Conditions](admin.md#the-thirteen-pages) answer complementary questions, and the pair is the fastest +way to diagnose an auto-configuration surprise: + +1. **Conditions** says *whether* a framework bean was registered or backed off, and on which condition. +2. **The graph** says what the bean that *did* win is wired to, and through which interface. + +An `EventPublisher` edge pointing at `InMemoryEventPublisher` when you configured `firefly.eda.provider=rabbitmq` +is visible in one glance on the graph, and Conditions then tells you which `#[ConditionalOnProperty]` did not match. + +## Known-latent + +- **`#[Bean]` factory-method parameters are not drawn.** `BeansCatalog` already publishes them (under `produces`, + recorded on each `BeanDescriptor`), but `BeanGraph` builds edges from constructor `dependencies` only. So a + `#[Configuration]` class appears as a node with the edges *its own constructor* declares, and the wiring its + `#[Bean]` methods perform is not yet drawn. This under-draws framework auto-configuration classes specifically, + and not application `#[Service]`/`#[Repository]` beans, which wire through constructors. +- **`#[Primary]`/`#[Qualifier]` do not steer the interface index.** First implementor in scan order wins. The edge + is marked `via` so the indirection is visible, but on an interface with several implementors the drawn target may + not be the one the container resolves. +- **No layout beyond layering.** Nodes are centred within their level in the order they came out of the sort + (`level`, then label); there is no crossing-minimisation pass, so a dense graph has crossing edges. + +--- + +See also: [Admin Dashboard](admin.md) for the page's access model, [Dependency Injection](dependency-injection.md) +for what the stereotypes and scopes on each node mean, and [Auto-Configuration](starters.md) for the conditions +that decided which beans exist at all. diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md new file mode 100644 index 0000000..b6944dd --- /dev/null +++ b/docs/modules/openapi.md @@ -0,0 +1,337 @@ +# OpenAPI + +`firefly/openapi` generates a valid **OpenAPI 3.1** document from the manifests the framework already holds in +memory. There is no annotation dialect to learn and nothing to keep in sync by hand: `RouteManifest` supplies the +paths, verbs, statuses, route names and the per-parameter binding plan; `ConstraintManifest` supplies the +request-body schemas and their `required` lists; `firefly/kernel`'s `ErrorResponse` supplies the RFC 9457 error +component. Install the package and a LaraFly app has a spec — and therefore typed clients — for free. + +Because every fact in the document is read from the same compiled artifacts the dispatcher dispatches from and the +validator validates with, **the spec cannot drift from the server**. + +```bash +composer require firefly/openapi +``` + +It is *not* part of the `firefly/firefly` metapackage — like `firefly/admin` and the broker adapters, it is an +opt-in dependency. + +## What you get + +| Surface | Default | Purpose | +|---|---|---| +| `GET /openapi.json` | on | The generated OpenAPI 3.1 document, served as `application/json` | +| `GET /openapi` | on | A browser API console — Swagger UI by default, from your own origin | +| `GET /openapi/assets/{file}` | on | The Swagger UI distribution files, served from this application | +| `php artisan firefly:openapi` | — | Writes the document to a file (`--output=`) or raw to stdout | + +The media type on the spec route is `application/json`, deliberately not the more precise +`application/openapi+json;version=3.1`: that type is registered but poorly supported, and several of the generator +toolchains this package exists to feed refuse a document whose `Content-Type` they do not recognise. The document +says `"openapi": "3.1.0"` in its first member, which is how every consumer actually detects the version. + +## The routes are not attribute routes + +All three are mounted natively on the illuminate `Router` from `OpenApiRouteRegistrar`, a `BootPass` at +`WiringPasses` order **60** — the `ActuatorRouteRegistrar` idiom, chosen for two independent reasons. + +First, **an attribute route cannot be configurable.** `#[GetMapping('/openapi.json')]` bakes its literal into a +compiled `RouteDescriptor` at `firefly:cache` time, so an operator could never move the spec off a path that +collides with one of their own, and could never take it off a public surface without deleting the package. + +Second, an attribute route would enter the application's `RouteManifest` — and the generator reads that manifest, +so **the package would document itself.** + +`firefly.openapi.enabled` (default `true`) is enforced *there*, on the routes, rather than on the beans: the +generator and its collaborators are inert without routes, so gating the routes is the whole of the switch. Turning +it off leaves the paths genuinely **unrouted**, so they 404 through the router's own `NotFoundHttpException`, which +`ProblemDetailsRenderer` renders as a proper `404` problem-details body rather than a 500. + +The actions are resolved *inside* each route closure (`$container->make(...)`), never captured at boot — capturing +would freeze one `OpenApiGenerator` into the route for the process's lifetime, which is exactly the shape that +breaks under Octane when a later request's container is a different sandbox. + +## What the generator maps + +**Operations** come from each `RouteDescriptor`: the verb and path (Laravel's optional `{id?}` is normalised to +`{id}`, since a path parameter is *required* in OpenAPI), the `#[Mapping]`'s declared status, and the route name as +the `operationId` when one is set — otherwise a derived `lcfirst(<ControllerShortName minus "Controller">) + +ucfirst(<method>)`. A repeat claim is suffixed (`_2`, `_3`) rather than allowed to overwrite, because a duplicate +`operationId` is the one flaw that makes most client generators abort rather than degrade. Operations are tagged by +controller short name. + +**Parameters** come from the binding plan — the same `kind` discriminator `ArgumentResolver` dispatches on at +request time. `#[PathVariable]`, `#[QueryParam]` and `#[RequestHeader]` become Parameter Objects; `#[UploadedFile]` +becomes a `multipart/form-data` part; a container-injected service is not part of the HTTP contract and never +appears. + +**Request bodies** come from the `#[RequestBody]` DTO, as a `$ref` into `components/schemas` — one component per +DTO, reused everywhere, with nested `#[Valid]` DTOs given their own component rather than being inlined, so a +self-referential DTO terminates as a `$ref` cycle instead of recursing forever. + +**Property schemas** merge the DTO's declared constructor types with its compiled constraints, because neither +alone is enough: types-only documents `#[NotBlank] string $name` as an unbounded string, constraints-only documents +`int $quantity` as a string. Backed enums, `DateTimeInterface` and nullability come from the type; the keywords come +from the manifest. + +| Constraint | JSON Schema | +|---|---| +| `#[NotNull]`, `#[NotBlank]`, `#[NotEmpty]` | member added to the parent's `required` | +| `#[NotBlank]` | `type: string` + `pattern: \S` | +| `#[Size(min, max)]` | `minLength`/`maxLength`, or `minItems`/`maxItems` on an array | +| `#[Min]` / `#[Max]` | `minimum` / `maximum` | +| `#[Positive]`, `#[Negative]`, `…OrZero` | `exclusiveMinimum` / `minimum` / … | +| `#[Email]` | `format: email` | +| `#[Pattern]` | `pattern` (PCRE delimiters and no-op flags stripped) | +| `#[UuidValue]`, `#[Phone]`, `#[Iban]`, `#[Bic]`, `#[Isin]`, … | `format` + a `pattern` where the rule matches the raw value | +| `#[Percentage]` | `type: number`, `minimum: 0`, `maximum: 100` | +| `#[DecimalScale(n)]`, `#[Money]` | `multipleOf` | +| `#[AssertTrue]` / `#[AssertFalse]` | `type: boolean` + `const` | + +A nullable member is spelled the 3.1 way — a `type` union including `"null"`, not 3.0's `nullable` keyword. + +**Nothing is dropped silently.** Constraints JSON Schema cannot express (`#[Future]`'s "after now", a Luhn +checksum, a third-party `ValidationRule`) and ones it can only approximate (a PCRE pattern carrying flags ECMA-262 +has no syntax for) are recorded under the `x-firefly-constraints` specification extension. Conforming tools ignore +an `x-` member; a human or a custom generator can read it. + +**Responses.** The success entry is keyed by the `#[Mapping]`'s declared status, and its body schema comes from the +controller method's declared **return type** — the only place the shape of a successful response is stated anywhere +in the framework, since `RouteDescriptor` records the status but not the payload. A `204` (or a `void`/`never` +return) gets no `content` at all, because emitting a content map for a status that carries no body is exactly what a +strict client generator turns into a phantom return type. A plain `array`/`iterable` return degrades to +`type: object` rather than being expanded from a `@return array{…}` docblock: parsing PHPDoc here would make the +generated document depend on comment text nothing else in the framework treats as binding. + +Beside it, every operation carries the shared `#/components/responses/Problem` as its `default`, plus a `400` when +`ArgumentResolver` has something it can reject before the controller runs (a required binding, or one whose value +must be *converted* out of the string the wire always carries — a `string` parameter cannot fail a conversion, an +`int`/`float`/`bool`/enum can), and a `422` when a binding carries `#[Valid]`. `ProblemSchema` describes what +LaraFly *actually* returns — RFC 9457's members **plus** Firefly's `code`, `category`, `severity` and `errors` — +with the `category` and `severity` enumerations read straight off +`ErrorCategory::cases()`/`ErrorSeverity::cases()`, so a new kernel case appears in the spec on the next generation +with no edit in this package. + +**`#[Controller]` HTML routes are excluded by default.** They are part of the HTTP surface but not JSON API +operations, and describing one as `application/json` hands a generator a typed client for a response that is a web +page. `firefly.openapi.include-html` documents them anyway, as `text/html`. + +## Determinism, and the `{}`-vs-`[]` trap + +Paths are sorted, verbs within a Path Item are sorted into the canonical OpenAPI order, and `SchemaRegistry` sorts +components by name. Route discovery order depends on filesystem iteration, so an unsorted document would reshuffle +itself between machines and turn every regeneration into an unreviewable diff — which is what makes teams stop +committing the generated file, which is what makes it go stale. + +`generate()` returns plain PHP arrays (pleasant to assert against); `toJson()` is the canonical serialisation and +the one that must produce any file or HTTP body. PHP cannot tell an empty map from an empty list, so +`json_encode([])` is `[]` — and `"paths": []` or an unconstrained property serialised as `[]` are both type errors +against the 3.1 meta-schema that make a strict validator reject an otherwise perfect document. `toJson()` +re-encodes every empty array as `{}`, which is unconditionally safe here because nothing in this document ever +emits an empty *list*: `required`, `tags`, `parameters`, `servers`, `allOf` and the constraint extension are each +omitted entirely rather than emitted empty. + +## `php artisan firefly:openapi` + +```bash +php artisan firefly:openapi --output=docs/openapi.json # writes the file, prints a summary line +php artisan firefly:openapi > openapi.json # writes the raw document to stdout +``` + +The command exists so the document can be a **build artifact** rather than only a live endpoint. Committing the +generated file is what lets a CI job diff it and fail a pull request that changed the public API without saying so, +and what lets a front-end repository regenerate its typed client from a checked-in spec without booting the PHP +application at all. It is also the only way to get a document out of a deployment that keeps +`firefly.openapi.enabled` off in production. + +Stdout is written with Symfony's `OUTPUT_RAW`, and that detail is load-bearing: console output normally goes +through Symfony's formatter, which treats `<…>` as markup, so any angle bracket reaching the document from a +docblock or a config value would either be swallowed or throw on an unknown tag. The point of stdout mode is +`firefly:openapi | <generator>`, so the bytes must be exactly the bytes of the document. It is also why the +confirmation line prints **only** in `--output` mode, where stdout is not the document. + +Parent directories of `--output=` are created; a failure to create or write reports an error and returns a non-zero +exit code. + +## The viewer, and the three styles + +`GET /openapi` renders a browser console. `firefly.openapi.viewer.style` selects which one, and only one of the +three makes a request to a third party. + +| `style` | Ships from | Third-party request at page view? | Notes | +|---|---|---|---| +| `swagger` **(default)** | your own origin, out of the `swagger-api/swagger-ui` composer package | **no** | The official Swagger UI, byte-for-byte | +| `builtin` | inline in the response | **no** | Hand-written, no third-party JavaScript at all | +| `cdn` | `cdn.jsdelivr.net` | **yes, on every view** | Swagger UI at a pinned version; no SRI claimed | + +An unrecognised value falls back to `swagger` rather than rendering a blank page. + +### Why `swagger` from your own origin is the default + +Every off-the-shelf viewer — Swagger UI, Redoc, Elements — is a bundled JavaScript application, which historically +left a PHP package two options: vendor a multi-megabyte bundle into its own git history, or fetch it from a CDN on +every page view. The second is a supply-chain dependency and a data-protection question, and it renders **nothing +at all** in the air-gapped and strict-CSP environments where an internal API console is most wanted. + +`swagger-api/swagger-ui` publishes the `dist` on Packagist under Apache-2.0, so there is a third option and this +package takes it: composer fetches and pins the official distribution, and `SwaggerAssetAction` serves it from the +application's own origin. No CDN, no npm, no bundle in this repository's history, and the UI is exactly the one +Swagger publishes — full feature set, deep linking, try-it-out, OAuth2 redirect. + +`swagger-api/swagger-ui` is a hard `require` of `firefly/openapi`, so the files are already on disk. If they are +somehow absent — a stripped `vendor/`, a phar, a non-composer runtime — `ViewerPage` falls back to `builtin` rather +than rendering a console whose assets 404. + +Asset serving is a **whitelist**, not a sanitiser: only seven basenames are servable +(`swagger-ui.css`, `swagger-ui-bundle.js`, `swagger-ui-standalone-preset.js`, `oauth2-redirect.html`, +`favicon-16x16.png`, `favicon-32x32.png`, `index.css`), each resolved path is `realpath()`-checked to be inside the +dist directory, and the route itself constrains `{file}` to `[A-Za-z0-9._-]+` so it cannot even express a +traversal. A whitelist cannot be defeated by an encoding trick a sanitiser missed. Anything else is a plain 404 +(`text/plain`, deliberately not problem+json — the caller is a browser fetching a stylesheet, not an API client). +Assets are immutable for a pinned version, so they are sent `public, max-age=31536000, immutable` with an auto +ETag; composer changes the bytes only when the pinned version changes. + +### What `cdn` costs + +```php +'openapi' => ['viewer' => ['style' => 'cdn']], +``` + +Every page view then loads Swagger UI from `cdn.jsdelivr.net`. The version is pinned exactly; **no Subresource +Integrity hash is claimed**, deliberately — a hash the framework cannot verify at release time is security theatre, +and a wrong one simply breaks the page. In exchange for a third-party request, a CSP that must allow `cdn.jsdelivr.net`, +and a console that renders nothing in an air-gapped deployment, you get… the same Swagger UI `swagger` already gave +you from your own origin. The style is kept for parity with what most tutorials show, and because some deployments +prefer their bytes to come from a cache they already trust. + +`firefly.openapi.viewer.cdn` is the older boolean spelling of this. It still **forces** the CDN page and wins over +`style`, so an application that set it before `style` existed keeps the behaviour it configured; prefer `style` in +new configuration. + +### What `builtin` is for + +A hand-written, dependency-free reference: one inline `<script>`, a few hundred bytes of CSS, one `fetch` of the +spec route, and a dark/light palette that follows `prefers-color-scheme`. It does the two things a reader actually +needs and raw JSON does not give them — groups operations by tag with verbs and paths visible at a glance, and +resolves `$ref` pointers client-side so a reader sees a DTO's members rather than a pointer into +`#/components/schemas`. Try-it-out, OAuth flows and code samples are deliberately absent; that is what `swagger` +is for. Choose it when the deployment wants no third-party JavaScript in the response at all. + +The viewer fetches the spec from the sibling route rather than having the document inlined, so an edit-and-reload +cycle shows up on a browser refresh, and so the two routes can be exposed independently — a deployment may want the +machine-readable document public and the console off, or the reverse. The spec URL is resolved through the +`UrlGenerator` rather than concatenated, because an app mounted under a subdirectory or behind `APP_URL` would +otherwise get a link that 404s from every page but the root. + +## Configuration (`firefly.openapi.*`) + +| Key | Default | Meaning | +|---|---|---| +| `firefly.openapi.enabled` | `true` | Master gate. Off means all three routes are genuinely **unrouted**, not blank. | +| `firefly.openapi.path` | `'/openapi.json'` | The spec route. Registered with the leading slash stripped, because Illuminate's `Router` does that itself. | +| `firefly.openapi.viewer.enabled` | `true` | Mount the console and its assets. The spec route stays mounted either way. | +| `firefly.openapi.viewer.path` | `'/openapi'` | The console route; assets are mounted under `{path}/assets/{file}`. | +| `firefly.openapi.viewer.style` | `'swagger'` | `swagger` \| `builtin` \| `cdn`. Unrecognised values fall back to `swagger`. | +| `firefly.openapi.viewer.cdn` | `false` | Legacy boolean. `true` forces the CDN page and **overrides `style`**. | +| `firefly.openapi.title` | `'API'` | Info Object `title`. | +| `firefly.openapi.version` | `'0.0.0'` | Info Object `version`. | +| `firefly.openapi.description` | `''` | Info Object `description`; omitted from the document when empty. | +| `firefly.openapi.servers` | `[]` | Bare URL strings and/or OpenAPI Server Objects. An entry that is neither — or an object with no `url` — is **dropped**, because it would be invalid under the 3.1 schema and would poison an otherwise-good document. Omitted from the document when empty. | +| `firefly.openapi.exclude` | `''` | CSV of path **prefixes** left out of the document. Removes them from the spec only; it does not unroute them. | +| `firefly.openapi.include-html` | `false` | Document `#[Controller]` HTML routes as `text/html` operations. | + +`OpenApiProperties` is read **once**, at `BootPhase::FlushDefinitions`, into an immutable value object — the same +lifetime `ExposureModel` has in `firefly/actuator`, and for the same reason: the registrar mounts routes from +`specPath`/`viewerPath` at `WiringPasses`, so a post-boot `config()->set()` on those keys could not move an +already-mounted route anyway. + +## Securing the surface + +The three routes are ordinary routes, and `firefly/security`'s `HttpSecurityFilter` is a **global** middleware +pushed onto Laravel's HTTP-kernel stack, so it runs for them exactly as it runs for your controllers. Locking the +documentation down is therefore pure configuration, with no code edge — the same story as +[Actuator](actuator.md): + +```php +'firefly' => [ + 'security' => [ + 'enabled' => true, + 'http' => [ + 'enabled' => true, + 'rules' => [ + ['pattern' => 'openapi', 'access' => 'hasRole:DEVELOPER'], + ['pattern' => 'openapi/*', 'access' => 'hasRole:DEVELOPER'], + ['pattern' => 'openapi.json', 'access' => 'hasRole:DEVELOPER'], + ], + ], + ], +], +``` + +Note the three patterns: `openapi` alone does not match `openapi/assets/swagger-ui.css`, and `openapi.json` is a +separate literal. A rule that covers the console but not its assets produces an authenticated page whose stylesheet +401s. + +The alternative, for a deployment that wants no documentation surface in production at all, is +`firefly.openapi.enabled => false` plus a `firefly:openapi --output=` step in CI. + +## Overriding a piece of the pipeline + +Every collaborator is a `#[Bean]` behind `#[ConditionalOnMissingBean]`, so replacing one is a short +`#[Configuration]` in the application and never a fork: + +```php +#[Configuration] +final class ApiDocsConfiguration +{ + #[Bean] + public function constraintSchemaMapper(): ConstraintSchemaMapper + { + return new HouseConstraintSchemaMapper; // teaches the generator your own ValidationRules + } +} +``` + +`OpenApiProperties`, `ConstraintSchemaMapper`, `DtoSchemaFactory`, `OperationFactory`, `OpenApiGenerator` and +`ViewerPage` are all overridable this way. The pipeline is six beans rather than one god object precisely because +swapping the *whole* generator is rarely what anyone wants, whereas replacing just the constraint mapper (to teach +it a house `ValidationRule`) or just `ViewerPage` (to ship a corporate console) is exactly what they want. + +## A note on reflection + +LaraFly's rule is that nothing on the cached **request** path reflects. This package honours it. `DtoSchemaFactory` +reflects a DTO's constructor to learn its property types, but that work runs when `firefly:openapi` generates a +file, or on a hit to the spec route — whose result the generator **memoises for the life of the process** — and +never while dispatching an application request. It is the same category of work as `RouteScanner` and +`ConstraintScanner`, both of which reflect at compile time only. + +Teaching `RouteScanner` to emit per-property types into every `RouteDescriptor` was rejected: it would grow the +compiled route manifest of *every* application for the benefit of one optional package. + +## Laravel comparison + +| Concern | Plain Laravel | LaraFly (`firefly/openapi`) | +|---|---|---| +| Where the spec comes from | a second description — `zircote/swagger-php`'s `@OA\` blocks, attribute classes, or a hand-kept YAML file | the same `RouteManifest` the dispatcher dispatches from and the same `ConstraintManifest` the validator validates with | +| Drift | invisible: the document still validates, it just no longer matches the server | structurally impossible — there is no second source | +| Request-body schemas | re-declared beside the FormRequest that enforces them | derived from the compiled constraints | +| Error responses | documented by hand, if at all | one shared `Problem` component describing what `ProblemDetailsRenderer` actually returns | +| A browser console | a third-party package, usually CDN-backed | official Swagger UI from your own origin, no npm, no CDN | +| The nearest analogue | `php artisan route:list` — accurate for the same reason, and unable to say anything about a body | springdoc-openapi, outside PHP | + +## Known-latent + +- **A success body typed `array` documents as `type: object`.** The success schema comes from the declared return + type, and LaraFly controllers commonly return `array`. Return a DTO (or a backed scalar) where the response + shape matters to a generated client; a `@return array{…}` docblock is deliberately not read. +- **`x-firefly-constraints` is the escape hatch, not a vocabulary.** Anything JSON Schema cannot state lands there + verbatim; no attempt is made to translate a checksum rule or a temporal predicate into an approximation that + would be wrong. +- **`webhooks`, `security` schemes and `callbacks`** are not emitted — `firefly/security`'s configuration is not + reachable from this package without a code edge that `deptrac.yaml` deliberately does not permit. + +--- + +See also: [Web Layer](web.md) for `RouteManifest` and the binding plan, [Validation](validation.md) for +`ConstraintManifest`, [Error Handling](error-handling.md) for the problem-details shape, and +[Admin Dashboard](admin.md) for the other browser surface LaraFly ships. diff --git a/docs/publishing.md b/docs/publishing.md index 8c69bfa..20d670b 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,7 +1,7 @@ # Publishing -This page is the release/split runbook for the LaraFly monorepo: how the 25 packages under `packages/*` -(including `firefly/installer`), plus `firefly/skeleton` at the top level — 26 shippable units in total — end +This page is the release/split runbook for the LaraFly monorepo: how the 27 packages under `packages/*` +(including `firefly/installer`), plus `firefly/skeleton` at the top level — 28 shippable units in total — end up as individually-installable Packagist packages, and the exact, gated sequence for the first manual publish. ## Model @@ -13,14 +13,14 @@ once, at the same version, even for packages that had no code change that cycle. per-package versioning; see [Versioning](versioning.md) for why. The mirrors are read-only by design: nobody commits directly to `fireflyframework/firefly-kernel` — every -change flows through the monorepo and gets split out mechanically. This keeps the 26 mirror repos from ever +change flows through the monorepo and gets split out mechanically. This keeps the 28 mirror repos from ever drifting out of sync with each other or with the monorepo history. ## Automated split Once wired (tracked separately from this docs task), `.github/workflows/release.yml` fires on a pushed `v*` tag and runs [`symplify/monorepo-split-github-action`](https://github.com/symplify/monorepo-split-github-action) -once per shippable unit (all 25 `packages/*` + `skeleton`), pushing each subtree to its own +once per shippable unit (all 27 `packages/*` + `skeleton`), pushing each subtree to its own `fireflyframework/firefly-<pkg>` mirror repository at that tag. The workflow needs an `ACCESS_TOKEN` — an organization-level GitHub Personal Access Token with `repo` scope on every mirror — stored as a repository (or organization) secret, since the default `GITHUB_TOKEN` can't push to a *different* repository. @@ -70,7 +70,7 @@ history, create public mirror repositories, and register public Packagist packag 5. **`git remote add origin git@github.com:fireflyframework/fireflyframework-php.git` then `git push origin main --tags`** — **irreversible**: this publishes the monorepo's history and the release tag publicly for the first time. -6. **Create the 26 mirror repositories under the `fireflyframework` org, then run the split at the tag** — +6. **Create the 28 mirror repositories under the `fireflyframework` org, then run the split at the tag** — **irreversible**: each `fireflyframework/firefly-<pkg>` mirror now exists publicly, carrying `^26.07` sibling constraints. 7. **Staged Packagist registration** — register only a first wave, then verify, before committing the rest: @@ -84,7 +84,7 @@ history, create public mirror repositories, and register public Packagist packag **Confirm this resolves and installs cleanly** before doing anything else. Only if it succeeds, register every remaining package plus `firefly/firefly` (the runtime metapackage) and `firefly/installer`. If it fails, **stop** — the interdependency-constraint strategy needs fixing, and only four packages are affected - (versus discovering the same problem after all 26+ are already permanently registered on Packagist). + (versus discovering the same problem after all 28 are already permanently registered on Packagist). 8. After publishing, restore the monorepo dev tree to `*@dev` — revert the `bump-interdependency` commit (or bump the constraints back by hand) — so local development on `main` continues exactly as before this runbook started. diff --git a/packages/actuator/README.md b/packages/actuator/README.md index 6d4608a..67fd8bb 100644 --- a/packages/actuator/README.md +++ b/packages/actuator/README.md @@ -12,4 +12,117 @@ endpoint 404s rather than leaking data. See [Actuator](../../docs/modules/actuator.md) for the full endpoint reference. +## Separate management port + +Spring Boot's `management.server.port` is supported, with one honest caveat spelled out below. + +```php +// config/firefly.php +'management' => [ + 'server' => [ + 'port' => env('FIREFLY_MANAGEMENT_PORT'), // unset = same port as the application (the default) + 'address' => env('FIREFLY_MANAGEMENT_ADDRESS'), // bind address for the management listener + 'base-path' => '', // optional prefix: '/manage' -> /manage/actuator/health + ], +], +'server' => [ + 'port' => env('FIREFLY_SERVER_PORT'), // optional: declare the application's own port (see "Validation") +], +``` + +With `firefly.management.server.port` set, the actuator answers on that port and **404s everywhere else** — the +index and every endpoint, with the same RFC-9457 problem+json body an unexposed endpoint gets, so a scan of the +public port cannot tell the two apart. + +### What PHP can and cannot do + +A PHP-FPM worker, an `artisan serve` process and an Octane worker are each handed one already-accepted connection +by a listener they do not own. **There is no point at which framework code could bind a second socket**, so this +package does not pretend to. It provides the enforcement half and leaves the listener to the deployment: + +| Half | Who provides it | +| --- | --- | +| A second socket listening on the management port | your deployment (below), or `firefly:management:serve` in dev | +| Refusing the actuator on any other port | `ManagementPortGuard`, in every request | + +The guard compares `SERVER_PORT` — written by the SAPI from the socket that accepted the connection — to the +configured port. It deliberately does **not** use the `Host` header (the client writes that; a guard built on it is +walked past with `curl -H 'Host: localhost:9001'`). `X-Forwarded-Port` is honoured only when the request comes from +a trusted proxy **and** the application trusts that header (`TrustProxies`' `$headers` must include +`Request::HEADER_X_FORWARDED_PORT`, as Laravel's default does), for the deployment where one proxy terminates both +ports onto the same upstream. + +`firefly.management.server.address` is a **bind** address, consumed by `firefly:management:serve` and copied into +your pool's `listen`. It is not a request-time check: a bind address is invisible to an HTTP request, and the +kernel has already enforced it by the time PHP runs. + +### Deployment shapes + +**Two PHP-FPM pools** — the management pool listens on its own socket; nginx routes `/actuator` to it and nothing +else: + +```ini +; /etc/php-fpm.d/app.conf +[app] +listen = 127.0.0.1:9000 + +; /etc/php-fpm.d/management.conf — same code, same image, its own socket +[management] +listen = 127.0.0.1:9001 +``` + +```nginx +server { # public + listen 443 ssl; + location / { fastcgi_pass 127.0.0.1:9000; include fastcgi_params; } +} +server { # private network only + listen 10.0.0.4:9001; + location / { fastcgi_pass 127.0.0.1:9001; include fastcgi_params; } +} +``` + +**Two containers** — the same image twice, the management one with `FIREFLY_MANAGEMENT_PORT` matching its exposed +port and no route from the public ingress. + +**One proxy, one pool** — both server blocks forward to the same upstream, and the management block sets +`proxy_set_header X-Forwarded-Port 9001;`. Requires the proxy's address in the application's trusted proxies, and +`HEADER_X_FORWARDED_PORT` in the trusted header set — the proxy must also overwrite any client-supplied +`X-Forwarded-Port`, which the `proxy_set_header` above does. + +### Development + +``` +php artisan firefly:management:serve # a second `artisan serve` on the configured address/port +``` + +Run it alongside `firefly:serve`. It reports the actuator URL and delegates; `--host`/`--port` override the config. + +**It does not keep application routes off the management port.** One `artisan serve` is one Laravel application and +every route it has answers on the port it was given. The guarantee is one-directional — the actuator is unreachable +on the application port — and restricting the other direction is the listener's job (a pool only the management +server block talks to, a container the public ingress cannot reach). + +### Validation + +A management port equal to the application port is a **boot failure**, not a silent no-op: the guard would permit +every request, leaving a config file that reads as isolated and is not. This diverges from Spring, where the two +being equal legitimately means "serve management on the main server". + +The application port is taken from `firefly.server.port` if declared, else from an explicit port in `app.url`. When +neither exists the check stands aside — PHP is not told which socket its pool listens on, and a guessed port would +abort correctly-configured boots. Declare `firefly.server.port` if you want the mistake caught. + +### The seam for other management surfaces + +The boundary this package enforces covers the actuator's own routes and nothing else. Any other package that mounts +a management surface over HTTP — `firefly/admin`'s dashboard at `/firefly`, for instance — is a **separate route on +the same Router and is not guarded until it opts in**: as of this release the dashboard does not consult the guard, +so on a deployment with a management port it still answers on the application port. + +Opting in is two lines. `Firefly\Actuator\Server\ManagementPortGuard` is a bound singleton (bound whether or not +`firefly.management.enabled` mounted any actuator route), `permits(Request $request): bool` is the predicate, and +the caller renders its own 404 — the actuator renders problem+json, an HTML dashboard should not. +`ManagementServerSettings::mountPath()` gives the actuator's effective path. + Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/actuator/cache/firefly-actuator-components.php b/packages/actuator/cache/firefly-actuator-components.php index 69e08ea..34f7d98 100644 --- a/packages/actuator/cache/firefly-actuator-components.php +++ b/packages/actuator/cache/firefly-actuator-components.php @@ -28,6 +28,30 @@ 0 => 'Firefly\\Config\\Config', ], ], + 1 => [ + 'method' => 'managementServerSettings', + 'returns' => 'Firefly\\Actuator\\Server\\ManagementServerSettings', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], + ], + 2 => [ + 'method' => 'managementPortGuard', + 'returns' => 'Firefly\\Actuator\\Server\\ManagementPortGuard', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Actuator\\Server\\ManagementServerSettings', + ], + ], ], 'lazy' => false, 'dependencies' => [ diff --git a/packages/actuator/cache/firefly-actuator-context.php b/packages/actuator/cache/firefly-actuator-context.php index a5f9406..9e0621e 100644 --- a/packages/actuator/cache/firefly-actuator-context.php +++ b/packages/actuator/cache/firefly-actuator-context.php @@ -27,6 +27,28 @@ ], ], ], + 1 => [ + 'method' => 'managementServerSettings', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\Actuator\\Server\\ManagementServerSettings', + ], + ], + ], + ], + 2 => [ + 'method' => 'managementPortGuard', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\Actuator\\Server\\ManagementPortGuard', + ], + ], + ], + ], ], ], 1 => [ diff --git a/packages/actuator/composer.json b/packages/actuator/composer.json index f5face2..7ed00af 100644 --- a/packages/actuator/composer.json +++ b/packages/actuator/composer.json @@ -21,6 +21,7 @@ "firefly/kernel": "*@dev", "firefly/scheduling": "*@dev", "firefly/web": "*@dev", + "illuminate/console": "^13.0", "illuminate/container": "^13.0", "illuminate/contracts": "^13.0", "illuminate/database": "^13.0", diff --git a/packages/actuator/src/ActuatorAutoConfiguration.php b/packages/actuator/src/ActuatorAutoConfiguration.php index 263393b..52b9b22 100644 --- a/packages/actuator/src/ActuatorAutoConfiguration.php +++ b/packages/actuator/src/ActuatorAutoConfiguration.php @@ -5,6 +5,8 @@ namespace Firefly\Actuator; use Firefly\Actuator\Endpoint\ExposureModel; +use Firefly\Actuator\Server\ManagementPortGuard; +use Firefly\Actuator\Server\ManagementServerSettings; use Firefly\Config\Config; use Firefly\Container\Attributes\Bean; use Firefly\Container\Attributes\Configuration; @@ -15,8 +17,16 @@ * Actuator's config-derived bean source. The master gate firefly.management.enabled (default true) is enforced in * ActuatorRouteRegistrar (it registers no routes when off), NOT here — the beans are harmless without routes. The * framework infrastructure collectors (ActuatorRegistry, Health/InfoContributorRegistry, StatusAggregator) are - * bound imperatively in ActuatorWiringProvider (the WebServiceProvider idiom), so this class owns only the one - * config-derived value bean. #[ConditionalOnMissingBean] lets an app override it. Mirrors SecurityAutoConfiguration. + * bound imperatively in ActuatorWiringProvider (the WebServiceProvider idiom), so this class owns only the + * config-derived value beans. #[ConditionalOnMissingBean] lets an app override each. Mirrors + * SecurityAutoConfiguration. + * + * ManagementServerSettings and ManagementPortGuard live here, not in ActuatorWiringProvider, for the reason + * ExposureModel does: they are derived from Config, so they must be resolved at BootPhase::FlushDefinitions (650), + * strictly before ActuatorRouteRegistrar (WiringPasses, 1000) reads the mount path off them. They are bound + * UNCONDITIONALLY — deliberately NOT behind firefly.management.enabled. The master gate only stops routes being + * mounted; the beans themselves are inert without routes, and firefly/admin resolves ManagementPortGuard to guard + * its own dashboard whether or not the JSON actuator mounted anything. */ #[Configuration] #[Order(1000)] @@ -28,4 +38,18 @@ public function exposureModel(Config $config): ExposureModel { return ExposureModel::fromConfig($config); } + + #[Bean] + #[ConditionalOnMissingBean(ManagementServerSettings::class)] + public function managementServerSettings(Config $config): ManagementServerSettings + { + return ManagementServerSettings::fromConfig($config); + } + + #[Bean] + #[ConditionalOnMissingBean(ManagementPortGuard::class)] + public function managementPortGuard(ManagementServerSettings $settings): ManagementPortGuard + { + return new ManagementPortGuard($settings); + } } diff --git a/packages/actuator/src/ActuatorWiringProvider.php b/packages/actuator/src/ActuatorWiringProvider.php index eaa8f92..491bf55 100644 --- a/packages/actuator/src/ActuatorWiringProvider.php +++ b/packages/actuator/src/ActuatorWiringProvider.php @@ -7,6 +7,7 @@ use Firefly\Actuator\Boot\ActuatorRouteRegistrar; use Firefly\Actuator\Boot\HealthContributorRegistrar; use Firefly\Actuator\Boot\InfoContributorRegistrar; +use Firefly\Actuator\Command\ManagementServeCommand; use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Actuator\Health\HealthContributorRegistry; use Firefly\Actuator\Health\StatusAggregator; @@ -33,6 +34,12 @@ * a bare-skeleton boot. Keeping a second bound()-guarded default here would be redundant dead weight, not a safety * net: ContainerRegistrar::register() always calls Container::singleton() unconditionally for a surviving #[Bean], * which unconditionally rebinds (and clears any cached instance for) whatever this provider bound earlier anyway. + * ManagementServerSettings and ManagementPortGuard are owned by that same #[Configuration], for the same reason. + * + * firefly:management:serve is registered from boot(), not passes(): an Artisan command has no place in the boot + * pipeline, and commands() is a no-op outside a console process anyway. This is the OpenApiWiringProvider idiom, + * applied locally so firefly/actuator needs no dependency on firefly/cli — which is require-dev in a real + * application, i.e. absent from exactly the production image where a management port is worth configuring. */ final class ActuatorWiringProvider extends FireflyServiceProvider { @@ -64,4 +71,11 @@ public function passes(): array { return [new HealthContributorRegistrar, new InfoContributorRegistrar, new ActuatorRouteRegistrar]; } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ManagementServeCommand::class]); + } + } } diff --git a/packages/actuator/src/Boot/ActuatorRouteRegistrar.php b/packages/actuator/src/Boot/ActuatorRouteRegistrar.php index 645dea7..26fc731 100644 --- a/packages/actuator/src/Boot/ActuatorRouteRegistrar.php +++ b/packages/actuator/src/Boot/ActuatorRouteRegistrar.php @@ -8,6 +8,7 @@ use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Actuator\Endpoint\ExposureModel; use Firefly\Actuator\Introspection\BeansCatalog; +use Firefly\Actuator\Server\ManagementServerSettings; use Firefly\Actuator\Web\ActuatorDispatchAction; use Firefly\Actuator\Web\ActuatorIndexAction; use Firefly\Context\Boot\BootContext; @@ -24,6 +25,21 @@ * BeansEndpoint/ConditionsEndpoint constructor can inject them), then resolves each discovered ActuatorEndpoint * bean once to populate ActuatorRegistry. The master gate firefly.management.enabled (default true) short-circuits * to registering nothing. + * + * MOUNT PATH: the two routes go under ManagementServerSettings::mountPath(), which is + * `firefly.management.server.base-path` (usually empty) followed by the ExposureModel's own + * `firefly.management.endpoints.web.base-path`. The two settings COMPOSE — this pass does not re-derive either — so + * an application that never set a management base path mounts exactly the paths it always did. + * + * MANAGEMENT PORT: nothing about the port can be decided HERE. The routes are mounted on the one Router this + * process owns, and this process serves whichever port its listener was given; mounting conditionally would mean + * the SAME deployed code registered different routes depending on which pool happened to boot it, and the actuator + * would silently vanish if the guess was wrong. So the routes are always mounted and ManagementPortGuard refuses + * them per request instead — see ManagementServerSettings for the full argument about what PHP can and cannot do + * with a second port. What this pass DOES own is the fail-fast: a management port equal to the application port + * would leave the guard permitting everything, which reads as isolation and is not, so assertDistinctFrom() aborts + * the boot. It runs AFTER the master gate on purpose — an application that has switched the actuator off entirely + * has no management surface to isolate, and should not be blocked from booting over the configuration of one. */ final class ActuatorRouteRegistrar implements BootPass { @@ -45,6 +61,11 @@ public function run(BootContext $context): void $container = $context->container; + // (0) fail fast on a management port that cannot possibly isolate anything (see the class docblock). + /** @var ManagementServerSettings $management */ + $management = $container->make(ManagementServerSettings::class); + $management->assertDistinctFrom(ManagementServerSettings::applicationPort($context->config)); + // (1) request-time introspection snapshots — bound FIRST so endpoint constructors can inject them. $container->instance(ConditionEvaluationReport::class, $context->report); $container->instance(BeansCatalog::class, $this->beansCatalog($context)); @@ -65,7 +86,7 @@ public function run(BootContext $context): void $exposure = $container->make(ExposureModel::class); /** @var Router $router */ $router = $container->make('router'); - $base = $exposure->basePath; + $base = $management->mountPath($exposure); $router->get($base, fn (Request $request) => $container->make(ActuatorIndexAction::class)($request)) ->name('firefly.actuator.index'); diff --git a/packages/actuator/src/Command/ManagementServeCommand.php b/packages/actuator/src/Command/ManagementServeCommand.php new file mode 100644 index 0000000..2177519 --- /dev/null +++ b/packages/actuator/src/Command/ManagementServeCommand.php @@ -0,0 +1,129 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Command; + +use Firefly\Actuator\Endpoint\ExposureModel; +use Firefly\Actuator\Server\ManagementServerSettings; +use Firefly\Config\Config; +use Illuminate\Console\Command; + +/** + * `php artisan firefly:management:serve` — the SECOND LISTENER, for development. + * + * `firefly.management.server.port` is only half a mechanism on its own. ManagementPortGuard makes the actuator + * refuse the application port, which is the enforcement half; something still has to ANSWER on the management port, + * and in production that is a second PHP-FPM pool, a second container, or a proxy rule (see the package README). + * None of those exist on a laptop, so without this command the honest result of setting a management port locally + * would be an actuator that 404s everywhere and a developer concluding the feature is broken. This command is what + * makes "it works out of the box" true in development: it starts a second `artisan serve` bound to the configured + * management address and port, alongside whichever server is already serving the application. + * + * IT DELEGATES TO `serve`, NOT TO `octane:start`, unlike firefly/cli's own `firefly:serve`. The management listener + * is a low-traffic side channel whose entire job is to exist; booting a second Octane supervisor (with its own + * worker pool, state resetters and reload watcher) to serve `/actuator/health` would cost more than the application + * server it sits next to. The application's own runtime choice is untouched — this is a sibling process, not a + * replacement. + * + * WHAT THIS DOES NOT DO, and the README says so too: it does not keep APPLICATION routes off the management port. + * One `artisan serve` is one Laravel application; every route it has is reachable on the port it was given. The + * guarantee this feature actually provides is one-directional — the ACTUATOR is unreachable on the application + * port — and restricting the reverse direction is a listener-level concern (an FPM pool that only nginx's + * management server block talks to, a container the public ingress has no route to), not something PHP can do from + * inside a request it has already been handed. + */ +final class ManagementServeCommand extends Command +{ + /** @var string */ + protected $signature = 'firefly:management:serve + {--host= : Bind address; defaults to firefly.management.server.address, then 127.0.0.1.} + {--port= : Listen port; defaults to firefly.management.server.port.}'; + + /** @var string */ + protected $description = 'Run a second dev listener for the actuator on the management port (firefly.management.server.port).'; + + public function handle(Config $config, ExposureModel $exposure): int + { + $settings = ManagementServerSettings::fromConfig($config); + + // A malformed --port must NOT quietly fall back to the configured one: the operator typed a port because + // they meant that port, and starting a listener somewhere else is the kind of "it ran, so it worked" + // outcome that gets noticed only when the health check they were debugging still fails. + $typed = $this->stringOption('port'); + if ($typed !== null && self::asPort($typed) === null) { + $this->components->error("[{$typed}] is not a TCP port between 1 and 65535."); + + return self::FAILURE; + } + + $port = $typed !== null ? self::asPort($typed) : $settings->port; + if ($port === null) { + $this->components->error( + 'No management port is configured. Set firefly.management.server.port (or pass --port) — without ' + .'one the actuator is served on the application port and this command has nothing to bind.', + ); + + return self::FAILURE; + } + + // The same fail-fast ActuatorRouteRegistrar applies at boot, repeated here because --port bypasses config + // entirely: a management listener on the application's own port is not a second listener, it is a port + // conflict that would either refuse to bind or shadow the application. + $applicationPort = ManagementServerSettings::applicationPort($config); + if ($applicationPort === $port) { + $this->components->error(sprintf( + 'Port %d is the application port. The management listener must have a port of its own.', + $port, + )); + + return self::FAILURE; + } + + $host = $this->stringOption('host') ?? $settings->address ?? '127.0.0.1'; + + $this->report($host, $port, $settings->mountPath($exposure)); + + return $this->call('serve', ['--host' => $host, '--port' => (string) $port]); + } + + /** + * `0.0.0.0`/`::` are wildcard BIND addresses, not addresses a browser can open — the same distinction + * firefly/cli's ServeCommand draws, and for the same reason: the bind argument is passed through exactly as + * typed, only the printed link is rewritten into something clickable. + */ + private function report(string $host, int $port, string $mountPath): void + { + $printable = match ($host) { + '0.0.0.0', '::', '[::]' => '127.0.0.1', + default => $host, + }; + + $this->newLine(); + $this->line(' <fg=gray>Actuator</> <options=bold>http://'.$printable.':'.$port.'/'.$mountPath.'</>'); + $this->line(' <fg=gray>Bind </> '.$host.':'.$port); + $this->line(' <fg=gray>Note </> the actuator answers ONLY here; application routes still answer on both'); + $this->line(' <fg=gray> </> ports, because one PHP process is one application. In production give'); + $this->line(' <fg=gray> </> this port its own PHP-FPM pool, container or proxy rule.'); + $this->newLine(); + } + + private function stringOption(string $name): ?string + { + $value = $this->option($name); + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + /** null for anything that is not an in-range TCP port — the same rule ManagementServerSettings applies to config. */ + private static function asPort(string $value): ?int + { + if (preg_match('/^\d+$/', $value) !== 1) { + return null; + } + + $port = (int) $value; + + return $port >= 1 && $port <= 65535 ? $port : null; + } +} diff --git a/packages/actuator/src/Server/ManagementPortGuard.php b/packages/actuator/src/Server/ManagementPortGuard.php new file mode 100644 index 0000000..8ae14de --- /dev/null +++ b/packages/actuator/src/Server/ManagementPortGuard.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Server; + +use Illuminate\Http\Request; + +/** + * The request-time half of `firefly.management.server.port`: does THIS request qualify to reach the management + * surface? A bound singleton, and the PUBLIC SEAM any other package mounting management UI reads — firefly/admin + * resolves this class and calls permits() on its dashboard request so the dashboard obeys the same port boundary as + * the JSON endpoints. Keep permits() a pure predicate for exactly that reason: callers render their own 404 (the + * actuator renders RFC-9457 problem+json, the dashboard renders HTML), and a guard that returned a Response would + * force one of them to fake the other's content type. + * + * WITHOUT A CONFIGURED PORT THIS IS A NO-OP. permits() returns true unconditionally, so an application that never + * heard of a management port behaves byte-for-byte as it did before this class existed — no new 404s, no new header + * reads, no new failure mode. + * + * WHICH "PORT THE REQUEST ARRIVED ON" — this is the whole security argument, so it is spelled out. + * + * The obvious call is `Request::getPort()`. It is the WRONG one. With no trusted proxy configured, Symfony derives + * that from the HOST HEADER, which the client writes: `curl -H 'Host: localhost:9001' http://localhost:8000/actuator/env` + * would walk straight through a guard built on it. A boundary a client can talk its way past is not a boundary. + * + * So the guard reads `SERVER_PORT` — written by the SAPI from the socket that actually accepted the connection + * (php-fpm from the pool's `listen`, the built-in server from `-S host:port`), never from request bytes. That is + * exactly the fact being asserted: this request came in on the management listener. + * + * `X-Forwarded-Port` is honoured ONLY when the request comes from a trusted proxy (Laravel's TrustProxies + * middleware, i.e. an address the application has explicitly vouched for). That covers the real deployment where one + * nginx/ALB terminates both :8000 and :9001 and forwards both to the SAME upstream pool, where SERVER_PORT is + * identical for both and the forwarded header is the only remaining evidence. Untrusted, the header is ignored + * outright rather than merged in — an attacker-supplied header is not a weaker signal, it is not a signal. + */ +final readonly class ManagementPortGuard +{ + public function __construct(private ManagementServerSettings $settings) {} + + /** + * True when the actuator may answer this request. Callers 404 on false — never 403: a 403 would confirm that a + * management surface exists on some other port, which is one more fact than an unauthenticated scan of the + * public port deserves, and 404 is what the actuator already returns for an unexposed endpoint. + */ + public function permits(Request $request): bool + { + if ($this->settings->port === null) { + return true; + } + + return $this->arrivalPort($request) === $this->settings->port; + } + + /** + * X-Forwarded-Port is evidence only when BOTH halves of Symfony's trusted-proxy contract hold: the peer is a + * trusted proxy, AND the application actually opted into that header (`Request::setTrustedProxies()`'s header + * set — Laravel's TrustProxies `$headers`). An operator who trusts a proxy for X-Forwarded-For alone has said + * their proxy does not sanitise the port header, and Symfony's own getPort() ignores it in that state; honouring + * it here would let a client behind such a proxy name its own arrival port and walk into the actuator. + */ + private function trustsForwardedPort(Request $request): bool + { + return $request->isFromTrustedProxy() + && (Request::getTrustedHeaderSet() & Request::HEADER_X_FORWARDED_PORT) !== 0; + } + + /** + * The port the listener accepted on, or null when it cannot be established (a SAPI that sets no SERVER_PORT). + * Null never permits a guarded request: an unknown port is not the management port. + */ + public function arrivalPort(Request $request): ?int + { + $forwarded = $this->trustsForwardedPort($request) + ? $request->headers->get('X-Forwarded-Port') + : null; + + // A forwarded chain is comma-separated and outermost-first; the first hop is the one that terminated the + // port the client actually dialled, which is the port the operator's rule names. + $candidate = $forwarded !== null && $forwarded !== '' + ? explode(',', $forwarded)[0] + : $request->server->get('SERVER_PORT'); + + if (is_int($candidate)) { + return $candidate; + } + + return is_string($candidate) && preg_match('/^\d+$/', trim($candidate)) === 1 + ? (int) trim($candidate) + : null; + } +} diff --git a/packages/actuator/src/Server/ManagementServerSettings.php b/packages/actuator/src/Server/ManagementServerSettings.php new file mode 100644 index 0000000..898875d --- /dev/null +++ b/packages/actuator/src/Server/ManagementServerSettings.php @@ -0,0 +1,205 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Server; + +use Firefly\Actuator\Endpoint\ExposureModel; +use Firefly\Config\Config; +use Firefly\Kernel\Exception\Framework\ConfigurationException; + +/** + * Spring's `management.server.*` — the management surface's OWN port, bind address and path prefix, read once at + * BootPhase::FlushDefinitions into an immutable value object (the ExposureModel/OpenApiProperties lifetime, and for + * the same reason: ActuatorRouteRegistrar mounts the routes from `mountPath()` at BootPhase::WiringPasses, so a + * post-boot `config()->set()` could not move an already-mounted route anyway). + * + * WHAT PHP CAN AND CANNOT DO HERE — read this before "finishing" the feature. + * + * Spring Boot's `management.server.port` opens a SECOND Tomcat connector inside the SAME JVM. A PHP-FPM worker, an + * `artisan serve` process, an Octane worker — each is handed ONE already-accepted connection by a listener it does + * not own and never sees. There is no point in the request lifecycle at which framework code could bind a second + * socket, and a `stream_socket_server()` opened from a request would die with the request. So this package does NOT + * pretend to serve two ports from one process. It splits the job in three, and each third is honest about which + * half of the guarantee it provides: + * + * 1. THE SECOND LISTENER is the deployment's job — a second PHP-FPM pool with its own `listen`, a second container, + * or a reverse-proxy rule. That is the ONLY thing that can make the management port a real network boundary, + * and no amount of PHP can substitute for it. See the package README's "Separate management port" section. + * 2. THE GUARD (ManagementPortGuard) is this package's job. It compares the port the request actually ARRIVED on to + * the configured one and 404s the actuator otherwise, so the separation is ENFORCED in-process even when the + * operator's proxy rule is missing or wrong. Without it, "management.server.port" would be pure documentation: + * the routes are mounted on the one Router this process has, and the application port would keep serving them. + * 3. THE DEV LISTENER is `php artisan firefly:management:serve` — a second `artisan serve` bound to the management + * address/port, so the feature works out of the box locally without a pool or a proxy. + * + * `address` is deliberately NOT part of the guard. A bind address is invisible to an HTTP request: the only thing a + * request carries that resembles one is the Host header, which the CLIENT writes. Refusing traffic because + * `Host: 10.0.0.4` does not equal a configured `127.0.0.1` would reject legitimate requests and accept forged ones + * — worse than nothing. The address is a BIND directive, consumed by `firefly:management:serve` and copied into the + * FPM pool's `listen`; the kernel enforces it long before PHP is reached. + */ +final readonly class ManagementServerSettings +{ + /** + * @param ?int $port firefly.management.server.port — null means "same port as the application", Spring's own + * default, and in that state every behaviour in this package is byte-for-byte what it was + * before the setting existed. + * @param ?string $address firefly.management.server.address — a BIND address, never a request-time check. + * @param string $basePath firefly.management.server.base-path, slash-trimmed; '' when unset. + */ + public function __construct( + public ?int $port, + public ?string $address, + public string $basePath, + ) {} + + public static function fromConfig(Config $config): self + { + return new self( + port: self::port($config), + address: self::address($config), + basePath: self::basePath($config), + ); + } + + /** Whether an operator has asked for the management surface to live on a port of its own. */ + public function isSeparate(): bool + { + return $this->port !== null; + } + + /** + * The router path the actuator is mounted at: this prefix, then the exposure model's own base path. COMPOSES + * with ExposureModel rather than replacing it — `firefly.management.endpoints.web.base-path` keeps meaning + * exactly what it meant (the actuator's path), and `firefly.management.server.base-path` adds a prefix in front + * of it, so `/manage` + `/actuator` serves `/manage/actuator/health`. + * + * DIVERGENCE FROM SPRING, DELIBERATE: Spring applies `management.server.base-path` ONLY when the management port + * differs from the application port, because there the prefix is the second connector's servlet context path and + * there is no second connector to hang it off otherwise. Applying it conditionally here would mean the actuator + * answers at `/actuator` in development (no management port) and `/manage/actuator` in production (management + * port set) from ONE config file — an environment-dependent URL, which is precisely the kind of "works on my + * machine" difference this package exists to remove. PHP has no second servlet context for the prefix to belong + * to, so there is nothing to be faithful to; the prefix is simply always part of the path. + */ + public function mountPath(ExposureModel $exposure): string + { + return $this->basePath === '' ? $exposure->basePath : $this->basePath.'/'.$exposure->basePath; + } + + /** + * Boot-time validation: a management port EQUAL to the application port is rejected, loudly. + * + * Spring treats the two being equal as "serve management on the main server" — a legal way to say "no + * separation". Here it cannot mean that, and reading it that way would be a trap. The whole mechanism is the + * ManagementPortGuard, and a guard configured with the application's own port permits every request that reaches + * it: an operator who wrote `management.server.port` got a config file that LOOKS isolated, a `/actuator` still + * answering on the public port, and no signal whatsoever that the isolation they asked for is not there. That is + * a security-relevant silent no-op, so it fails the boot instead. + * + * $applicationPort is whatever applicationPort() could establish; null means "unknown", and an unknown + * application port is NOT an error — see that method for why PHP frequently cannot know it. + */ + public function assertDistinctFrom(?int $applicationPort): void + { + if ($this->port === null || $applicationPort === null || $this->port !== $applicationPort) { + return; + } + + throw new ConfigurationException(sprintf( + 'firefly.management.server.port (%d) is the application port. A management port only isolates the ' + .'actuator when it is a DIFFERENT port served by a different listener (a second PHP-FPM pool, a second ' + .'container, or a proxy rule) — set it to a port of its own, or remove it to serve the actuator on the ' + .'application port as before.', + $this->port, + )); + } + + /** + * The port the APPLICATION is served on, or null when this process cannot know it. + * + * PHP is not told. An FPM pool's `listen` lives in a file the framework never reads; `artisan serve --port` is a + * flag on a different process; behind a proxy the public port and the upstream port are different numbers on + * different machines. So there are exactly two honest sources, in order: + * + * - `firefly.server.port` — Spring's `server.port`, an explicit declaration. Nothing in the framework binds it + * (nothing could); it exists so an operator can TELL the framework what the deployment does, and get the + * equality check above in return. + * - the explicit port in `app.url` — the local case where this mistake actually happens + * (`APP_URL=http://localhost:8000` next to `management.server.port=8000`). Only an EXPLICIT port counts: + * `https://api.example.test` yields null rather than a guessed 443, because a public URL behind a proxy says + * nothing about the port this process's listener accepted on, and inventing one would fail boots that are + * correctly configured. + * + * Returning null when neither is available is the right answer, not a gap to be papered over: refusing to guess + * is what keeps this check from being the thing that breaks a valid deployment. + */ + public static function applicationPort(Config $config): ?int + { + $declared = self::asPort($config->get('firefly.server.port')); + if ($declared !== null) { + return $declared; + } + + $url = $config->get('app.url'); + if (! is_string($url) || $url === '') { + return null; + } + + $port = parse_url($url, PHP_URL_PORT); + + return is_int($port) ? $port : null; + } + + /** + * An UNSET port is `null` OR `''`, not just a missing key. `'port' => env('FIREFLY_MANAGEMENT_PORT')` is the + * spelling every published Laravel config file uses, and env() answers null (or '' for an empty variable) when + * the variable is absent — while Illuminate's `Repository::has()` reports true for a key explicitly set to null. + * Reading this through Config::int() would therefore throw "Required configuration key is not set" for the most + * ordinary possible config file, so the raw value is normalised here instead. + */ + private static function port(Config $config): ?int + { + $raw = $config->get('firefly.management.server.port'); + if ($raw === null || $raw === '') { + return null; + } + + $port = self::asPort($raw); + if ($port === null) { + throw new ConfigurationException(sprintf( + 'firefly.management.server.port must be a TCP port between 1 and 65535, got [%s].', + is_scalar($raw) ? (string) $raw : get_debug_type($raw), + )); + } + + return $port; + } + + /** null for anything that is not an in-range TCP port, so both callers can decide what that means. */ + private static function asPort(mixed $raw): ?int + { + $port = match (true) { + is_int($raw) => $raw, + is_string($raw) && preg_match('/^\d+$/', trim($raw)) === 1 => (int) trim($raw), + default => null, + }; + + return $port !== null && $port >= 1 && $port <= 65535 ? $port : null; + } + + private static function address(Config $config): ?string + { + $raw = $config->get('firefly.management.server.address'); + + return is_string($raw) && trim($raw) !== '' ? trim($raw) : null; + } + + private static function basePath(Config $config): string + { + $raw = $config->get('firefly.management.server.base-path'); + + return is_string($raw) ? trim(trim($raw), '/') : ''; + } +} diff --git a/packages/actuator/src/Web/ActuatorDispatchAction.php b/packages/actuator/src/Web/ActuatorDispatchAction.php index 117fb75..f588d8b 100644 --- a/packages/actuator/src/Web/ActuatorDispatchAction.php +++ b/packages/actuator/src/Web/ActuatorDispatchAction.php @@ -8,6 +8,7 @@ use Firefly\Actuator\Endpoint\EndpointRequest; use Firefly\Actuator\Endpoint\EndpointResponse; use Firefly\Actuator\Endpoint\ExposureModel; +use Firefly\Actuator\Server\ManagementPortGuard; use Firefly\Config\Config; use Firefly\Kernel\Error\ErrorCategory; use Firefly\Kernel\Error\ErrorSeverity; @@ -30,11 +31,16 @@ public function __construct( private readonly ExposureModel $exposure, private readonly Config $config, private readonly ProblemDetailsRenderer $problems, + private readonly ManagementPortGuard $guard, ) {} public function __invoke(Request $request, string $path): Response { try { + if (! $this->guard->permits($request)) { + return $this->problems->render($this->notFound(), $request); + } + $segments = array_values(array_filter(explode('/', $path), static fn (string $s): bool => $s !== '')); $id = $segments[0] ?? ''; $subPath = array_slice($segments, 1); diff --git a/packages/actuator/src/Web/ActuatorIndexAction.php b/packages/actuator/src/Web/ActuatorIndexAction.php index 7575921..4e4fbe5 100644 --- a/packages/actuator/src/Web/ActuatorIndexAction.php +++ b/packages/actuator/src/Web/ActuatorIndexAction.php @@ -6,13 +6,28 @@ use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Actuator\Endpoint\ExposureModel; +use Firefly\Actuator\Server\ManagementPortGuard; +use Firefly\Actuator\Server\ManagementServerSettings; use Firefly\Config\Config; +use Firefly\Kernel\Error\ErrorCategory; +use Firefly\Kernel\Error\ErrorSeverity; +use Firefly\Kernel\Exception\FireflyException; +use Firefly\Web\Exception\ProblemDetailsRenderer; use Illuminate\Http\Request; use Illuminate\Http\Response; /** * The HAL index at {base}: a `_links` map of every EXPOSED + enabled endpoint to its href. Mirrors Spring's * /actuator index so tooling can discover endpoints. + * + * The hrefs are built from ManagementServerSettings::mountPath(), NOT from the ExposureModel's base path alone: + * `firefly.management.server.base-path` prefixes the mount, and an index advertising `/actuator/health` while the + * router only answers `/manage/actuator/health` would hand every discovery client a set of dead links — the one + * failure mode a HAL index exists to prevent. + * + * The index is guarded by ManagementPortGuard exactly as the dispatch action is, and returns the same problem+json + * 404 rather than an empty `_links` object: an index that answered 200 with nothing in it on the application port + * would confirm the actuator is mounted somewhere, which is the disclosure the management port is there to stop. */ final class ActuatorIndexAction { @@ -20,11 +35,23 @@ public function __construct( private readonly ActuatorRegistry $registry, private readonly ExposureModel $exposure, private readonly Config $config, + private readonly ManagementServerSettings $management, + private readonly ManagementPortGuard $guard, + private readonly ProblemDetailsRenderer $problems, ) {} public function __invoke(Request $request): Response { - $base = rtrim($request->getSchemeAndHttpHost().'/'.$this->exposure->basePath, '/'); + if (! $this->guard->permits($request)) { + // The twin of ActuatorDispatchAction::notFound() — same status, same code, same renderer — so both + // halves of the surface are indistinguishable from an unrouted URL on the wrong port. + return $this->problems->render( + new FireflyException('Not Found', 'RESOURCE_NOT_FOUND', 404, ErrorCategory::Framework, ErrorSeverity::Warning), + $request, + ); + } + + $base = rtrim($request->getSchemeAndHttpHost().'/'.$this->management->mountPath($this->exposure), '/'); $links = ['self' => ['href' => $base]]; foreach ($this->registry->all() as $id => $endpoint) { diff --git a/packages/actuator/tests/Boot/ActuatorRouteRegistrarTest.php b/packages/actuator/tests/Boot/ActuatorRouteRegistrarTest.php index c487dcb..9fdf72f 100644 --- a/packages/actuator/tests/Boot/ActuatorRouteRegistrarTest.php +++ b/packages/actuator/tests/Boot/ActuatorRouteRegistrarTest.php @@ -5,6 +5,7 @@ use Firefly\Actuator\Boot\ActuatorRouteRegistrar; use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Actuator\Endpoint\ExposureModel; +use Firefly\Actuator\Server\ManagementServerSettings; use Firefly\Config\Config; use Firefly\Config\Profile\Profiles; use Firefly\Context\Boot\BootContext; @@ -12,22 +13,29 @@ use Firefly\Context\Condition\ConditionEvaluationReport; use Firefly\Context\Condition\ConditionEvaluator; use Firefly\Context\Definition\BeanDefinitionRegistry; +use Firefly\Kernel\Exception\Framework\ConfigurationException; use Illuminate\Config\Repository; use Illuminate\Container\Container; use Illuminate\Events\Dispatcher; use Illuminate\Routing\Router; /** + * ManagementServerSettings is bound explicitly for the same reason ExposureModel is: both are #[Bean]s on + * ActuatorAutoConfiguration in a real boot, and neither is autowirable from a bare Container (their constructors + * take scalars/arrays). The registrar resolves them, so this harness has to supply them. + * * @param array<string, mixed> $management + * @param array<string, mixed> $firefly extra top-level firefly.* config (e.g. `server.port`) */ -function registrarContext(array $management): BootContext +function registrarContext(array $management, array $firefly = []): BootContext { $container = new Container; - $repository = new Repository(['firefly' => ['management' => $management]]); + $repository = new Repository(['firefly' => ['management' => $management] + $firefly]); $config = new Config($repository); $container->instance('config', $repository); $container->instance(Config::class, $config); $container->instance(ExposureModel::class, ExposureModel::fromConfig($config)); + $container->instance(ManagementServerSettings::class, ManagementServerSettings::fromConfig($config)); $container->instance(ActuatorRegistry::class, new ActuatorRegistry); $container->instance('router', new Router(new Dispatcher($container), $container)); $report = new ConditionEvaluationReport; @@ -69,6 +77,58 @@ function registrarContext(array $management): BootContext expect($router->getRoutes()->getRoutes())->toBeEmpty(); }); +it('mounts under the management server base path when one is configured', function () { + $context = registrarContext(['enabled' => true, 'server' => ['base-path' => '/manage']]); + + (new ActuatorRouteRegistrar)->run($context); + + /** @var Router $router */ + $router = $context->container->make('router'); + $uris = collect($router->getRoutes()->getRoutes())->map(fn ($r) => $r->uri())->all(); + + expect($uris)->toContain('manage/actuator')->toContain('manage/actuator/{path}'); +}); + +// A management port equal to the application port would leave ManagementPortGuard permitting every request — a +// config file that reads as isolated and is not. The registrar aborts the boot rather than mounting that. +it('aborts the boot when the management port is the application port', function () { + $context = registrarContext( + ['enabled' => true, 'server' => ['port' => 8000]], + ['server' => ['port' => 8000]], + ); + + expect(fn () => (new ActuatorRouteRegistrar)->run($context)) + ->toThrow(ConfigurationException::class, 'is the application port'); +}); + +it('mounts normally when the management port differs from the application port', function () { + $context = registrarContext( + ['enabled' => true, 'server' => ['port' => 9001]], + ['server' => ['port' => 8000]], + ); + + (new ActuatorRouteRegistrar)->run($context); + + /** @var Router $router */ + $router = $context->container->make('router'); + expect(collect($router->getRoutes()->getRoutes())->map(fn ($r) => $r->uri())->all())->toContain('actuator'); +}); + +// The master gate runs FIRST on purpose: an application with the actuator switched off has no management surface +// to isolate and must not be blocked from booting over the configuration of one. +it('does not validate the management port when the master gate is off', function () { + $context = registrarContext( + ['enabled' => false, 'server' => ['port' => 8000]], + ['server' => ['port' => 8000]], + ); + + (new ActuatorRouteRegistrar)->run($context); + + /** @var Router $router */ + $router = $context->container->make('router'); + expect($router->getRoutes()->getRoutes())->toBeEmpty(); +}); + it('is a WiringPasses pass ordered 50', function () { $pass = new ActuatorRouteRegistrar; expect($pass->phase())->toBe(BootPhase::WiringPasses)->and($pass->order())->toBe(50); diff --git a/packages/actuator/tests/Boot/ManagementPortBootFailureTest.php b/packages/actuator/tests/Boot/ManagementPortBootFailureTest.php new file mode 100644 index 0000000..576dcf7 --- /dev/null +++ b/packages/actuator/tests/Boot/ManagementPortBootFailureTest.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\ActuatorServiceProvider; +use Firefly\Actuator\ActuatorWiringProvider; +use Firefly\Actuator\Server\ManagementPortGuard; +use Firefly\Actuator\Server\ManagementServerSettings; +use Firefly\Context\Boot\ApplicationContext; +use Firefly\Kernel\Exception\Framework\ConfigurationException; +use Firefly\Scheduling\Schedule\ScheduledManifest; +use Firefly\Web\Route\RouteManifest; +use Illuminate\Foundation\Application; +use Illuminate\Http\Request; + +/** + * The boot-time half of the feature, exercised through the REAL provider stack rather than a hand-built BootContext + * (ActuatorRouteRegistrarTest covers the pass in isolation): the #[Bean]s really are registered, really are + * resolvable, and a management port equal to the application port really does abort a whole application boot rather + * than producing a container whose guard permits everything. + * + * Same bare-skeleton shape as PackageBootTest — RouteManifest/ScheduledManifest are stubbed in rather than dragging + * the Web/Scheduling boot pipelines in behind them. + * + * @param array<string, mixed> $firefly + */ +function bootActuatorWithManagement(array $firefly): Application +{ + return fireflyApplication( + config: ['firefly' => $firefly], + providers: [ActuatorServiceProvider::class, ActuatorWiringProvider::class], + bindings: [ + RouteManifest::class => new RouteManifest([]), + ScheduledManifest::class => new ScheduledManifest([]), + ], + ); +} + +it('binds the management settings and guard as resolvable beans', function () { + $app = bootActuatorWithManagement(['management' => ['enabled' => true, 'server' => ['port' => 9001]]]); + + /** @var ManagementServerSettings $settings */ + $settings = $app->make(ManagementServerSettings::class); + + expect($app->make(ApplicationContext::class))->toBeInstanceOf(ApplicationContext::class) + ->and($settings->port)->toBe(9001) + ->and($settings->isSeparate())->toBeTrue() + ->and($app->make(ManagementPortGuard::class))->toBeInstanceOf(ManagementPortGuard::class); +}); + +it('aborts the whole boot when the management port is the application port', function () { + expect(fn () => bootActuatorWithManagement([ + 'management' => ['enabled' => true, 'server' => ['port' => 8000]], + 'server' => ['port' => 8000], + ]))->toThrow(ConfigurationException::class, 'firefly.management.server.port (8000) is the application port'); +}); + +// An unset management port must leave the beans present and inert, so nothing about a default application changes. +it('binds an inert guard when no management port is configured', function () { + $app = bootActuatorWithManagement(['management' => ['enabled' => true]]); + + /** @var ManagementServerSettings $settings */ + $settings = $app->make(ManagementServerSettings::class); + + expect($settings->port)->toBeNull() + ->and($settings->isSeparate())->toBeFalse() + ->and($app->make(ManagementPortGuard::class)->permits(Request::create('http://localhost:1/x'))) + ->toBeTrue(); +}); diff --git a/packages/actuator/tests/CapstoneManagementBasePathTest.php b/packages/actuator/tests/CapstoneManagementBasePathTest.php new file mode 100644 index 0000000..4776729 --- /dev/null +++ b/packages/actuator/tests/CapstoneManagementBasePathTest.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Tests\Support\ManagementBasePathCapstoneTestCase; + +/** + * firefly.management.server.base-path composes with firefly.management.endpoints.web.base-path rather than + * replacing it, and — unlike Spring — applies with or without a management port, so one config file yields the same + * actuator URL in development and production. See ManagementServerSettings::mountPath() for that argument in full. + */ +uses(ManagementBasePathCapstoneTestCase::class); + +it('serves the actuator under the management server base path', function () { + /** @var ManagementBasePathCapstoneTestCase $this */ + $this->getJson('/manage/actuator/health')->assertStatus(200)->assertJsonPath('status', 'UP'); +}); + +it('no longer serves the un-prefixed path', function () { + /** @var ManagementBasePathCapstoneTestCase $this */ + $this->getJson('/actuator/health')->assertStatus(404); +}); + +// A HAL index advertising /actuator/health while the router only answers /manage/actuator/health would hand every +// discovery client a set of dead links — the one failure an index exists to prevent. +it('advertises the prefixed hrefs from the HAL index', function () { + /** @var ManagementBasePathCapstoneTestCase $this */ + $this->getJson('/manage/actuator') + ->assertStatus(200) + ->assertJsonPath('_links.self.href', 'http://localhost/manage/actuator') + ->assertJsonPath('_links.health.href', 'http://localhost/manage/actuator/health'); +}); diff --git a/packages/actuator/tests/CapstoneManagementPortTest.php b/packages/actuator/tests/CapstoneManagementPortTest.php new file mode 100644 index 0000000..e030547 --- /dev/null +++ b/packages/actuator/tests/CapstoneManagementPortTest.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Tests\Support\ManagementPortCapstoneTestCase; + +/** + * The end-to-end contract of firefly.management.server.port, over the REAL HTTP kernel: with a management port + * configured, the actuator answers on that port and NOWHERE else. + * + * This is the half of the feature PHP can actually enforce. The second listening socket is the deployment's job (a + * second PHP-FPM pool, a second container, a proxy rule — or `php artisan firefly:management:serve` locally); what + * the framework guarantees, and what these cases pin, is that the application port stops serving the actuator the + * moment a management port exists. Routes are still MOUNTED — one process, one Router — so every case here is + * proving a request-time refusal, not an absent route. + */ +uses(ManagementPortCapstoneTestCase::class); + +it('404s the health endpoint on the application port', function () { + /** @var ManagementPortCapstoneTestCase $this */ + $this->getJson('/actuator/health')->assertStatus(404); +}); + +it('serves the health endpoint on the management port', function () { + /** @var ManagementPortCapstoneTestCase $this */ + $this->getJson($this->onManagementPort('/actuator/health')) + ->assertStatus(200) + ->assertJsonPath('status', 'UP'); +}); + +// An index answering 200 with an empty _links on the application port would confirm the actuator exists somewhere, +// which is precisely the disclosure the management port is there to stop. +it('404s the HAL index on the application port and serves it on the management port', function () { + /** @var ManagementPortCapstoneTestCase $this */ + $this->getJson('/actuator')->assertStatus(404); + + $this->getJson($this->onManagementPort('/actuator')) + ->assertStatus(200) + ->assertJsonPath('_links.health.href', 'http://localhost:9001/actuator/health'); +}); + +// The refusal must be indistinguishable from "no such route", so a scan of the public port learns nothing. The +// actuator's own 404 is RFC-9457 problem+json with RESOURCE_NOT_FOUND, exactly as an unexposed endpoint's is. +it('refuses with the same problem+json 404 an unexposed endpoint gets', function () { + /** @var ManagementPortCapstoneTestCase $this */ + $onApp = $this->getJson('/actuator/health'); + $unexposed = $this->getJson($this->onManagementPort('/actuator/env')); + + expect($onApp->json('code'))->toBe('RESOURCE_NOT_FOUND') + ->and($onApp->json('code'))->toBe($unexposed->json('code')) + ->and($onApp->getStatusCode())->toBe($unexposed->getStatusCode()) + ->and($onApp->headers->get('Content-Type'))->toBe('application/problem+json'); +}); + +// The guard must not turn into a general-purpose firewall: it refuses the ACTUATOR on the wrong port, and touches +// nothing else. Application routes are the listener's business, not PHP's — see ManagementServeCommand. +it('leaves non-actuator routes untouched on the application port', function () { + /** @var ManagementPortCapstoneTestCase $this */ + $this->app()->make('router')->get('/ping', fn (): string => 'pong'); + + expect($this->responseBody($this->get('/ping')->assertStatus(200)))->toBe('pong'); +}); diff --git a/packages/actuator/tests/Command/ManagementServeCommandTest.php b/packages/actuator/tests/Command/ManagementServeCommandTest.php new file mode 100644 index 0000000..e614f68 --- /dev/null +++ b/packages/actuator/tests/Command/ManagementServeCommandTest.php @@ -0,0 +1,146 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Tests\Support\ArtisanAssertions; +use Firefly\Actuator\Tests\Support\ManagementServeCapstoneTestCase; +use Illuminate\Console\Command; +use Illuminate\Support\Facades\Artisan; + +/** + * Every passing case stubs the delegation target rather than starting a server — `artisan serve` blocks forever, + * which in a test suite is indistinguishable from a hang. The stub goes in through Artisan::registerCommand(), not + * Artisan::command(), because the latter defers registration to a console-application `starting` callback that + * never fires again once testbench has already built the console application; this is the same technique + * firefly/cli's ServeCommandTest uses on `octane:start`, for the same reason. + */ +uses(ManagementServeCapstoneTestCase::class); + +/** + * Replaces `serve` with a recorder and hands back the live log of what it was invoked with. + * + * @return ArrayObject<int, array{host: mixed, port: mixed}> + */ +function stubServe(): ArrayObject +{ + /** @var ArrayObject<int, array{host: mixed, port: mixed}> $calls */ + $calls = new ArrayObject; + + Artisan::registerCommand(new class($calls) extends Command + { + /** @var string */ + protected $signature = 'serve {--host=} {--port=}'; + + /** @var string */ + protected $description = 'Test stub standing in for the framework\'s own serve command.'; + + /** @param ArrayObject<int, array{host: mixed, port: mixed}> $calls */ + public function __construct(private readonly ArrayObject $calls) + { + parent::__construct(); + } + + public function handle(): int + { + $this->calls[] = ['host' => $this->option('host'), 'port' => $this->option('port')]; + + return self::SUCCESS; + } + }); + + return $calls; +} + +it('fails, naming the config key, when no management port is configured', function () { + /** @var ManagementServeCapstoneTestCase $this */ + ArtisanAssertions::outputContains( + $this->artisan('firefly:management:serve'), + Command::FAILURE, + ['firefly.management.server.port'], + ); +}); + +it('refuses a management port that is the application port', function () { + /** @var ManagementServeCapstoneTestCase $this */ + config()->set('firefly.server.port', 8000); + + ArtisanAssertions::outputContains( + $this->artisan('firefly:management:serve', ['--port' => '8000']), + Command::FAILURE, + ['is the application port'], + ); +}); + +it('reports the actuator URL and delegates to serve on the management port', function () { + /** @var ManagementServeCapstoneTestCase $this */ + $calls = stubServe(); + + ArtisanAssertions::outputContains( + $this->artisan('firefly:management:serve', ['--port' => '9001']), + Command::SUCCESS, + ['http://127.0.0.1:9001/actuator'], + ); + + expect($calls->getArrayCopy())->toBe([['host' => '127.0.0.1', 'port' => '9001']]); +}); + +// The bind argument is passed through EXACTLY as typed — only the printed link is rewritten, because pasting +// http://0.0.0.0:9001 into a browser is a coin flip across platforms. +it('binds the wildcard address as typed but prints a clickable one', function () { + /** @var ManagementServeCapstoneTestCase $this */ + $calls = stubServe(); + + ArtisanAssertions::outputContains( + $this->artisan('firefly:management:serve', ['--port' => '9001', '--host' => '0.0.0.0']), + Command::SUCCESS, + ['http://127.0.0.1:9001/actuator', '0.0.0.0:9001'], + ); + + expect($calls->getArrayCopy())->toBe([['host' => '0.0.0.0', 'port' => '9001']]); +}); + +it('defaults the bind address to the configured management address', function () { + /** @var ManagementServeCapstoneTestCase $this */ + config()->set('firefly.management.server.address', '10.0.0.4'); + config()->set('firefly.management.server.port', 9001); + $calls = stubServe(); + + ArtisanAssertions::outputContains( + $this->artisan('firefly:management:serve'), + Command::SUCCESS, + ['10.0.0.4:9001'], + ); + + expect($calls->getArrayCopy())->toBe([['host' => '10.0.0.4', 'port' => '9001']]); +}); + +// The prefix belongs in the printed URL: an operator following a link to /actuator on a deployment whose actuator +// lives at /manage/actuator has been sent to a 404 by the very command meant to make this work locally. +it('prints the management server base path in the URL', function () { + /** @var ManagementServeCapstoneTestCase $this */ + config()->set('firefly.management.server.base-path', '/manage'); + stubServe(); + + ArtisanAssertions::outputContains( + $this->artisan('firefly:management:serve', ['--port' => '9001']), + Command::SUCCESS, + ['http://127.0.0.1:9001/manage/actuator'], + ); +}); + +// A malformed --port must not quietly fall back to the configured port: the operator typed a port because they +// meant that port, and a listener bound somewhere else is the kind of "it ran, so it worked" outcome that gets +// noticed only when the health check they were debugging still fails. +it('rejects a malformed --port instead of falling back to the configured one', function (string $typed) { + /** @var ManagementServeCapstoneTestCase $this */ + config()->set('firefly.management.server.port', 9001); + $calls = stubServe(); + + ArtisanAssertions::outputContains( + $this->artisan('firefly:management:serve', ['--port' => $typed]), + Command::FAILURE, + ['is not a TCP port between 1 and 65535'], + ); + + expect($calls->getArrayCopy())->toBe([]); +})->with([['abc'], ['0'], ['70000']]); diff --git a/packages/actuator/tests/Server/ManagementPortGuardTest.php b/packages/actuator/tests/Server/ManagementPortGuardTest.php new file mode 100644 index 0000000..2e824ef --- /dev/null +++ b/packages/actuator/tests/Server/ManagementPortGuardTest.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Server\ManagementPortGuard; +use Firefly\Actuator\Server\ManagementServerSettings; +use Illuminate\Http\Request; + +function guardFor(?int $port): ManagementPortGuard +{ + return new ManagementPortGuard(new ManagementServerSettings($port, null, '')); +} + +/** Request::create() writes SERVER_PORT from the URI's port, exactly as a SAPI writes it from the socket. */ +function requestOnPort(int $port): Request +{ + return Request::createFromBase(Request::create("http://localhost:{$port}/actuator/health")); +} + +it('permits everything when no management port is configured', function () { + expect(guardFor(null)->permits(requestOnPort(80)))->toBeTrue() + ->and(guardFor(null)->permits(requestOnPort(9001)))->toBeTrue(); +}); + +it('permits a request that arrived on the management port', function () { + expect(guardFor(9001)->permits(requestOnPort(9001)))->toBeTrue(); +}); + +it('refuses a request that arrived on the application port', function () { + expect(guardFor(9001)->permits(requestOnPort(8000)))->toBeFalse(); +}); + +// THE ATTACK THIS GUARD EXISTS TO STOP. Request::getPort() derives the port from the Host header when no trusted +// proxy is configured, so a guard built on it would be walked past by a forged Host. SERVER_PORT comes from the +// socket, and the forged header must not move it. +it('ignores a forged Host header claiming the management port', function () { + // The header is set AFTER construction on purpose: Request::create() rewrites HTTP_HOST from the URI, so a + // forged host passed in the $server array would be quietly overwritten and the test would prove nothing. + $request = Request::createFromBase(Request::create('http://localhost:8000/actuator/env')); + $request->headers->set('HOST', 'localhost:9001'); + + expect($request->getPort())->toBe(9001) + ->and(guardFor(9001)->permits($request))->toBeFalse(); +}); + +// One proxy terminating both :8000 and :9001 onto the SAME upstream pool leaves SERVER_PORT identical for both; +// X-Forwarded-Port is then the only evidence left, and it is trustworthy exactly as far as the proxy is. +it('honours X-Forwarded-Port from a trusted proxy', function () { + $request = Request::createFromBase(Request::create( + 'http://localhost:9000/actuator/health', + 'GET', + server: ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PORT' => '9001'], + )); + $request->setTrustedProxies(['10.0.0.1'], Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PORT); + + try { + expect(guardFor(9001)->permits($request))->toBeTrue() + ->and(guardFor(9002)->permits($request))->toBeFalse(); + } finally { + Request::setTrustedProxies([], 0); + } +}); + +// Trusting a proxy for X-Forwarded-For alone is a statement that the proxy does NOT sanitise the port header — +// Symfony's own getPort() ignores it in that state, and so must this guard, or a client behind such a proxy could +// name its own arrival port. +it('ignores X-Forwarded-Port when the proxy is trusted for other headers only', function () { + $request = Request::createFromBase(Request::create( + 'http://localhost:8000/actuator/env', + 'GET', + server: ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PORT' => '9001'], + )); + $request->setTrustedProxies(['10.0.0.1'], Request::HEADER_X_FORWARDED_FOR); + + try { + expect(guardFor(9001)->arrivalPort($request))->toBe(8000) + ->and(guardFor(9001)->permits($request))->toBeFalse(); + } finally { + Request::setTrustedProxies([], 0); + } +}); + +it('ignores X-Forwarded-Port from an untrusted peer', function () { + $request = Request::createFromBase(Request::create( + 'http://localhost:8000/actuator/env', + 'GET', + server: ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_X_FORWARDED_PORT' => '9001'], + )); + + expect(guardFor(9001)->permits($request))->toBeFalse(); +}); + +it('refuses when the arrival port cannot be established at all', function () { + $request = Request::createFromBase(Request::create('http://localhost/actuator/health')); + $request->server->remove('SERVER_PORT'); + $request->headers->remove('HOST'); + + expect(guardFor(9001)->arrivalPort($request))->toBeNull() + ->and(guardFor(9001)->permits($request))->toBeFalse(); +}); diff --git a/packages/actuator/tests/Server/ManagementServerSettingsTest.php b/packages/actuator/tests/Server/ManagementServerSettingsTest.php new file mode 100644 index 0000000..a01a3d4 --- /dev/null +++ b/packages/actuator/tests/Server/ManagementServerSettingsTest.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Endpoint\ExposureModel; +use Firefly\Actuator\Server\ManagementServerSettings; +use Firefly\Config\Config; +use Firefly\Kernel\Exception\Framework\ConfigurationException; +use Illuminate\Config\Repository; + +/** + * @param array<string, mixed> $config + */ +function managementSettings(array $config): ManagementServerSettings +{ + return ManagementServerSettings::fromConfig(new Config(new Repository($config))); +} + +it('defaults to no management port, no address and no prefix', function () { + $settings = managementSettings([]); + + expect($settings->port)->toBeNull() + ->and($settings->address)->toBeNull() + ->and($settings->basePath)->toBe('') + ->and($settings->isSeparate())->toBeFalse(); +}); + +it('reads port, address and base path', function () { + $settings = managementSettings(['firefly' => ['management' => ['server' => [ + 'port' => 9001, + 'address' => ' 127.0.0.1 ', + 'base-path' => '/manage/', + ]]]]); + + expect($settings->port)->toBe(9001) + ->and($settings->address)->toBe('127.0.0.1') + ->and($settings->basePath)->toBe('manage') + ->and($settings->isSeparate())->toBeTrue(); +}); + +// `'port' => env('FIREFLY_MANAGEMENT_PORT')` is what a published config file actually contains, and env() answers +// null (or '' for a set-but-empty variable) when the variable is absent — while Repository::has() reports TRUE for +// a key explicitly set to null. Reading this through Config::int() would throw "Required configuration key is not +// set" for the most ordinary config file there is. +it('treats an explicit null or empty port as unset, not as an error', function (mixed $raw) { + expect(managementSettings(['firefly' => ['management' => ['server' => ['port' => $raw]]]])->port)->toBeNull(); +})->with([[null], ['']]); + +it('accepts a numeric string port, because .env values are strings', function () { + expect(managementSettings(['firefly' => ['management' => ['server' => ['port' => '9001']]]])->port)->toBe(9001); +}); + +it('rejects a port that is not a TCP port', function (mixed $raw) { + expect(fn () => managementSettings(['firefly' => ['management' => ['server' => ['port' => $raw]]]])) + ->toThrow(ConfigurationException::class, 'must be a TCP port between 1 and 65535'); +})->with([[0], [70000], [-1], ['nine thousand'], [true]]); + +it('prefixes the exposure base path with the management server base path', function () { + $exposure = ExposureModel::fromConfig(new Config(new Repository([]))); + + expect(managementSettings([])->mountPath($exposure))->toBe('actuator') + ->and(managementSettings(['firefly' => ['management' => ['server' => ['base-path' => '/manage']]]])->mountPath($exposure)) + ->toBe('manage/actuator'); +}); + +// DELIBERATE DIVERGENCE FROM SPRING: Spring applies management.server.base-path only when the management port +// differs. Applying it unconditionally keeps the actuator's URL identical in development (no management port) and +// production (management port set) from one config file. +it('applies the base path prefix even without a management port', function () { + $exposure = ExposureModel::fromConfig(new Config(new Repository([]))); + $settings = managementSettings(['firefly' => ['management' => ['server' => ['base-path' => 'manage']]]]); + + expect($settings->isSeparate())->toBeFalse()->and($settings->mountPath($exposure))->toBe('manage/actuator'); +}); + +it('rejects a management port equal to the application port', function () { + expect(fn () => managementSettings([])->assertDistinctFrom(null))->not->toThrow(ConfigurationException::class); + + $settings = managementSettings(['firefly' => ['management' => ['server' => ['port' => 8000]]]]); + + expect(fn () => $settings->assertDistinctFrom(8000)) + ->toThrow(ConfigurationException::class, 'is the application port'); + expect(fn () => $settings->assertDistinctFrom(9001))->not->toThrow(ConfigurationException::class); + expect(fn () => $settings->assertDistinctFrom(null))->not->toThrow(ConfigurationException::class); +}); + +it('resolves the application port from firefly.server.port first', function () { + $config = new Config(new Repository([ + 'firefly' => ['server' => ['port' => '8000']], + 'app' => ['url' => 'http://localhost:1234'], + ])); + + expect(ManagementServerSettings::applicationPort($config))->toBe(8000); +}); + +it('falls back to an explicit port in app.url', function () { + $config = new Config(new Repository(['app' => ['url' => 'http://localhost:8000']])); + + expect(ManagementServerSettings::applicationPort($config))->toBe(8000); +}); + +// Refusing to guess is the point: a public URL behind a proxy says nothing about the port this process's listener +// accepted on, and an invented 443/80 would abort correctly-configured boots. +it('returns null rather than guessing a default port for a portless app.url', function () { + $config = new Config(new Repository(['app' => ['url' => 'https://api.example.test']])); + + expect(ManagementServerSettings::applicationPort($config))->toBeNull(); +}); + +it('returns null when nothing declares an application port', function () { + expect(ManagementServerSettings::applicationPort(new Config(new Repository([]))))->toBeNull(); +}); diff --git a/packages/actuator/tests/Support/ArtisanAssertions.php b/packages/actuator/tests/Support/ArtisanAssertions.php new file mode 100644 index 0000000..8096901 --- /dev/null +++ b/packages/actuator/tests/Support/ArtisanAssertions.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Support; + +use Illuminate\Testing\PendingCommand; + +/** + * PHPStan-safe Artisan assertions for firefly:management:serve's tests. + * + * InteractsWithConsole::artisan() is declared `@return PendingCommand|int` — it returns a raw int only when + * $mockConsoleOutput is disabled, which never happens under this monorepo's FireflyTestCase harness. Rather than + * widening or suppressing that at six call sites, both members of the real union are handled here so level max sees + * a fully-typed call in either branch. Deliberately a CLASS rather than a file-local function: the whole monorepo + * suite runs in one PHPUnit process, so two test files declaring a same-named global function would fatal with + * "Cannot redeclare function". Mirrors firefly/cli's own Firefly\Cli\Tests\Support\ArtisanAssertions — copied + * rather than imported, because a package's tests must not depend on a sibling package's test-only autoload. + */ +final class ArtisanAssertions +{ + /** + * Assert the exit code and every expected output fragment, then RUN the command explicitly so a caller can + * inspect what it delegated to without depending on PendingCommand's destructor firing first. + * + * @param list<string> $needles + */ + public static function outputContains(PendingCommand|int $result, int $exitCode, array $needles): void + { + if (! $result instanceof PendingCommand) { + expect($result)->toBe($exitCode); + + return; + } + + $result->assertExitCode($exitCode); + foreach ($needles as $needle) { + $result->expectsOutputToContain($needle); + } + + $result->run(); + } +} diff --git a/packages/actuator/tests/Support/ManagementBasePathCapstoneTestCase.php b/packages/actuator/tests/Support/ManagementBasePathCapstoneTestCase.php new file mode 100644 index 0000000..3f4bfb1 --- /dev/null +++ b/packages/actuator/tests/Support/ManagementBasePathCapstoneTestCase.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Support; + +/** + * The same full HTTP boot as ActuatorCapstoneTestCase, with firefly.management.server.base-path set and NO + * management port — the combination Spring would silently ignore. Its own boot for the usual reason: the mount path + * is read at BootPhase::WiringPasses and a post-boot config()->set() cannot move a route that is already on the + * Router. + */ +abstract class ManagementBasePathCapstoneTestCase extends ActuatorCapstoneTestCase +{ + protected function configOverrides(): array + { + return parent::configOverrides() + [ + 'firefly.management.server.base-path' => '/manage', + ]; + } +} diff --git a/packages/actuator/tests/Support/ManagementPortCapstoneTestCase.php b/packages/actuator/tests/Support/ManagementPortCapstoneTestCase.php new file mode 100644 index 0000000..1606259 --- /dev/null +++ b/packages/actuator/tests/Support/ManagementPortCapstoneTestCase.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Support; + +/** + * The same full HTTP boot as ActuatorCapstoneTestCase, with firefly.management.server.port set. + * + * It has to be its OWN boot, not a config()->set() inside a test body: ManagementServerSettings is a singleton + * #[Bean] resolved once at BootPhase::FlushDefinitions and ActuatorRouteRegistrar validates the port at + * WiringPasses, so a post-boot mutation reaches neither — the same constraint managementEnabled()/exposureInclude() + * already document on the parent. + * + * 9001 is deliberately NOT testbench's app.url port: app.url is `http://localhost` with no explicit port, so + * ManagementServerSettings::applicationPort() answers null and the equality check correctly stands aside. A request + * is then aimed at a port by passing an ABSOLUTE URL to the test helper — Laravel's prepareUrlForRequest() passes a + * fully-qualified URL through untouched, and Symfony's Request::create() writes SERVER_PORT from its port component + * exactly as a real SAPI writes it from the accepted socket. + */ +abstract class ManagementPortCapstoneTestCase extends ActuatorCapstoneTestCase +{ + public const MANAGEMENT_PORT = 9001; + + protected function configOverrides(): array + { + return parent::configOverrides() + [ + 'firefly.management.server.port' => self::MANAGEMENT_PORT, + ]; + } + + /** The same path, addressed on the management listener rather than the application one. */ + public function onManagementPort(string $path): string + { + return 'http://localhost:'.self::MANAGEMENT_PORT.$path; + } +} diff --git a/packages/actuator/tests/Support/ManagementServeCapstoneTestCase.php b/packages/actuator/tests/Support/ManagementServeCapstoneTestCase.php new file mode 100644 index 0000000..b1a3594 --- /dev/null +++ b/packages/actuator/tests/Support/ManagementServeCapstoneTestCase.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Support; + +/** + * A console-shaped boot for firefly:management:serve. No management port is set here — each case sets what it needs + * through --port, or asserts the unconfigured failure — so this base only exists to give the command an application + * whose actuator wiring is real (the command resolves Config and ExposureModel out of the container). + */ +abstract class ManagementServeCapstoneTestCase extends ActuatorCapstoneTestCase {} diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php index 7b7fe86..d8fc50f 100644 --- a/packages/admin/src/Boot/AdminRouteRegistrar.php +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -5,6 +5,8 @@ namespace Firefly\Admin\Boot; use Firefly\Actuator\Endpoint\ActuatorRegistry; +use Firefly\Actuator\Server\ManagementPortGuard; +use Firefly\Actuator\Server\ManagementServerSettings; use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; use Firefly\Admin\Web\AdminAction; @@ -66,6 +68,9 @@ public function run(BootContext $context): void $container->make(AdminEndpointReader::class), $container->make(ViewFactory::class), $container, + // Resolved here rather than injected as a bean so the dashboard works whether or not the + // actuator's own wiring has bound one: the settings come from the same config keys either way. + new ManagementPortGuard(ManagementServerSettings::fromConfig($context->config)), )); /** @var Router $router */ diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 16c5924..6d2d7a4 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -4,6 +4,7 @@ namespace Firefly\Admin\Web; +use Firefly\Actuator\Server\ManagementPortGuard; use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; use Firefly\Admin\BeanGraph; @@ -30,10 +31,24 @@ public function __construct( private AdminEndpointReader $reader, private ViewFactory $views, private Container $container, + private ManagementPortGuard $guard, ) {} public function __invoke(Request $request, string $page = ''): SymfonyResponse { + // The management port boundary applies to the dashboard MORE than to the JSON actuator, not less. + // The actuator withholds sensitive endpoints behind ExposureModel; this dashboard deliberately + // bypasses that model so it can render beans, env and config properties in-process. If an operator + // has moved management traffic to a private port, a dashboard still answering on the public one + // would publish exactly the surface they moved — and with no signal that it had happened. + // + // 404, never 403: a 403 confirms a management surface exists on some other port, which is one more + // fact than an unauthenticated scan of the public port deserves. Same reasoning, same status, as + // ManagementPortGuard's own callers in the actuator. + if (! $this->guard->permits($request)) { + return $this->html($this->render('missing', ['slug' => trim($page, '/')]), 404); + } + $slug = trim($page, '/'); $current = null; foreach (AdminPage::all() as $candidate) { diff --git a/packages/admin/tests/ManagementPortBoundaryTest.php b/packages/admin/tests/ManagementPortBoundaryTest.php new file mode 100644 index 0000000..56596d6 --- /dev/null +++ b/packages/admin/tests/ManagementPortBoundaryTest.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +use Firefly\Admin\Tests\Support\ManagementPortTestCase; + +uses(ManagementPortTestCase::class); + +/** + * The management port boundary applies to the dashboard MORE than to the JSON actuator, not less. + * + * The actuator withholds sensitive endpoints behind ExposureModel; the dashboard deliberately bypasses that + * model so it can render beans, env and config properties in-process. A dashboard still answering on the + * public application port after an operator moved management traffic to a private one would publish exactly + * the surface they moved, with no signal that it had happened. + * + * The counter-case — that nothing changes when no management port is configured — is the whole of + * CapstoneAdminIntegrationTest, which configures none and expects 200s throughout. + */ +it('refuses every dashboard page that did not arrive on the management port', function (string $path) { + /** @var ManagementPortTestCase $this */ + $this->get($path)->assertStatus(404); +})->with(['/firefly', '/firefly/beans', '/firefly/env', '/firefly/configprops', '/firefly/graph']); + +// 404, never 403: a 403 confirms a management surface exists on some other port, which is one more fact than +// an unauthenticated scan of the public port deserves. +it('does not confirm that a management surface exists elsewhere', function () { + /** @var ManagementPortTestCase $this */ + $response = $this->get('/firefly/env'); + + $response->assertStatus(404); + + expect($response->getContent())->not->toContain('9001') + ->and($response->getContent())->not->toContain('management'); +}); diff --git a/packages/admin/tests/Support/ManagementPortTestCase.php b/packages/admin/tests/Support/ManagementPortTestCase.php new file mode 100644 index 0000000..30f9fe0 --- /dev/null +++ b/packages/admin/tests/Support/ManagementPortTestCase.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Support; + +/** + * The dashboard with a management port configured that the test requests do NOT arrive on. + * + * Testbench serves through the HTTP kernel without a real listener, so the arrival port is whatever the + * request reports — which is not 9001. That is exactly the shape being asserted: a request that did not + * come in on the management port must not reach the dashboard. + */ +abstract class ManagementPortTestCase extends AdminCapstoneTestCase +{ + /** @return array<string, mixed> */ + protected function configOverrides(): array + { + return [...parent::configOverrides(), 'firefly.management.server.port' => 9001]; + } +} diff --git a/packages/context/src/Pass/EagerSingletonsPass.php b/packages/context/src/Pass/EagerSingletonsPass.php index ca8d8d3..1a0dce6 100644 --- a/packages/context/src/Pass/EagerSingletonsPass.php +++ b/packages/context/src/Pass/EagerSingletonsPass.php @@ -61,11 +61,36 @@ public function order(): int public function run(BootContext $context): void { foreach ($this->orderedEagerAbstracts($context) as $abstract) { + // A COMPILED MANIFEST GOES STALE THE MOMENT A CLASS IS DELETED OR RENAMED, and that is an + // ordinary thing to do while developing. Before this guard, the consequence was catastrophic and + // unrecoverable: the manifest still named the class, this pass make()d it, the container threw + // "Target class does not exist", and BOTH commands that repair the situation — firefly:cache and + // firefly:clear — died with the same error, because each has to boot the application before it + // can rewrite or delete the manifest. Deleting one controller bricked the application, and the + // only escape was to `rm -rf bootstrap/cache/firefly` by hand. + // + // Skipping is the only defensible response. A definition naming a class that no longer exists + // describes an application state that has already moved on, and refusing to boot over it helps + // nobody: the class is gone, nothing can inject it, and the next firefly:cache will drop it from + // the manifest anyway. A stale entry is a cache-invalidation problem, never a reason to take the + // application down. + // + // Only a MISSING class is tolerated. Every other resolution failure — a genuinely broken + // constructor, an unsatisfiable dependency, the registrar's own NoUniqueBeanDefinition guard — + // still propagates, because those are real defects in code that does exist and failing fast at + // boot is exactly right for them. $context->container->make($abstract); } } /** + * The abstracts to resolve, in #[Order], with any whose DECLARING CLASS no longer exists dropped. + * + * The check is on the declaring class rather than on the binding key, and rather than on whether the + * container has a binding, because both of those answer yes for a stale entry: the registrar binds + * straight from the same manifest, and a #[Bean] key is often a bean NAME with no class of its own. The + * declaring class is the thing that actually goes missing when someone deletes a file. + * * @return list<string> */ private function orderedEagerAbstracts(BootContext $context): array @@ -78,6 +103,27 @@ private function orderedEagerAbstracts(BootContext $context): array foreach ($context->definitions->all() as $definition) { $descriptor = $definition->descriptor; + // A COMPILED MANIFEST GOES STALE THE MOMENT A CLASS IS DELETED OR RENAMED, and that is an + // ordinary thing to do while developing. Before this guard the consequence was catastrophic and + // unrecoverable: the manifest still named the class, this pass resolved it, the container threw + // "Target class does not exist", and BOTH commands that repair the situation — firefly:cache and + // firefly:clear — died with the same error, because each must boot the application before it can + // rewrite or delete the manifest. Deleting one controller bricked the application, and the only + // escape was `rm -rf bootstrap/cache/firefly` by hand. + // + // Skipping is the only defensible response. A definition naming a class that no longer exists + // describes an application that has already moved on: nothing can inject it, and the next + // firefly:cache drops it from the manifest anyway. A stale entry is a cache-invalidation + // problem, never a reason to take the application down. + // + // Only a MISSING class is tolerated here. Every other resolution failure — a broken constructor, + // an unsatisfiable dependency, the registrar's own NoUniqueBeanDefinition guard — still + // propagates from make(), because those are real defects in code that does exist, and failing + // fast at boot is exactly right for them. + if (! class_exists($descriptor->class)) { + continue; + } + if ($descriptor->scope === Scope::Singleton && ! $descriptor->lazy) { $entries[] = [$descriptor->order, $descriptor->class]; } diff --git a/packages/context/tests/Pass/EagerSingletonsPassTest.php b/packages/context/tests/Pass/EagerSingletonsPassTest.php index a36e7a6..d5a9db4 100644 --- a/packages/context/tests/Pass/EagerSingletonsPassTest.php +++ b/packages/context/tests/Pass/EagerSingletonsPassTest.php @@ -40,6 +40,9 @@ public function record(string $entry): void } } +/** A real declaring class for the #[Bean] factory entries below. */ +final class EagerBeanHolder {} + final class EagerWidgetA { public function __construct(EagerLog $log) @@ -174,9 +177,12 @@ function eagerContext(): BootContext new BeanDescriptor('makeB', EagerWidgetB::class, null, Scope::Singleton, false, 0, lazy: true), ]; - // Outer holder deliberately Scope::Transient so only the #[Bean] entries are under test here. + // Outer holder deliberately Scope::Transient so only the #[Bean] entries are under test here. It is a + // REAL class: EagerSingletonsPass skips a definition whose declaring class no longer exists, because a + // stale compiled manifest must not brick the application (see StaleManifestSurvivalTest), and a #[Bean] + // factory cannot run without the class that declares it either way. $context->definitions->add(new BeanDefinition( - eagerDescriptor('App\\BeanHolder', scope: Scope::Transient, beans: $beans) + eagerDescriptor(EagerBeanHolder::class, scope: Scope::Transient, beans: $beans) )); (new EagerSingletonsPass)->run($context); diff --git a/packages/context/tests/Pass/StaleManifestSurvivalTest.php b/packages/context/tests/Pass/StaleManifestSurvivalTest.php new file mode 100644 index 0000000..c8b57f2 --- /dev/null +++ b/packages/context/tests/Pass/StaleManifestSurvivalTest.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +use Firefly\Config\Config; +use Firefly\Config\Profile\Profiles; +use Firefly\Container\Descriptor\ComponentDescriptor; +use Firefly\Container\Scope; +use Firefly\Context\Boot\BootContext; +use Firefly\Context\Condition\ConditionEvaluationReport; +use Firefly\Context\Condition\ConditionEvaluator; +use Firefly\Context\Definition\BeanDefinition; +use Firefly\Context\Definition\BeanDefinitionRegistry; +use Firefly\Context\Definition\DefinitionSource; +use Firefly\Context\Pass\EagerSingletonsPass; +use Firefly\Context\Scanner\ContextManifest; +use Illuminate\Config\Repository; +use Illuminate\Container\Container; +use Illuminate\Contracts\Container\BindingResolutionException; + +/** + * A compiled manifest goes stale the moment a class is deleted or renamed, which is an ordinary thing to do + * while developing. + * + * The consequence used to be catastrophic AND unrecoverable. The manifest still named the class, + * EagerSingletonsPass resolved it, the container threw "Target class does not exist" — and both commands + * that repair the situation, firefly:cache and firefly:clear, died with the SAME error, because each must + * boot the application before it can rewrite or delete the manifest. Deleting one controller bricked the + * application, and the only escape was to remove bootstrap/cache/firefly by hand. + * + * Reproduced end to end before the fix: `/`, `/actuator` and `/firefly` all answered 500, and + * `php artisan firefly:cache` exited 1 without writing anything. + */ +function stalePassContext(BeanDefinitionRegistry $definitions): BootContext +{ + $container = new Container; + $config = new Config(new Repository([])); + $profiles = new Profiles(['default']); + + return new BootContext( + container: $container, + definitions: $definitions, + config: $config, + profiles: $profiles, + conditions: new ConditionEvaluator($config, $profiles), + report: new ConditionEvaluationReport, + contextManifest: new ContextManifest([]), + ); +} + +function staleDefinition(string $class): BeanDefinition +{ + return new BeanDefinition( + descriptor: new ComponentDescriptor( + class: $class, + stereotype: 'component', + name: null, + scope: Scope::Singleton, + primary: false, + order: 0, + qualifier: null, + interfaces: [], + beans: [], + ), + source: DefinitionSource::User, + ); +} + +it('boots past a definition whose class no longer exists', function () { + $definitions = new BeanDefinitionRegistry; + $definitions->add(staleDefinition('App\\Deleted\\GoneController')); + + $context = stalePassContext($definitions); + + // The whole point: this must NOT throw. Before the guard it raised + // BindingResolutionException("Target class [App\Deleted\GoneController] does not exist."). + (new EagerSingletonsPass)->run($context); + + expect($context->container->resolved('App\\Deleted\\GoneController'))->toBeFalse(); +}); + +// Only a MISSING class is tolerated. A class that exists and genuinely cannot be built is a real defect, and +// failing fast at boot is exactly right for it — a guard that swallowed those would hide broken code. +it('still fails fast when a class that DOES exist cannot be constructed', function () { + $definitions = new BeanDefinitionRegistry; + $definitions->add(staleDefinition(UnconstructableFixture::class)); + + expect(fn () => (new EagerSingletonsPass)->run(stalePassContext($definitions))) + ->toThrow(BindingResolutionException::class); +}); + +it('still resolves the definitions around a stale one', function () { + $definitions = new BeanDefinitionRegistry; + $definitions->add(staleDefinition('App\\Deleted\\GoneController')); + $definitions->add(staleDefinition(ConstructableFixture::class)); + + $context = stalePassContext($definitions); + (new EagerSingletonsPass)->run($context); + + expect($context->container->resolved(ConstructableFixture::class))->toBeTrue(); +}); + +final class ConstructableFixture {} + +/** Constructible only if the container can satisfy an interface nothing binds — which nothing does. */ +final class UnconstructableFixture +{ + public function __construct(public readonly NeverBindableContract $missing) {} +} + +interface NeverBindableContract {} diff --git a/packages/openapi/README.md b/packages/openapi/README.md index e452146..12d9059 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -71,26 +71,35 @@ binding carries `#[Valid]`. The problem schema describes what LaraFly actually r *plus* Firefly's `code`, `category`, `severity` and `errors`, with the category and severity enumerations read straight off the kernel enums. -## The viewer, and the CDN flag +## The viewer -The default console at `/openapi` is a single self-contained HTML page: **no npm build at install time and no -network access at request time.** It groups operations by tag and resolves `$ref` pointers client-side so a -reader sees a DTO's members rather than a pointer. +`/openapi` serves the **official Swagger UI** — the real distribution, not a lookalike — from your own +application's origin. **No npm build at install time and no third-party request at page view.** -Every off-the-shelf viewer (Swagger UI, Redoc, Elements) is a bundled JavaScript application, which leaves -only two options: vendor a multi-megabyte bundle into a PHP package, or fetch it from a CDN on every page -view. The second is a supply-chain dependency and a data-protection question, and it does not render at all in -the air-gapped and strict-CSP environments where an internal API console is most wanted. +That combination used to look impossible. Shipping an off-the-shelf viewer seemed to leave only two options: +vendor a multi-megabyte bundle into a PHP package's git history, or fetch it from a CDN on every page view. +The second is a supply-chain dependency and a data-protection question, and it does not render at all in the +air-gapped and strict-CSP environments where an internal API console is most wanted. -Swagger UI is available for teams that want the full feature set: +The way out is that Swagger already publishes its `dist` on Packagist, under Apache-2.0. `firefly/openapi` +requires `swagger-api/swagger-ui`, so composer fetches and pins it like any other dependency, and this +package serves the files from a route of its own. Only the seven basenames the page references are servable, +each `realpath()`-checked inside the dist directory, and they are sent immutable with a long max-age — +composer only changes them when the pinned version changes. -```php -'firefly' => ['openapi' => ['viewer' => ['cdn' => true]]], -``` +Three styles, chosen with `firefly.openapi.viewer.style`: + +| Style | What you get | +| --- | --- | +| `swagger` *(default)* | The official Swagger UI, served locally. Deep linking, try-it-out, OAuth2, the lot. | +| `builtin` | A hand-written reference: one `<script>`, a few hundred bytes of CSS, zero third-party code. Operations by tag, resolved `$ref` schemas, constraint keywords, and a request console. | +| `cdn` | Swagger UI from `cdn.jsdelivr.net`. The only style that makes a third-party request at page view; the version is pinned exactly. | + +`swagger` falls back to `builtin` when `swagger-api/swagger-ui` is not installed — a default that cannot +render is worse than a different default. -**This flag defaults to `false`, and turning it on means the browser fetches code from `cdn.jsdelivr.net` on -every page view.** The version is pinned exactly; no Subresource Integrity hash is claimed, because a hash the -framework cannot verify at release time is security theatre. +The older `'viewer' => ['cdn' => true]` spelling still forces the CDN page, so an application that set it +before `style` existed keeps the behaviour it configured. ## Configuration @@ -102,7 +111,8 @@ framework cannot verify at release time is security theatre. 'viewer' => [ 'enabled' => true, 'path' => '/openapi', - 'cdn' => false, // opt in to Swagger UI over a CDN — see above + 'style' => 'swagger', // swagger (official UI, served locally) | builtin | cdn + 'cdn' => false, // legacy spelling; true still forces the cdn style ], 'title' => 'API', 'version' => '0.0.0', diff --git a/packages/openapi/cache/firefly-openapi-components.php b/packages/openapi/cache/firefly-openapi-components.php index 7c03d87..97a9020 100644 --- a/packages/openapi/cache/firefly-openapi-components.php +++ b/packages/openapi/cache/firefly-openapi-components.php @@ -65,6 +65,18 @@ ], ], 4 => [ + 'method' => 'documentInfo', + 'returns' => 'Firefly\\OpenApi\\Generator\\DocumentInfo', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], + ], + 5 => [ 'method' => 'openApiGenerator', 'returns' => 'Firefly\\OpenApi\\Generator\\OpenApiGenerator', 'name' => null, @@ -76,9 +88,10 @@ 0 => 'Firefly\\Web\\Route\\RouteManifest', 1 => 'Firefly\\OpenApi\\OpenApiProperties', 2 => 'Firefly\\OpenApi\\Generator\\OperationFactory', + 3 => 'Firefly\\OpenApi\\Generator\\DocumentInfo', ], ], - 5 => [ + 6 => [ 'method' => 'viewerPage', 'returns' => 'Firefly\\OpenApi\\Web\\ViewerPage', 'name' => null, diff --git a/packages/openapi/cache/firefly-openapi-context.php b/packages/openapi/cache/firefly-openapi-context.php index 1365d18..fb9cec2 100644 --- a/packages/openapi/cache/firefly-openapi-context.php +++ b/packages/openapi/cache/firefly-openapi-context.php @@ -61,6 +61,17 @@ ], ], 4 => [ + 'method' => 'documentInfo', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\OpenApi\\Generator\\DocumentInfo', + ], + ], + ], + ], + 5 => [ 'method' => 'openApiGenerator', 'conditions' => [ 0 => [ @@ -71,7 +82,7 @@ ], ], ], - 5 => [ + 6 => [ 'method' => 'viewerPage', 'conditions' => [ 0 => [ diff --git a/packages/openapi/src/Attributes/ApiIgnore.php b/packages/openapi/src/Attributes/ApiIgnore.php new file mode 100644 index 0000000..a38059a --- /dev/null +++ b/packages/openapi/src/Attributes/ApiIgnore.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Attributes; + +use Attribute; + +/** + * Leaves a controller — or one action on it — out of the generated document entirely. + * + * There was already a way to hide routes (`firefly.openapi.exclude`, a list of path prefixes) and it is the + * wrong tool for this job twice over: it lives in config rather than next to the code, so nothing reminds + * anyone to update it when a route moves, and it is keyed by PATH, so hiding `/internal/reindex` also hides + * every future route that happens to start with those characters. #[ApiIgnore] is keyed by the thing that + * actually decides — the class or the method — and travels with it through every rename and remount. + * + * This is NOT a security control. The route still exists and still answers; only its description is withheld. + * Anything that must not be reachable belongs behind #[PreAuthorize] or is not a route at all. What it IS for + * is the operation that is real but not part of the published contract: a health probe, an internal + * back-office action, an endpoint that exists for one deploy while a client migrates. + * + * On a CLASS it removes every route the class declares; on a METHOD it removes exactly that route. There is + * no "un-ignore" on a method inside an ignored class, because a whitelist inside a blacklist is a rule nobody + * can read off the code at a glance. + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class ApiIgnore {} diff --git a/packages/openapi/src/Attributes/ApiOperation.php b/packages/openapi/src/Attributes/ApiOperation.php new file mode 100644 index 0000000..5ac65f8 --- /dev/null +++ b/packages/openapi/src/Attributes/ApiOperation.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Attributes; + +use Attribute; + +/** + * Overrides what the generator derived for one operation — springdoc's @Operation. + * + * PRECEDENCE, which is the whole point of this class: attribute beats docblock beats derived default. Every + * member here is optional and an omitted one falls THROUGH rather than blanking the derived value, so + * #[ApiOperation(operationId: 'cancelOrder')] fixes the id and leaves the docblock-derived summary and + * description exactly where they were. That fall-through is why `summary`/`description` default to the empty + * string rather than to null: an empty string is what an author writes when they mean "nothing to say here", + * and it is indistinguishable from "not stated" — so neither can blank a docblock, and an author who wants a + * genuinely empty summary should delete the docblock instead. + * + * `deprecated` is a ?bool for the opposite reason. It has a real third state: `null` means "not stated, use + * the docblock's @deprecated", `true` means deprecated, and `false` means deprecated: false EVEN IF the + * docblock says @deprecated — which is the one case where a PHP-level deprecation (the method is going away, + * internally) genuinely differs from an HTTP-level one (the endpoint is going away, for clients). + * + * `operationId` is the member worth the most care. It is REQUIRED to be unique across the whole document and + * a duplicate is the single flaw that makes most client generators abort rather than degrade, so an id set + * here is still run through the generator's uniqueness check and suffixed if it collides — the attribute + * chooses the name, it does not get to break the document. It is also the name a generated client's method + * ends up with, which is why it is worth setting by hand for anything a human will call often. + * + * `tags` REPLACES the derived single tag rather than adding to it, because the derived tag is a guess from + * the class name and merging a guess with an author's list produces a group nobody asked for. Listing the + * derived tag alongside a new one is a two-word edit if that is what was wanted. + */ +#[Attribute(Attribute::TARGET_METHOD)] +final class ApiOperation +{ + /** + * @param list<string> $tags replaces the controller-derived tag; empty means "leave it alone" + */ + public function __construct( + public readonly string $summary = '', + public readonly string $description = '', + public readonly ?string $operationId = null, + public readonly ?bool $deprecated = null, + public readonly array $tags = [], + ) {} +} diff --git a/packages/openapi/src/Attributes/ApiParameter.php b/packages/openapi/src/Attributes/ApiParameter.php new file mode 100644 index 0000000..f50c2c6 --- /dev/null +++ b/packages/openapi/src/Attributes/ApiParameter.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Attributes; + +use Attribute; + +/** + * Enriches ONE already-bound parameter — springdoc's @Parameter. Repeatable, and declared on the METHOD + * rather than on the parameter itself. + * + * WHY ON THE METHOD. A controller parameter already carries #[PathVariable]/#[QueryParam]/#[RequestHeader], + * and those are read by RouteScanner at `firefly:cache` time and compiled into the binding plan the + * dispatcher runs on. Adding a documentation-only attribute into that same parameter list would put a + * generator concern inside the hot signature the scanner walks, and would invite the next reader to assume + * the dispatcher honours it. Keeping it on the method draws the line where it belongs: the binding plan says + * what the server READS, this says what the document SHOWS. + * + * `name` is matched against the WIRE key, not the PHP variable — `X-Tenant`, not `$tenant` — because that is + * what appears in the document and what a client actually sends. A name matching no binding is IGNORED rather + * than added as a new parameter: the binding plan is the only honest statement of what the endpoint reads, + * and inventing a parameter the dispatcher will never look at documents an API that does not exist. + * + * `required` is a ?bool so that "not stated" survives; it is honoured for query and header parameters only. + * A PATH parameter is required by the OpenAPI specification itself — `required: false` is invalid there — so + * an override is dropped rather than allowed to produce a document a strict validator rejects. + * + * `example` is emitted as the Parameter Object's own `example` member, which is still current in 3.1. (The + * SCHEMA Object's `example` is the one 3.1 deprecated in favour of JSON Schema's `examples` array — see + * ApiProperty, which is on the schema side of that line and spells it the other way.) A null example is read + * as "no example": a parameter whose only documented value is null teaches a reader nothing. + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class ApiParameter +{ + public function __construct( + public readonly string $name, + public readonly string $description = '', + public readonly mixed $example = null, + public readonly ?bool $required = null, + ) {} +} diff --git a/packages/openapi/src/Attributes/ApiProperty.php b/packages/openapi/src/Attributes/ApiProperty.php new file mode 100644 index 0000000..6c7d40b --- /dev/null +++ b/packages/openapi/src/Attributes/ApiProperty.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Attributes; + +use Attribute; + +/** + * Enriches one DTO member's schema — springdoc's @Schema on a field. + * + * The generator already knows a great deal about a member without being told: the declared PHP type fixes the + * JSON type, a backed enum fixes the accepted SET, and the compiled ConstraintManifest fixes the bounds, + * patterns and formats that #[Email]/#[Size]/#[Pattern] actually enforce. What none of that can supply is + * PROSE, and what a docblock cannot supply is a `format` or an example. This attribute is the second half. + * + * `description` beats the member's docblock, which beats the promoted-constructor `@param` line. All three + * are absent by default and nothing is invented in their place — an undescribed property gets no + * `description` key rather than a restatement of its own name. + * + * `format` OVERWRITES a constraint-derived one. That is deliberate and it is the point: #[Email] emits + * `format: email` and an author who writes `format: 'idn-email'` has said something more precise than the + * validator could. `format` in JSON Schema 2020-12 is an open, annotation-only vocabulary, so an unregistered + * value is legal and merely un-asserted rather than an error. + * + * `example` is emitted as `examples: [value]`, NOT as `example: value`. OpenAPI 3.1 aligned the Schema Object + * with JSON Schema 2020-12, whose keyword is the plural ARRAY form, and explicitly deprecated the singular + * `example` it inherited from 3.0. Writing the deprecated spelling would still render in most viewers today + * and would be the first thing a 3.2 validator complains about. + * + * A null `example` is read as "no example", the same convention ApiParameter uses: a member whose only + * published example is null teaches a reader nothing, and the distinction between "absent" and "null" is not + * worth a sentinel constant in an attribute people write by hand. + * + * `deprecated` marks the member without removing it, which is the only way to retire a request field without + * breaking every client at once. + */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] +final class ApiProperty +{ + public function __construct( + public readonly string $description = '', + public readonly mixed $example = null, + public readonly ?string $format = null, + public readonly bool $deprecated = false, + ) {} +} diff --git a/packages/openapi/src/Attributes/ApiResponse.php b/packages/openapi/src/Attributes/ApiResponse.php new file mode 100644 index 0000000..df37a91 --- /dev/null +++ b/packages/openapi/src/Attributes/ApiResponse.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Attributes; + +use Attribute; + +/** + * Documents one response the generator cannot derive — springdoc's @ApiResponse. Repeatable. + * + * WHAT IS ALREADY DERIVED, and therefore what this is NOT for. The success status comes from #[Mapping]; a + * `400` is emitted exactly when the binding plan contains something ArgumentResolver can reject before the + * controller runs; a `422` exactly when some binding carries #[Valid]; and a `default` always, covering every + * problem the handler itself raises. See OperationFactory::responses(), which explains why each of those is + * derivable and why guessing more would be worse than saying less. + * + * WHAT IT IS FOR is the half that provably cannot be derived: the statuses that depend on the controller's + * BODY. A `404` from a repository miss, a `409` from a uniqueness clash, a `402` from a payment gateway — + * none of these is stated anywhere the framework can read, short of parsing the method's statements and + * resolving every exception type it can reach. `default` already covers them structurally (they all render + * through the same RFC-9457 ProblemDetailsRenderer), so this attribute is about telling a HUMAN, and a client + * generator, which of them are real and what they mean. + * + * A status declared here that the generator also derived REPLACES the derived entry — that is the documented + * precedence, and it is what lets an author put real prose on the `200` instead of "Successful response." + * without losing the derived response's content type. + * + * `type` is a PHP type NAME, not a schema: `'array'`, `'string'`, or a DTO class-string, which becomes a + * `$ref` to a component registered exactly like a request body's. Passing a class the process cannot autoload + * degrades to an untyped body rather than emitting a dangling pointer. + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class ApiResponse +{ + /** + * @param int|string $status an HTTP status, or the literal string `default` + * @param string|null $type a PHP type name or DTO class-string; null documents the response as bodiless + */ + public function __construct( + public readonly int|string $status, + public readonly string $description, + public readonly ?string $type = null, + ) {} +} diff --git a/packages/openapi/src/Attributes/ApiTag.php b/packages/openapi/src/Attributes/ApiTag.php new file mode 100644 index 0000000..1077198 --- /dev/null +++ b/packages/openapi/src/Attributes/ApiTag.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Attributes; + +use Attribute; + +/** + * Names and describes the TAG a controller's operations are grouped under — springdoc's @Tag. + * + * Without it the tag name is derived from the class (`Lumen\Web\WalletController` reads as "Wallet") and its + * description comes from the class docblock. That derivation is good enough most of the time and is exactly + * why it stays the default; this attribute exists for the cases where it is not. A `V2OrdersController` + * derives the tag "V2Orders", which is how nobody writes it in prose, and two controllers that split one + * cohesive area (`OrderController` + `OrderRefundController`) derive two tags where the reader wants one — + * pointing both at #[ApiTag('Orders')] merges them, because a tag is a NAME, not a class. + * + * `description` is optional and falls through to the class docblock when omitted, so a controller that + * already documents itself in prose only needs the attribute to fix the NAME. The description surfaces in + * the document's root `tags` array, which is the only place OpenAPI lets a tag carry one — an operation's + * own `tags` member is a bare list of strings. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final class ApiTag +{ + public function __construct( + public readonly string $name, + public readonly string $description = '', + ) {} +} diff --git a/packages/openapi/src/Generator/ApiDocs.php b/packages/openapi/src/Generator/ApiDocs.php new file mode 100644 index 0000000..79b0680 --- /dev/null +++ b/packages/openapi/src/Generator/ApiDocs.php @@ -0,0 +1,282 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Generator; + +use Firefly\OpenApi\Attributes\ApiIgnore; +use Firefly\OpenApi\Attributes\ApiOperation; +use Firefly\OpenApi\Attributes\ApiParameter; +use Firefly\OpenApi\Attributes\ApiResponse; +use Firefly\OpenApi\Attributes\ApiTag; +use Firefly\Web\Route\RouteDescriptor; +use ReflectionClass; +use ReflectionMethod; + +/** + * The one place the three documentation sources are merged, and the one place this package reflects a + * controller. + * + * PRECEDENCE, stated once and applied nowhere else: ATTRIBUTE beats DOCBLOCK beats DERIVED DEFAULT. + * + * summary #[ApiOperation(summary:)] -> docblock's first sentence -> humanised method name + * description #[ApiOperation(description:)] -> the docblock's remaining paragraphs -> nothing + * operationId #[ApiOperation(operationId:)] -> the #[Mapping]'s route name -> `orderShow` + * deprecated #[ApiOperation(deprecated:)] -> the docblock's @deprecated -> false + * tags #[ApiOperation(tags:)] -> #[ApiTag(name:)] -> `Order` from the class + * tag prose #[ApiTag(description:)] -> the CLASS docblock -> nothing + * presence #[ApiIgnore] -> (no docblock spelling) -> documented + * + * The operationId row is the one whose LAST TWO columns are applied elsewhere: this class returns only what + * the attribute said, because the fall-through has to happen next to the uniqueness ledger that suffixes a + * collision, and that ledger lives in OpenApiGenerator. There is deliberately no docblock spelling of + * #[ApiIgnore] — a `@internal` tag means something to static analysers already and quietly repurposing it to + * delete routes from a published contract would be a trap. + * + * An omitted attribute member falls THROUGH to the next source rather than blanking it, which is why the + * string members here are compared against '' rather than against null: an attribute an author did not write + * and an attribute member they left at its default are the same statement, and both must let the docblock + * win. `deprecated` is the documented exception — it is a ?bool precisely so that `deprecated: false` can + * mean "not deprecated, whatever the docblock says" rather than "not stated". + * + * WHY REFLECTING HERE IS ALLOWED, WHEN packages/web FORBIDS IT. The framework's invariant is that nothing on + * the CACHED REQUEST PATH reflects: RouteScanner and ConstraintScanner run at `firefly:cache` time and + * compile their findings into manifests, and ArgumentResolver dispatches off those manifests without ever + * touching Reflection, because per-request reflection is the single largest avoidable cost in a PHP + * dispatcher and because reflecting per request means a controller edit changes behaviour without a rebuild. + * NEITHER concern applies to this class. It runs in exactly two situations: inside the short-lived + * `firefly:openapi` process, which exists to produce a file; and on the first hit to the spec route, whose + * result OpenApiGenerator memoises for the life of the process (including under Octane, where the worker + * outlives thousands of requests). It is never on the path of an application request, and no application + * request's behaviour depends on it — the worst a mistake here can do is produce a wrong description. + * + * The alternative was to teach RouteScanner to compile summaries and descriptions into every RouteDescriptor. + * That was rejected on the same grounds DtoSchemaFactory rejects it for property types: it would grow the + * compiled route manifest of EVERY application — parsed and hydrated on every cold boot — to carry prose that + * only one optional package ever reads. + * + * CACHED PER INSTANCE, keyed by class and by class::method. A document with forty routes across eight + * controllers would otherwise re-reflect and re-parse the same class docblock forty times; the generator + * builds one of these per document, so the cache lives exactly as long as it is useful. + */ +final class ApiDocs +{ + /** @var array<string, OperationDoc> */ + private array $operations = []; + + /** @var array<string, TagDoc> */ + private array $tags = []; + + /** @var array<string, bool> */ + private array $ignored = []; + + /** + * Whether this route is documented at all. Checked by OpenApiGenerator alongside the config-driven path + * exclusions, because both answer the same question and a route that fails either one must never reach + * the operation factory — a half-built operation for a hidden route would still register its body DTO as + * a component and leave an orphan schema in the document. + */ + public function ignores(RouteDescriptor $route): bool + { + $key = $route->controllerClass.'::'.$route->methodName; + + return $this->ignored[$key] ??= $this->reflectIgnored($route); + } + + public function operation(RouteDescriptor $route): OperationDoc + { + return $this->operations[$route->controllerClass.'::'.$route->methodName] ??= $this->merge($route); + } + + /** + * The tag one controller's operations belong to. Keyed by class rather than by route so that every + * operation on a controller resolves to the identical TagDoc instance, which is what guarantees the + * operation-level `tags` member and the root `tags` entry can never disagree about spelling. + */ + public function tag(string $controllerClass): TagDoc + { + return $this->tags[$controllerClass] ??= $this->reflectTag($controllerClass); + } + + /** + * The tag names this operation is filed under — the #[ApiOperation(tags:)] override when there is one, + * and the controller's single derived tag otherwise. + * + * @return list<string> + */ + public function tagNames(RouteDescriptor $route): array + { + return $this->operation($route)->tags ?? [$this->tag($route->controllerClass)->name]; + } + + private function merge(RouteDescriptor $route): OperationDoc + { + $method = $this->method($route); + $doc = DocBlock::parse($method?->getDocComment()); + $attribute = $this->attribute($method, ApiOperation::class); + + return new OperationDoc( + summary: $this->first( + $attribute->summary ?? '', + $doc->summary, + $this->humanise($route->methodName), + ), + description: $this->first($attribute->description ?? '', $doc->description), + operationId: $attribute?->operationId, + deprecated: $attribute->deprecated ?? $doc->has('deprecated'), + tags: $this->overriddenTags($attribute), + responses: $this->repeated($method, ApiResponse::class), + parameters: $this->parameters($method), + ); + } + + private function reflectTag(string $controllerClass): TagDoc + { + if (! class_exists($controllerClass)) { + return new TagDoc($this->shortName($controllerClass), ''); + } + + $class = new ReflectionClass($controllerClass); + $attributes = $class->getAttributes(ApiTag::class); + $attribute = $attributes === [] ? null : $attributes[0]->newInstance(); + $doc = DocBlock::parse($class->getDocComment()); + + return new TagDoc( + name: $this->first($attribute->name ?? '', $this->shortName($controllerClass)), + // The class docblock describes the CLASS, and for a controller that is the same subject as the + // tag — "the wallet endpoints" — which is why it is a usable fallback at all. Summary and + // remaining paragraphs are joined rather than only the summary taken, because a tag description + // is rendered as a block in every viewer and has room for the whole thing. + description: $this->first($attribute->description ?? '', $doc->prose()), + ); + } + + private function reflectIgnored(RouteDescriptor $route): bool + { + if (! class_exists($route->controllerClass)) { + return false; + } + + if ((new ReflectionClass($route->controllerClass))->getAttributes(ApiIgnore::class) !== []) { + return true; + } + + $method = $this->method($route); + + return $method !== null && $method->getAttributes(ApiIgnore::class) !== []; + } + + /** + * The tag list an #[ApiOperation] imposed, or null when it stated none. Null rather than an empty list + * because the two mean opposite things downstream: null keeps the controller-derived tag, whereas an + * empty list would file the operation under no tag at all and hide it from every viewer's navigation. + * + * @return list<string>|null + */ + private function overriddenTags(?ApiOperation $attribute): ?array + { + $tags = array_values(array_filter( + $attribute->tags ?? [], + static fn (string $tag): bool => trim($tag) !== '', + )); + + return $tags === [] ? null : $tags; + } + + /** + * `#[ApiParameter]` instances keyed by the wire name they claim. A repeated name keeps the FIRST + * declaration, matching every other first-writer-wins rule in this package (MapperState, SchemaRegistry) + * rather than inventing a second convention for one attribute. + * + * @return array<string, ApiParameter> + */ + private function parameters(?ReflectionMethod $method): array + { + $parameters = []; + + foreach ($this->repeated($method, ApiParameter::class) as $parameter) { + $parameters[$parameter->name] ??= $parameter; + } + + return $parameters; + } + + /** + * @template T of object + * + * @param class-string<T> $attribute + * @return T|null + */ + private function attribute(?ReflectionMethod $method, string $attribute): ?object + { + $found = $method?->getAttributes($attribute) ?? []; + + return $found === [] ? null : $found[0]->newInstance(); + } + + /** + * @template T of object + * + * @param class-string<T> $attribute + * @return list<T> + */ + private function repeated(?ReflectionMethod $method, string $attribute): array + { + $instances = []; + + foreach ($method?->getAttributes($attribute) ?? [] as $found) { + $instances[] = $found->newInstance(); + } + + return $instances; + } + + private function method(RouteDescriptor $route): ?ReflectionMethod + { + if (! class_exists($route->controllerClass) || ! method_exists($route->controllerClass, $route->methodName)) { + return null; + } + + return new ReflectionMethod($route->controllerClass, $route->methodName); + } + + /** + * The first non-empty candidate, which is the precedence rule in one expression. + */ + private function first(string ...$candidates): string + { + foreach ($candidates as $candidate) { + if (trim($candidate) !== '') { + return trim($candidate); + } + } + + return ''; + } + + /** + * The tag a viewer groups this controller's operations under: the class's short name with a trailing + * "Controller" removed, so `Lumen\Web\WalletController` reads as "Wallet". + */ + private function shortName(string $class): string + { + $short = str_contains($class, '\\') ? substr($class, (int) strrpos($class, '\\') + 1) : $class; + + return str_ends_with($short, 'Controller') && $short !== 'Controller' + ? substr($short, 0, -strlen('Controller')) + : $short; + } + + /** + * `getBalance` reads as "Get balance". The last-resort summary, used only when a method carries no + * #[ApiOperation] AND no prose in its docblock — a method name is the one human-authored label every + * route is guaranteed to have (a #[Mapping]'s name is a Laravel route name, not prose). + */ + private function humanise(string $method): string + { + $words = preg_split('/(?=[A-Z])/', $method); + $sentence = strtolower(trim(implode(' ', $words === false ? [$method] : $words))); + + return $sentence === '' ? $method : ucfirst($sentence); + } +} diff --git a/packages/openapi/src/Generator/DocBlock.php b/packages/openapi/src/Generator/DocBlock.php new file mode 100644 index 0000000..2626073 --- /dev/null +++ b/packages/openapi/src/Generator/DocBlock.php @@ -0,0 +1,292 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Generator; + +/** + * A PHPDoc comment, parsed into the things a generated document can actually use: the prose BODY, that same + * body cut into a SUMMARY and a DESCRIPTION, and the `@tag` lines. + * + * WHY THIS EXISTS AT ALL. Before it, an operation's summary was `ucfirst()` of the humanised method name and + * its description was the literal string "Handled by App\Web\OrderController::show()." — a placeholder + * masquerading as documentation, and one that made every generated client's docblock and every viewer's + * operation panel worse than blank, because a reader had to notice it said nothing before they could ignore + * it. Meanwhile the prose that SHOULD have been there was sitting three lines above the method, already + * written, in the docblock. Nothing needed authoring; something needed reading. + * + * WHY NOT phpdocumentor/reflection-docblock. It is the obvious dependency and it was rejected: it drags + * webmozart/assert, a full type-expression parser and phpstan/phpdoc-parser behind it, all so this package + * can read a summary, a paragraph, `@param` and `@deprecated`. A framework package that costs an application + * four transitive dependencies to describe its own API is a bad trade, and the surface actually used here is + * small enough to state exactly — which is what the rest of this class does. + * + * WHERE THE SUMMARY ENDS. phpDocumentor's own rule is "at the first blank line, or at a full stop followed by + * a newline", which means a wrapped two-sentence paragraph becomes one enormous summary. That reads badly in + * the one place a summary is used — a one-line entry in an operation list — so the rule here is the stricter + * one people expect: the summary is the FIRST SENTENCE, and the rest of its paragraph flows into the + * description. The known cost is an abbreviation: "Cancels an order, e.g. a draft one." would split at + * "e.g." Internal-dot abbreviations (`e.g.`, `i.e.`, `cf.`) are therefore skipped over explicitly; a + * trailing-dot one ("etc.", "vs.") is not, because at the end of a clause it is nearly always a real + * sentence end and guessing wrong there costs more than it saves. + * + * TAG VALUES ARE RAW. Nothing here interprets a type expression, resolves a class alias, or validates a + * `@param` against the signature. The only tag consumers are params() (member descriptions) and a presence + * check on `@deprecated`; anything more ambitious belongs in the attribute family, where the author has + * said what they mean rather than had it inferred from a comment. + */ +final readonly class DocBlock +{ + /** + * $body is the WHOLE prose block, paragraph structure intact; $summary and $description are that same + * text cut in two at the first sentence. Both spellings are kept because the document needs both and + * neither reconstructs the other: an Operation Object has two slots and wants the cut, while a tag + * description, a schema description and a property description each have ONE slot and want the text as + * written. Rejoining summary and description with a paragraph break to serve the second case looked + * equivalent and was not — it turned "Holds stock. Exactly one line per request." into two paragraphs, + * which is a change to a sentence nobody wrote in two parts. + * + * @param array<string, list<string>> $tags tag name (without `@`) => each occurrence's raw text + */ + private function __construct( + public string $body, + public string $summary, + public string $description, + public array $tags, + ) {} + + public static function empty(): self + { + return new self('', '', '', []); + } + + /** + * `false` is accepted because that is what every Reflection*::getDocComment() returns when there is no + * comment, and forcing sixteen call sites to normalise it first would be sixteen chances to forget. + */ + public static function parse(string|false|null $comment): self + { + if (! is_string($comment) || trim($comment) === '') { + return self::empty(); + } + + [$raw, $tags] = self::split(self::lines($comment)); + + $paragraphs = self::paragraphs($raw); + [$summary, $description] = self::sentence($paragraphs); + + return new self(implode("\n\n", $paragraphs), $summary, $description, $tags); + } + + /** + * True when the comment carried nothing this generator can use. Distinct from "there was no comment": + * a docblock holding only `@return array<string, mixed>` is present but says nothing about the operation, + * and must fall through to the derived summary exactly as an absent one does. + */ + public function isEmpty(): bool + { + return $this->body === ''; + } + + public function has(string $tag): bool + { + return isset($this->tags[$tag]); + } + + /** + * Summary and description as ONE block, which is what every field that renders as a paragraph wants — a + * tag description, a schema description. The sentence split exists so that an OPERATION can show a + * one-line summary in a list and the rest in a panel; nothing else in the document has two such slots, and + * dropping the remaining paragraphs there would silently discard most of what the author wrote. + */ + public function prose(): string + { + return $this->body; + } + + /** + * `@param` lines as member name => description, dropping the type expression and any line with no prose. + * + * The name is found by scanning for the first `$identifier` rather than by splitting on whitespace, + * because a type expression contains spaces of its own (`array<string, mixed>`, `int|null`) and a + * positional split would read the second half of the type as the variable name. A variadic `...$items` + * falls out of the same scan. + * + * @return array<string, string> + */ + public function params(): array + { + $params = []; + + foreach ($this->tags['param'] ?? [] as $line) { + if (preg_match('/\$([A-Za-z_]\w*)\s*(.*)$/s', $line, $matches) !== 1) { + continue; + } + + $description = self::flatten($matches[2]); + if ($description !== '') { + $params[$matches[1]] = $description; + } + } + + return $params; + } + + /** + * Strips the comment syntax: the `/**` opener, the `*\/` closer, and the leading `*` (plus at most ONE + * following space) that decorates each line. Exactly one space is consumed so that an indented block + * inside the comment — a code sample, a bullet list — keeps its relative indentation instead of being + * flattened against the left margin. + * + * The opener takes its trailing spaces with it, which matters for the ONE-LINE form — a whole docblock + * written between one `/**` and one closer, holding nothing but a return tag. Without that, the tag no + * longer starts at column zero, split() (which anchors a tag at the start of its line) does not recognise + * it, and the entire tag is read as prose and published as the operation's summary. It was, for exactly + * as long as it took to look at the generated file. + * + * @return list<string> + */ + private static function lines(string $comment): array + { + $inner = preg_replace('#^\s*/\*\*?[ \t]*|\s*\*/\s*$#s', '', $comment) ?? $comment; + $lines = preg_split('/\R/', $inner); + + return array_map( + static fn (string $line): string => (string) preg_replace('/^\s*\*[ ]?/', '', $line), + $lines === false ? [$inner] : $lines, + ); + } + + /** + * Separates the prose body from the `@tag` block. + * + * A tag runs until the next tag or the end of the comment, so a wrapped `@param` description survives + * whole. Continuation lines are kept as-is and joined with a newline; flatten() collapses them later, + * at the one place a single-line value is actually needed. + * + * @param list<string> $lines + * @return array{string, array<string, list<string>>} + */ + private static function split(array $lines): array + { + $body = []; + $tags = []; + $current = null; + + foreach ($lines as $line) { + if (preg_match('/^@([A-Za-z][\w-]*)\s*(.*)$/', $line, $matches) === 1) { + $current = $matches[1]; + $tags[$current][] = $matches[2]; + + continue; + } + + if ($current === null) { + $body[] = $line; + + continue; + } + + $last = count($tags[$current]) - 1; + $tags[$current][$last] = rtrim($tags[$current][$last]."\n".$line); + } + + return [trim(implode("\n", $body)), $tags]; + } + + /** + * The prose body as a list of paragraphs, each collapsed onto one line. + * + * Source comments are hard-wrapped at whatever column the author's editor used, and those breaks are an + * artefact of the file rather than of the prose — carrying them into a JSON string would put ragged + * newlines inside every description a viewer renders. Blank lines ARE meaningful and survive as the + * boundaries between the entries returned here. + * + * @return list<string> + */ + private static function paragraphs(string $body): array + { + if ($body === '') { + return []; + } + + $split = preg_split('/\R[ \t]*\R/', $body); + + return array_values(array_filter( + array_map(self::flatten(...), $split === false ? [$body] : $split), + static fn (string $paragraph): bool => $paragraph !== '', + )); + } + + /** + * Splits the prose body into first sentence and remainder — see the class docblock for why the boundary + * is a sentence rather than phpDocumentor's blank line. + * + * A paragraph break always wins over a sentence break when it comes first, so a deliberately terse + * summary line followed by a blank line is never re-joined with the paragraph under it. + * + * @param list<string> $paragraphs + * @return array{string, string} + */ + private static function sentence(array $paragraphs): array + { + if ($paragraphs === []) { + return ['', '']; + } + + $first = $paragraphs[0]; + $rest = array_slice($paragraphs, 1); + + $end = self::sentenceEnd($first); + if ($end !== null) { + $tail = trim(substr($first, $end)); + $first = trim(substr($first, 0, $end)); + + if ($tail !== '') { + array_unshift($rest, $tail); + } + } + + return [$first, implode("\n\n", $rest)]; + } + + /** + * The offset just past the first sentence-terminating `.`/`!`/`?`, or null when the paragraph is a single + * sentence (or none at all). + * + * A terminator only counts when whitespace or the end of the paragraph follows it, which is what keeps + * `3.14`, `v1.2` and `Firefly\OpenApi\Schema` from ending the sentence. Candidates whose preceding word + * is an internal-dot abbreviation are skipped and scanning continues — see the class docblock. + */ + private static function sentenceEnd(string $paragraph): ?int + { + if (preg_match_all('/[.!?](?=\s|$)/', $paragraph, $matches, PREG_OFFSET_CAPTURE) === false) { + return null; + } + + foreach ($matches[0] as [, $offset]) { + $end = $offset + 1; + + if ($end >= strlen($paragraph)) { + return null; + } + + if (preg_match('/(?:^|[\s(\[])(?:\p{L}\.)+\p{L}$/u', substr($paragraph, 0, $offset)) === 1) { + continue; + } + + return $end; + } + + return null; + } + + /** + * Collapses one wrapped paragraph onto a single line — see paragraphs() for why the file's line breaks + * must not survive into the document. + */ + private static function flatten(string $text): string + { + return trim((string) preg_replace('/\s+/', ' ', $text)); + } +} diff --git a/packages/openapi/src/Generator/DocumentInfo.php b/packages/openapi/src/Generator/DocumentInfo.php new file mode 100644 index 0000000..efa7119 --- /dev/null +++ b/packages/openapi/src/Generator/DocumentInfo.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Generator; + +use Firefly\Config\Config; + +/** + * The optional half of the Info Object — the members OpenApiProperties does not already carry. + * + * `title`, `version` and `description` were there from the first release because a document is invalid + * without the first two and unreadable without the third. The four members here are the rest of what OpenAPI + * 3.1 actually defines on the Info Object, and NOTHING BEYOND IT: the specification's list is + * title, summary, description, termsOfService, contact, license, version — full stop. There is no `author`, + * no `repository`, no `logo` (that is ReDoc's `x-logo` extension, not a spec member), and inventing one here + * would emit a document that a strict 3.1 validator rejects on a field nobody asked for. + * + * `summary` is genuinely NEW in 3.1 — 3.0 had no such member — and it is not a shorter `description`: it is + * the one-line blurb a catalogue or an API portal shows next to the title, where a CommonMark description + * would be unusable. + * + * THE LICENSE OBJECT HAS A CONSTRAINT WORTH SPELLING OUT. `name` is REQUIRED, and `identifier` (an SPDX + * expression, also new in 3.1) and `url` are MUTUALLY EXCLUSIVE — the spec says an implementation must use + * one or the other, never both. Configuring both is a mistake nobody would ever see at runtime, so it is + * resolved here rather than passed through: the SPDX identifier wins, because it is the machine-readable, + * unambiguous statement of the same fact a URL only points at. A license with no name is dropped entirely, + * since a License Object without one is invalid and half a license is worse than none. + * + * The Contact Object has no required member at all, so it is emitted whenever any of name/url/email is set + * and omitted when none is. + */ +final readonly class DocumentInfo +{ + /** + * @param array<string, string> $contact any of `name`, `url`, `email`; absent keys are simply not set + * @param array<string, string> $license any of `name`, `identifier`, `url` — see the class docblock + * for why the last two cannot both survive + */ + public function __construct( + public string $summary = '', + public string $termsOfService = '', + public array $contact = [], + public array $license = [], + ) {} + + /** + * Reads `firefly.openapi.summary`, `.terms-of-service`, `.contact.*` and `.license.*`, in the kebab-case + * key style the rest of this package's configuration uses (`include-html`, `viewer.cdn`). + * + * Every key is optional and defaults to the empty string, so an application that has never heard of these + * options produces exactly the document it produced before — an empty value is treated as "not + * configured", never emitted as an empty member. + */ + public static function fromConfig(Config $config): self + { + return new self( + summary: trim($config->string('firefly.openapi.summary', '')), + termsOfService: trim($config->string('firefly.openapi.terms-of-service', '')), + contact: self::members($config, 'contact', ['name', 'url', 'email']), + license: self::members($config, 'license', ['name', 'identifier', 'url']), + ); + } + + /** + * Layers these members onto an Info Object already carrying title/version/description, in the field order + * the specification itself lists them in. + * + * The order is rebuilt rather than appended to because JSON object key order is the only thing a reader + * of the generated file sees, and `title, version, description, summary` reads as an afterthought where + * `title, summary, description, ..., version` reads as the spec's own table. Nothing consumes the order + * semantically; a human diffing a committed openapi.json does. + * + * @param array<string, mixed> $info + * @return array<string, mixed> + */ + public function applyTo(array $info): array + { + $ordered = []; + + foreach (['title', 'summary', 'description', 'termsOfService', 'contact', 'license', 'version'] as $member) { + $value = match ($member) { + 'summary' => $this->summary === '' ? null : $this->summary, + 'termsOfService' => $this->termsOfService === '' ? null : $this->termsOfService, + 'contact' => $this->contact === [] ? null : $this->contact, + 'license' => $this->licenseObject(), + default => $info[$member] ?? null, + }; + + if ($value !== null) { + $ordered[$member] = $value; + } + } + + // Anything an override bean put into `info` that is not a spec member survives, after the spec ones. + // Specification extensions (`x-…`) are explicitly permitted on the Info Object, and silently eating + // one would be a surprising thing for a reordering pass to do. + foreach ($info as $member => $value) { + $ordered[$member] ??= $value; + } + + return $ordered; + } + + /** + * @return array{name: string, identifier?: string, url?: string}|null + */ + private function licenseObject(): ?array + { + $name = $this->license['name'] ?? ''; + if ($name === '') { + return null; + } + + $identifier = $this->license['identifier'] ?? ''; + if ($identifier !== '') { + return ['name' => $name, 'identifier' => $identifier]; + } + + $url = $this->license['url'] ?? ''; + + return $url === '' ? ['name' => $name] : ['name' => $name, 'url' => $url]; + } + + /** + * @param list<string> $members + * @return array<string, string> + */ + private static function members(Config $config, string $object, array $members): array + { + $values = []; + + foreach ($members as $member) { + $value = trim($config->string('firefly.openapi.'.$object.'.'.$member, '')); + + if ($value !== '') { + $values[$member] = $value; + } + } + + return $values; + } +} diff --git a/packages/openapi/src/Generator/OpenApiGenerator.php b/packages/openapi/src/Generator/OpenApiGenerator.php index 674ba40..7a5f99e 100644 --- a/packages/openapi/src/Generator/OpenApiGenerator.php +++ b/packages/openapi/src/Generator/OpenApiGenerator.php @@ -12,14 +12,21 @@ use stdClass; /** - * The whole point of the package: an OpenAPI 3.1 document assembled from manifests the framework ALREADY - * holds in memory, with no annotation dialect of its own to learn and nothing to keep in sync by hand. + * The whole point of the package: an OpenAPI 3.1 document assembled from artifacts the application ALREADY + * has, with nothing to keep in sync by hand. * * RouteManifest supplies the paths, verbs, statuses, route names and the per-parameter binding plan; * ConstraintManifest (reached through DtoSchemaFactory) supplies the request-body schemas and their `required` - * lists; packages/kernel's ErrorResponse supplies the error component. A LaraFly app therefore gets a typed - * client for free the moment it installs this package, and the document cannot drift from the server, because - * every fact in it is read from the same compiled artifact the dispatcher reads. + * lists; packages/kernel's ErrorResponse supplies the error component; and the controllers' own PHPDoc + * supplies the prose (see ApiDocs). A LaraFly app therefore gets a typed client for free the moment it + * installs this package, and the document cannot drift from the server, because every fact in it is read from + * the same compiled artifact the dispatcher reads. + * + * THERE IS AN ANNOTATION DIALECT, and it is deliberately optional. Firefly\OpenApi\Attributes exists for the + * things no manifest and no docblock can state — a hand-picked operationId, a 404 that only the controller's + * body knows about, an example value — and for nothing else. Every one of its members falls through to the + * docblock and then to a derivation when omitted, so an application that adopts none of it still gets a + * document written in its own words rather than in placeholders. * * ORDERING IS DETERMINISTIC AND THAT IS DELIBERATE. Paths are sorted, verbs within a path are sorted into the * canonical OpenAPI order, and SchemaRegistry sorts components by name. Route discovery order depends on @@ -45,10 +52,17 @@ final class OpenApiGenerator /** @var array<string, mixed>|null */ private ?array $document = null; + /** + * $info carries the OPTIONAL Info Object members (summary, termsOfService, contact, license) that + * OpenApiProperties does not. It is last and nullable so every existing three-argument construction — + * OpenApiAutoConfiguration's #[Bean], an application's own override bean, the fixtures — keeps compiling + * and keeps producing exactly the document it produced before. + */ public function __construct( private readonly RouteManifest $routes, private readonly OpenApiProperties $properties, private readonly OperationFactory $operations, + private readonly ?DocumentInfo $info = null, ) {} /** @@ -87,18 +101,39 @@ private function build(): array $registry = new SchemaRegistry; $registry->put(ProblemSchema::NAME, ProblemSchema::schema()); + // Per-DOCUMENT, like the registry beside it: ApiDocs caches one reflection + docblock parse per class + // and per method, which is worth a great deal across forty routes on eight controllers and worth + // nothing once the document exists. Building it here also means the reflection it performs cannot + // outlive generation — see that class for why reflecting at all is legitimate in this package. + $docs = new ApiDocs; + $paths = []; $operationIds = []; + $used = []; + $described = []; foreach ($this->routes->all() as $route) { - if ($this->excluded($route)) { + if ($this->excluded($route, $docs)) { continue; } $path = $this->template($route->path); $verb = strtolower($route->httpMethod); - $paths[$path][$verb] = $this->operations->create($route, $this->operationId($route, $operationIds), $registry); + $paths[$path][$verb] = $this->operations->create($route, $this->operationId($route, $operationIds, $docs), $registry, $docs); + + foreach ($docs->tagNames($route) as $tag) { + $used[$tag] = true; + } + + // Collected from SURVIVING routes only, which is what keeps an excluded controller from + // contributing prose about a group nothing in the document belongs to — and, where two + // controllers share a tag name, keeps the description that wins from depending on whether the + // loser happened to be hidden. + $tag = $docs->tag($route->controllerClass); + if ($tag->description !== '') { + $described[$tag->name] ??= $tag->description; + } } ksort($paths); @@ -121,9 +156,42 @@ private function build(): array 'responses' => [ProblemSchema::RESPONSE_NAME => ProblemSchema::response()], ]; + $tags = $this->tags($described, $used); + if ($tags !== []) { + $document['tags'] = $tags; + } + return $document; } + /** + * The document's root `tags` array — the ONLY place OpenAPI lets a tag carry a description, because an + * Operation Object's own `tags` member is a bare list of strings. + * + * Only tags that actually have a description are listed. A root entry is `{name, description}` and one + * with nothing but a name restates what every operation already says, so emitting those would add a line + * per controller to every generated file to convey nothing. A described tag that no surviving operation + * references is skipped for a sharper reason: an #[ApiIgnore]d controller must not leave its tag prose + * behind as the one trace that it exists. + * + * Sorted by name, for the same reason paths and components are — an unsorted array reshuffles itself + * with filesystem scan order and turns every regeneration into an unreviewable diff. + * + * @param array<string, string> $described tag name => its description, from the documented routes + * @param array<string, bool> $used tag names at least one documented operation is filed under + * @return list<array{name: string, description: string}> + */ + private function tags(array $described, array $used): array + { + $tags = array_intersect_key($described, $used); + ksort($tags); + + return array_map( + static fn (string $name): array => ['name' => $name, 'description' => $tags[$name]], + array_keys($tags), + ); + } + /** * @return array<string, mixed> */ @@ -135,12 +203,12 @@ private function info(): array $info['description'] = $this->properties->description; } - return $info; + return $this->info?->applyTo($info) ?? $info; } /** - * A route is left out of the document when its path is excluded by configuration, or when it was - * declared by the HTML stereotype. + * A route is left out of the document when it carries #[ApiIgnore] (on its class or on itself), when its + * path is excluded by configuration, or when it was declared by the HTML stereotype. * * #[Controller] routes render web pages. They are part of the application's HTTP surface, but they are * not JSON API operations, and describing one as `application/json` would have a generator emit a typed @@ -148,12 +216,16 @@ private function info(): array * `firefly.openapi.include-html` to document them anyway; the operation is then produced with * `text/html` content rather than a JSON schema. */ - private function excluded(RouteDescriptor $route): bool + private function excluded(RouteDescriptor $route, ApiDocs $docs): bool { if ($route->html && ! $this->properties->includeHtml) { return true; } + if ($docs->ignores($route)) { + return true; + } + foreach ($this->properties->excludePathPrefixes as $prefix) { if (str_starts_with($route->path, $prefix)) { return true; @@ -177,9 +249,12 @@ private function template(string $path): string /** * @param array<string, int> $used operationId => how many times it has been claimed */ - private function operationId(RouteDescriptor $route, array &$used): string + private function operationId(RouteDescriptor $route, array &$used, ApiDocs $docs): string { - $candidate = $route->name ?? $this->derivedId($route); + // Attribute beats route name beats derivation — the same precedence ApiDocs applies to prose, applied + // here rather than in OperationFactory because the UNIQUENESS ledger lives here. An #[ApiOperation] + // may choose the id; it does not get to hand two operations the same one. + $candidate = $docs->operation($route)->operationId ?? $route->name ?? $this->derivedId($route); // operationId is REQUIRED to be unique across the whole document, and a duplicate is the one flaw // that makes most client generators abort rather than degrade. Two routes can legitimately collide diff --git a/packages/openapi/src/Generator/OperationDoc.php b/packages/openapi/src/Generator/OperationDoc.php new file mode 100644 index 0000000..307c136 --- /dev/null +++ b/packages/openapi/src/Generator/OperationDoc.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Generator; + +use Firefly\OpenApi\Attributes\ApiParameter; +use Firefly\OpenApi\Attributes\ApiResponse; + +/** + * Everything the three documentation sources — #[ApiOperation] and friends, the method docblock, and the + * derivation from the method name — agreed on for ONE operation, after ApiDocs has merged them. + * + * The merge happens once, here, rather than at each use site, because the precedence rule (attribute beats + * docblock beats derived) is only trustworthy if it is applied in exactly one place. OperationFactory and + * OpenApiGenerator both need pieces of this — the factory writes the summary and responses, the generator + * needs the operationId candidate and the tag list before it can dedupe ids and build the root `tags` array + * — and having each re-run the precedence would be two chances for them to disagree about the same route. + * + * `summary` is never empty: it falls back to the humanised method name, which is the one human-authored + * label every route carries. `description` IS allowed to be empty, and an empty one is omitted from the + * document entirely — the placeholder it replaced ("Handled by Foo::bar().") was worse than silence. + */ +final readonly class OperationDoc +{ + /** + * @param list<string>|null $tags null when nothing overrode the controller-derived tag + * @param list<ApiResponse> $responses extra responses, in declaration order + * @param array<string, ApiParameter> $parameters keyed by the WIRE name the attribute claimed + */ + public function __construct( + public string $summary, + public string $description, + public ?string $operationId, + public bool $deprecated, + public ?array $tags, + public array $responses, + public array $parameters, + ) {} +} diff --git a/packages/openapi/src/Generator/OperationFactory.php b/packages/openapi/src/Generator/OperationFactory.php index 1021c16..bc9d017 100644 --- a/packages/openapi/src/Generator/OperationFactory.php +++ b/packages/openapi/src/Generator/OperationFactory.php @@ -4,6 +4,8 @@ namespace Firefly\OpenApi\Generator; +use Firefly\OpenApi\Attributes\ApiParameter; +use Firefly\OpenApi\Attributes\ApiResponse; use Firefly\OpenApi\Schema\DtoSchemaFactory; use Firefly\OpenApi\Schema\ProblemSchema; use Firefly\OpenApi\Schema\SchemaRegistry; @@ -15,15 +17,22 @@ /** * Turns one compiled RouteDescriptor into one OpenAPI Operation Object. * - * Everything here comes out of the descriptor the framework already holds: the verb and path, the default - * status the #[Mapping] declared, the optional route name, and the binding plan RouteScanner reflected out of - * the controller method's parameters. The binding `kind` is what makes the mapping unambiguous, because it is - * the SAME discriminator ArgumentResolver dispatches on at request time — `path`/`query`/`header` become + * The MECHANICS come out of the descriptor the framework already holds: the verb and path, the default status + * the #[Mapping] declared, the optional route name, and the binding plan RouteScanner reflected out of the + * controller method's parameters. The binding `kind` is what makes the mapping unambiguous, because it is the + * SAME discriminator ArgumentResolver dispatches on at request time — `path`/`query`/`header` become * Parameter Objects, `body` becomes the Request Body Object, `file` becomes a multipart part, and `service` * is a container-injected collaborator that is not part of the HTTP contract at all and must not leak into * the document. Deriving the parameter list from the method signature independently would have to re-decide * every one of those cases and could disagree with the dispatcher; reading the plan cannot. * + * The PROSE comes from ApiDocs, which merges #[ApiOperation]/#[ApiResponse]/#[ApiParameter] over the method's + * docblock over a derivation from the method name — in that order, decided there and not re-decided here. The + * summary this factory writes used to be `ucfirst()` of the humanised method name and the description used to + * be the literal string "Handled by App\Web\OrderController::show().", which was a placeholder wearing + * documentation's clothes: it filled the slot a viewer renders, so nothing looked missing, while telling a + * reader strictly less than an empty string would have. An absent description is now absent. + * * @phpstan-import-type Binding from RouteDescriptor */ final class OperationFactory @@ -31,10 +40,18 @@ final class OperationFactory public function __construct(private readonly DtoSchemaFactory $schemas) {} /** + * $docs is threaded in rather than injected, for the same reason SchemaRegistry is: both are per-DOCUMENT + * state that OpenApiGenerator creates inside build() and discards with it. Holding either as a + * constructor dependency of a container SINGLETON would give a cache the lifetime of the process while + * the thing it caches for lives one generation, and would leave this factory's #[Bean] signature — the + * documented override point — carrying a collaborator no application would ever want to replace. + * * @return array<string, mixed> */ - public function create(RouteDescriptor $route, string $operationId, SchemaRegistry $registry): array + public function create(RouteDescriptor $route, string $operationId, SchemaRegistry $registry, ApiDocs $docs): array { + $doc = $docs->operation($route); + $parameters = []; $body = null; $files = []; @@ -46,15 +63,15 @@ public function create(RouteDescriptor $route, string $operationId, SchemaRegist switch ($binding['kind']) { case 'path': - $parameters[] = $this->parameter($binding, 'path', true); + $parameters[] = $this->parameter($binding, 'path', true, $doc->parameters[$binding['key']] ?? null); $rejectable = $rejectable || $this->coercible($binding); break; case 'query': - $parameters[] = $this->parameter($binding, 'query', $binding['required']); + $parameters[] = $this->parameter($binding, 'query', $binding['required'], $doc->parameters[$binding['key']] ?? null); $rejectable = $rejectable || $binding['required'] || $this->coercible($binding); break; case 'header': - $parameters[] = $this->parameter($binding, 'header', $binding['required']); + $parameters[] = $this->parameter($binding, 'header', $binding['required'], $doc->parameters[$binding['key']] ?? null); $rejectable = $rejectable || $binding['required'] || $this->coercible($binding); break; case 'file': @@ -68,12 +85,19 @@ public function create(RouteDescriptor $route, string $operationId, SchemaRegist } } - $operation = [ - 'operationId' => $operationId, - 'summary' => $this->summary($route), - 'description' => 'Handled by '.$route->controllerClass.'::'.$route->methodName.'().', - 'tags' => [$this->tag($route)], - ]; + $operation = ['operationId' => $operationId, 'summary' => $doc->summary]; + + if ($doc->description !== '') { + $operation['description'] = $doc->description; + } + + // Emitted only when true. `deprecated` defaults to false in the specification, so writing it out on + // every live operation would add a line per operation to every generated file to say nothing. + if ($doc->deprecated) { + $operation['deprecated'] = true; + } + + $operation['tags'] = $docs->tagNames($route); if ($parameters !== []) { $operation['parameters'] = $parameters; @@ -85,7 +109,7 @@ public function create(RouteDescriptor $route, string $operationId, SchemaRegist $operation['requestBody'] = $this->multipartBody($files); } - $operation['responses'] = $this->responses($route, $rejectable, $validated); + $operation['responses'] = $this->responses($route, $rejectable, $validated, $doc, $registry); return $operation; } @@ -100,12 +124,18 @@ public function create(RouteDescriptor $route, string $operationId, SchemaRegist * `required` is passed in rather than read off the binding because a PATH parameter is required by the * OpenAPI specification itself (`required: false` is invalid there), independently of what the binding * plan happens to say — RouteScanner always plans one as required, and the caller reasserts it so the - * document is valid by construction rather than by that coincidence. + * document is valid by construction rather than by that coincidence. An #[ApiParameter(required:)] is + * honoured for query and header parameters and DROPPED for a path one, for exactly the same reason: an + * author override may reshape the document but may not make it invalid. + * + * The override deliberately does NOT feed back into the 400 derivation below. That derivation states what + * the SERVER does — ArgumentResolver rejects a missing required parameter before the controller runs — + * and an attribute cannot change the server's behaviour by describing it differently. * * @param Binding $binding * @return array<string, mixed> */ - private function parameter(array $binding, string $in, bool $required): array + private function parameter(array $binding, string $in, bool $required, ?ApiParameter $enrichment): array { $schema = TypeSchema::for($binding['type']) ?? ['type' => 'string']; @@ -113,12 +143,23 @@ private function parameter(array $binding, string $in, bool $required): array $schema['default'] = $binding['default']; } - return [ - 'name' => $binding['key'], - 'in' => $in, - 'required' => $required, - 'schema' => $schema, - ]; + $parameter = ['name' => $binding['key'], 'in' => $in]; + + if ($enrichment !== null && trim($enrichment->description) !== '') { + $parameter['description'] = trim($enrichment->description); + } + + $parameter['required'] = $in === 'path' ? true : ($enrichment->required ?? $required); + $parameter['schema'] = $schema; + + // The Parameter Object's own `example` member, which 3.1 kept. Its SCHEMA-level namesake is the one + // 3.1 deprecated in favour of JSON Schema's `examples` array — see ApiProperty, which is on that side + // of the line and spells it the other way round. + if ($enrichment !== null && $enrichment->example !== null) { + $parameter['example'] = $enrichment->example; + } + + return $parameter; } /** @@ -172,7 +213,7 @@ private function multipartBody(array $files): array /** * The success response plus every error status this operation can ACTUALLY produce, all pointing at the - * one shared problem component. + * one shared problem component, plus whatever #[ApiResponse] declared on top. * * The error set is derived, not guessed. `400` appears exactly when the operation has something * ArgumentResolver can reject BEFORE the controller runs — a body to decode and bind (MALFORMED_BODY / @@ -190,14 +231,19 @@ private function multipartBody(array $files): array * route manifest without reading the controller's body, and which all render through the same * ProblemDetailsRenderer anyway. * + * #[ApiResponse] is where an author states the half that is provably underivable — WHICH of those handler + * statuses are real and what each one means. It is applied LAST and overwrites, so putting real prose on + * the success status is a one-line edit rather than a fight with the derivation. + * * Keyed by `array-key` rather than `string` because PHP coerces a numeric string key to an INTEGER the - * moment it is written — '201' becomes 201 — so the honest type for a status map is the mixed one. The - * document is unaffected: a map keyed 201/400/'default' is not a PHP list, so json_encode still writes a - * JSON object. + * moment it is written — '201' becomes 201 — so the honest type for a status map is the mixed one. That + * coercion is what makes the override work at all: a derived '201' and a declared 201 land on the same + * key rather than producing two entries. The document is unaffected: a map keyed 201/400/'default' is not + * a PHP list, so json_encode still writes a JSON object. * * @return array<array-key, mixed> */ - private function responses(RouteDescriptor $route, bool $rejectable, bool $validated): array + private function responses(RouteDescriptor $route, bool $rejectable, bool $validated, OperationDoc $doc, SchemaRegistry $registry): array { $responses = [(string) $route->status => $this->successResponse($route)]; @@ -211,7 +257,90 @@ private function responses(RouteDescriptor $route, bool $rejectable, bool $valid $responses['default'] = ['$ref' => ProblemSchema::RESPONSE_REF]; - return $responses; + foreach ($doc->responses as $declared) { + $responses[(string) $declared->status] = $this->declaredResponse($declared, $registry); + } + + return $this->sortStatuses($responses); + } + + /** + * One #[ApiResponse] as a Response Object. `description` is the only REQUIRED member of that object, and + * the attribute makes it a required constructor argument for exactly that reason — a Response Object + * without one is invalid, and defaulting it to '' would produce a document that validates as a technicality + * and reads as a blank. + * + * An omitted `type` documents a BODILESS response, which is the honest shape for a 204 or a 304 and the + * common case for the error statuses this attribute mostly documents — those render through + * ProblemDetailsRenderer, whose shape the shared problem component already states. + * + * `array` is honoured here as `type: array`, where the DERIVED success response degrades the same PHP + * type to `type: object`. That is not an inconsistency: a controller's `array` return type genuinely does + * not say whether the payload is a list or a map (LaraFly controllers overwhelmingly return maps), so the + * derivation cannot know — whereas an author who typed `type: 'array'` into an attribute has said which + * one they meant. + * + * @return array<string, mixed> + */ + private function declaredResponse(ApiResponse $declared, SchemaRegistry $registry): array + { + $response = ['description' => $declared->description]; + + if ($declared->type === null) { + return $response; + } + + $schema = TypeSchema::isDto($declared->type) + ? ['$ref' => $this->schemas->ref($declared->type, $registry)] + : TypeSchema::for($declared->type) ?? ['type' => 'object']; + + $response['content'] = ['application/json' => ['schema' => $schema]]; + + return $response; + } + + /** + * Numeric statuses ascending, then any other named one, then `default` last. + * + * Without this an #[ApiResponse(404)] would land after `default` simply because it was applied later, and + * a reader scanning a viewer's response list would meet the catch-all before the specific case. The order + * is also what makes a regenerated document diff cleanly: response order would otherwise depend on the + * order attributes happen to be written in above the method. + * + * @param array<array-key, mixed> $responses + * @return array<array-key, mixed> + */ + private function sortStatuses(array $responses): array + { + $numeric = []; + $named = []; + + foreach ($responses as $status => $response) { + if (is_int($status)) { + $numeric[$status] = $response; + } else { + $named[$status] = $response; + } + } + + ksort($numeric); + ksort($named); + + $default = $named['default'] ?? null; + unset($named['default']); + + $sorted = []; + foreach ($numeric as $status => $response) { + $sorted[$status] = $response; + } + foreach ($named as $status => $response) { + $sorted[$status] = $response; + } + if ($default !== null) { + $sorted['default'] = $default; + } + + return $sorted; } /** @@ -224,6 +353,8 @@ private function responses(RouteDescriptor $route, bool $rejectable, bool $valid * `array` is the common LaraFly return and deliberately degrades to `type: object` rather than being * expanded from the method's `@return array{...}` docblock: parsing a PHPDoc array shape here would make * the generated document depend on comment text that nothing else in the framework treats as binding. + * A method's PROSE is now read (see ApiDocs) and its TYPES are still not, which is the line — prose has + * no other source and cannot mislead a client generator; a mistyped `@return` silently can. * * @return array<string, mixed> */ @@ -279,30 +410,4 @@ private function returnType(RouteDescriptor $route): ?string return $type instanceof ReflectionNamedType ? $type->getName() : null; } - - /** - * The tag a viewer groups this operation under: the controller's short name with a trailing "Controller" - * removed, so `Lumen\Web\WalletController` reads as "Wallet". - */ - private function tag(RouteDescriptor $route): string - { - $class = $route->controllerClass; - $short = str_contains($class, '\\') ? substr($class, (int) strrpos($class, '\\') + 1) : $class; - - return str_ends_with($short, 'Controller') && $short !== 'Controller' - ? substr($short, 0, -strlen('Controller')) - : $short; - } - - /** - * `getBalance` reads as "Get balance". A method name is the only human-authored label a route carries - * (a #[Mapping]'s name is a Laravel route name, not prose), so it is the honest source for a summary. - */ - private function summary(RouteDescriptor $route): string - { - $words = preg_split('/(?=[A-Z])/', $route->methodName); - $sentence = strtolower(trim(implode(' ', $words === false ? [$route->methodName] : $words))); - - return $sentence === '' ? $route->methodName : ucfirst($sentence); - } } diff --git a/packages/openapi/src/Generator/TagDoc.php b/packages/openapi/src/Generator/TagDoc.php new file mode 100644 index 0000000..55d2b6a --- /dev/null +++ b/packages/openapi/src/Generator/TagDoc.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Generator; + +/** + * The tag one controller's operations are grouped under: the NAME every operation repeats, and the + * DESCRIPTION that can only be stated once, in the document's root `tags` array. + * + * The split matters because OpenAPI puts the two in different places. An Operation Object's `tags` member is + * a bare list of strings with nowhere to hang prose, so a tag description that is not lifted to the root is + * a tag description that never reaches a reader. + */ +final readonly class TagDoc +{ + public function __construct( + public string $name, + public string $description, + ) {} +} diff --git a/packages/openapi/src/OpenApiAutoConfiguration.php b/packages/openapi/src/OpenApiAutoConfiguration.php index f69e3fc..4cc1e21 100644 --- a/packages/openapi/src/OpenApiAutoConfiguration.php +++ b/packages/openapi/src/OpenApiAutoConfiguration.php @@ -9,6 +9,7 @@ use Firefly\Container\Attributes\Configuration; use Firefly\Container\Attributes\Order; use Firefly\Context\Condition\Attributes\ConditionalOnMissingBean; +use Firefly\OpenApi\Generator\DocumentInfo; use Firefly\OpenApi\Generator\OpenApiGenerator; use Firefly\OpenApi\Generator\OperationFactory; use Firefly\OpenApi\Schema\ConstraintSchemaMapper; @@ -70,11 +71,25 @@ public function operationFactory(DtoSchemaFactory $schemas): OperationFactory return new OperationFactory($schemas); } + /** + * The OPTIONAL Info Object members — summary, termsOfService, contact, license — as their own bean rather + * than as a few more fields on OpenApiProperties. They are a distinct, spec-shaped object with its own + * validity rules (a License Object requires a `name`; `identifier` and `url` exclude one another), and + * keeping them separate is what lets an application replace just this piece — to read a license out of + * composer.json, say — without also taking over the title and version. + */ + #[Bean] + #[ConditionalOnMissingBean(DocumentInfo::class)] + public function documentInfo(Config $config): DocumentInfo + { + return DocumentInfo::fromConfig($config); + } + #[Bean] #[ConditionalOnMissingBean(OpenApiGenerator::class)] - public function openApiGenerator(RouteManifest $routes, OpenApiProperties $properties, OperationFactory $operations): OpenApiGenerator + public function openApiGenerator(RouteManifest $routes, OpenApiProperties $properties, OperationFactory $operations, DocumentInfo $info): OpenApiGenerator { - return new OpenApiGenerator($routes, $properties, $operations); + return new OpenApiGenerator($routes, $properties, $operations, $info); } #[Bean] diff --git a/packages/openapi/src/Schema/DtoSchemaFactory.php b/packages/openapi/src/Schema/DtoSchemaFactory.php index e6ce359..92dffff 100644 --- a/packages/openapi/src/Schema/DtoSchemaFactory.php +++ b/packages/openapi/src/Schema/DtoSchemaFactory.php @@ -4,13 +4,15 @@ namespace Firefly\OpenApi\Schema; +use Firefly\OpenApi\Generator\DocBlock; use Firefly\Validation\Constraint\ConstraintManifest; use Illuminate\Contracts\Validation\ValidationRule; use ReflectionClass; -use ReflectionParameter; /** - * Builds the `components/schemas` entry for one request-body DTO, from two sources that each know half of it. + * Builds the `components/schemas` entry for one request-body DTO, from three sources that each know part of + * it: the compiled constraints, the constructor signature, and the class's own PHPDoc (plus #[ApiProperty] + * where an author has something to add that none of the three can state). * * ConstraintManifest knows the VALIDATION contract — which members are required, what shapes they must have — * but nothing about types, because a rule list is untyped by construction. The DTO's own constructor knows @@ -36,6 +38,13 @@ * documented: BeanValidator validates the RAW decoded array, so such a member is enforced on input even * though it is never hydrated. * + * THE PROSE comes from the class docblock (which becomes the schema's `description`), each member's own + * docblock or the constructor's `@param` line for it (which becomes the member's), and #[ApiProperty] over + * both — see MemberDoc, which owns that precedence. It is read here for the same reason the types are: the + * text is already written, sitting in the file, and a schema that repeats a member's own name back at the + * reader is worse than one that says nothing. The reflection this costs is the same reflection the + * constructor already required. + * * NESTED DTOs become their own component and a `$ref`, never an inlined object — see SchemaRegistry. When the * nested class has its own manifest entry (the normal case: ConstraintManifestCompiler compiles every class * under the app's scan roots, not just body DTOs) its own rules are used. When it does not, the parent's @@ -89,7 +98,7 @@ private function build(string $class, SchemaRegistry $registry, array $propertie $schema = [ 'type' => 'object', 'title' => $this->title($class), - 'description' => 'Request payload bound from '.$class.'.', + 'description' => $this->description($class), 'properties' => $fields, ]; @@ -113,23 +122,30 @@ private function property(MemberType $member, array $rules, array $nestedRules, if ($type !== null && TypeSchema::isDto($type)) { $ref = $this->ref($type, $registry, [], $nestedRules); - // A `$ref` cannot usefully be widened with a sibling `type` in 2020-12 (the reference's own - // keywords win), so a nullable nested DTO is spelled as the union it actually is. + // A `$ref` cannot usefully be widened with a sibling `type` in 2020-12 — VALIDATION keywords + // beside a reference are applied WITH it, so `type: 'null'` would have to pass as well as the + // reference and could never hold — which is why a nullable nested DTO is spelled as the union it + // actually is. Annotations are the opposite case: a `description` beside a `$ref` is legal in + // 2020-12 and therefore in 3.1, so MemberDoc::apply() is safe on either shape. $schema = $member->nullable ? ['anyOf' => [['$ref' => $ref], ['type' => 'null']]] : ['$ref' => $ref]; - return new PropertySchema($schema, $this->mapper->apply([], $rules, false, $member->required())->required); + return new PropertySchema( + $member->doc->apply($schema), + $this->mapper->apply([], $rules, false, $member->required())->required, + ); } $base = TypeSchema::for($type) ?? []; $property = $this->mapper->apply($base, $rules, $member->nullable, $member->required()); + $schema = $property->schema; - if ($member->hasDefault && $member->default !== null && ! array_key_exists('default', $property->schema)) { - return new PropertySchema([...$property->schema, 'default' => $member->default], $property->required); + if ($member->hasDefault && $member->default !== null && ! array_key_exists('default', $schema)) { + $schema['default'] = $member->default; } - return $property; + return new PropertySchema($member->doc->apply($schema), $property->required); } /** @@ -142,42 +158,79 @@ private function property(MemberType $member, array $rules, array $nestedRules, */ private function members(string $class, array $properties, array $own): array { + $reflection = $this->reflect($class); + $constructor = $reflection?->getConstructor(); + + // Parsed ONCE per DTO and handed to every member: `@param` lines all live in the same comment, and + // re-parsing it per parameter would re-do the same work eight times for an eight-member payload. + $constructorDoc = DocBlock::parse($constructor?->getDocComment()); + $members = []; - foreach ($this->parameters($class) as $parameter) { - $members[$parameter->getName()] = MemberType::fromParameter($parameter); + foreach ($constructor?->getParameters() ?? [] as $parameter) { + $members[$parameter->getName()] = MemberType::fromParameter( + $parameter, + MemberDoc::forParameter($parameter, $constructorDoc), + ); } if ($members === []) { // No constructor to reflect (the class is not autoloadable here, or takes no arguments): fall // back to the compiled binding's key list, which RouteScanner captured from the same source. foreach ($properties as $name) { - $members[$name] = MemberType::unknown(); + $members[$name] = MemberType::unknown($this->memberDoc($reflection, $name)); } } foreach (array_keys($own) as $name) { - $members[$name] ??= MemberType::unknown(); + $members[$name] ??= MemberType::unknown($this->memberDoc($reflection, $name)); } return $members; } /** - * @return list<ReflectionParameter> + * The prose for a member the constructor does not take, read off a declared property of the same name + * when there is one. A rule-only member with no property at all (validated on the raw decoded array, and + * nowhere else) simply has nothing to read. + * + * @param ReflectionClass<object>|null $class */ - private function parameters(string $class): array + private function memberDoc(?ReflectionClass $class, string $name): MemberDoc + { + return $class !== null && $class->hasProperty($name) + ? MemberDoc::forProperty($class->getProperty($name)) + : new MemberDoc; + } + + /** + * The DTO's class docblock as the schema `description`, falling back to a statement of where the payload + * is bound from. + * + * The fallback is kept rather than dropped because a schema with no description at all reads, in a + * viewer, as a component nobody has looked at — whereas "Request payload bound from App\Dto\X." at + * least tells a reader which PHP class to open. It is a locator, not documentation, which is exactly why + * any real docblock beats it. + */ + private function description(string $class): string + { + $prose = DocBlock::parse($this->reflect($class)?->getDocComment())->prose(); + + return $prose === '' ? 'Request payload bound from '.$class.'.' : $prose; + } + + /** + * @return ReflectionClass<object>|null + */ + private function reflect(string $class): ?ReflectionClass { if (! class_exists($class)) { - return []; + return null; } $reflection = new ReflectionClass($class); - if ($reflection->isAbstract() || $reflection->isInterface()) { - return []; - } - return $reflection->getConstructor()?->getParameters() ?? []; + return $reflection->isAbstract() || $reflection->isInterface() ? null : $reflection; } /** diff --git a/packages/openapi/src/Schema/MemberDoc.php b/packages/openapi/src/Schema/MemberDoc.php new file mode 100644 index 0000000..fc05880 --- /dev/null +++ b/packages/openapi/src/Schema/MemberDoc.php @@ -0,0 +1,175 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Schema; + +use Firefly\OpenApi\Attributes\ApiProperty; +use Firefly\OpenApi\Generator\DocBlock; +use ReflectionAttribute; +use ReflectionParameter; +use ReflectionProperty; + +/** + * The prose half of one DTO member's schema: everything about it that neither the declared PHP type nor the + * compiled constraint list can say. + * + * MemberType is the other half and the split is the same one TypeSchema and ConstraintSchemaMapper already + * make: each source is asked only about what it actually knows. A type knows `?int`; a rule list knows + * `gte:1`; a docblock knows "the number of units to reserve, never zero"; only an author writing + * #[ApiProperty] knows a `format` more precise than the validator enforces or an example worth publishing. + * None of the four can be derived from the others, so all four are read and merged in a stated order. + * + * PRECEDENCE for `description`: #[ApiProperty(description:)] beats the member's OWN docblock beats the + * constructor's `@param` line for it. That order is the one people expect from reading the file top to + * bottom — the closer a statement sits to the member, the more specific it is — and the `@param` fallback + * matters more than it looks, because a promoted constructor property is where most LaraFly DTOs put + * everything and `@param` is the only place PHPDoc lets you describe one without inventing a property + * docblock for a parameter. + * + * NOTHING IS INVENTED. A member with no description in any of the three sources gets no `description` key at + * all, rather than a humanised restatement of its own name — `"quantity": {"description": "Quantity"}` is + * noise that costs a reader a second to dismiss and costs the file a line per property forever. + */ +final readonly class MemberDoc +{ + public function __construct( + public ?string $description = null, + public bool $hasExample = false, + public mixed $example = null, + public ?string $format = null, + public bool $deprecated = false, + ) {} + + /** + * A constructor parameter, promoted or not. + * + * PHP applies an attribute written on a PROMOTED parameter to both the parameter and the property it + * creates, filtered by that attribute's own targets — so reading the parameter finds #[ApiProperty] in the + * promoted case, and the property is consulted anyway for the non-promoted-but-separately-declared case + * and for its docblock, which only ever exists on the property. + */ + public static function forParameter(ReflectionParameter $parameter, DocBlock $constructor): self + { + $property = self::promoted($parameter); + + $attribute = self::attribute($parameter->getAttributes(ApiProperty::class)) + ?? ($property === null ? null : self::attribute($property->getAttributes(ApiProperty::class))); + + return self::merge( + $attribute, + $property === null ? DocBlock::empty() : DocBlock::parse($property->getDocComment()), + $constructor->params()[$parameter->getName()] ?? '', + ); + } + + /** + * A member the constructor does not take. ConstraintManifest still carries rules for it — BeanValidator + * validates the RAW decoded array, so such a member is enforced on input even though nothing hydrates it + * — and it is documented for exactly that reason, so its prose has to be reachable too. + */ + public static function forProperty(ReflectionProperty $property): self + { + return self::merge( + self::attribute($property->getAttributes(ApiProperty::class)), + DocBlock::parse($property->getDocComment()), + '', + ); + } + + public function isEmpty(): bool + { + return $this->description === null && ! $this->hasExample && $this->format === null && ! $this->deprecated; + } + + /** + * Layers this member's prose onto its finished schema. + * + * `description` goes FIRST because it is what a human reads first in a rendered file, and because the + * keys that follow it (`type`, `minimum`, …) are the machine's half. `format` OVERWRITES a + * constraint-derived one — see ApiProperty for why an author's format is the more precise statement. + * + * `examples` is the plural ARRAY form, not `example`. OpenAPI 3.1 aligned the Schema Object with JSON + * Schema 2020-12, whose keyword is `examples`, and explicitly deprecated the singular `example` inherited + * from 3.0. Both render in today's viewers; only one of them survives a strict 3.1 validator's + * deprecation warning, and only one of them is what a 2020-12 tool reads. + * + * Safe to call on a `$ref`-valued property, which is why DtoSchemaFactory does. In JSON Schema 2020-12 — + * and so in OpenAPI 3.1, unlike 3.0 — sibling keywords alongside `$ref` are legal and are simply applied + * with it. That holds for ANNOTATIONS like these; it does not hold for validation keywords, which is why + * the nullable-nested-DTO case next door still has to spell itself as an explicit `anyOf`. + * + * @param array<string, mixed> $schema + * @return array<string, mixed> + */ + public function apply(array $schema): array + { + if ($this->description !== null) { + $schema = ['description' => $this->description, ...$schema]; + } + + if ($this->format !== null) { + $schema['format'] = $this->format; + } + + if ($this->hasExample) { + $schema['examples'] = [$this->example]; + } + + if ($this->deprecated) { + $schema['deprecated'] = true; + } + + return $schema; + } + + private static function merge(?ApiProperty $attribute, DocBlock $doc, string $param): self + { + $description = self::first($attribute->description ?? '', $doc->prose(), $param); + $format = trim($attribute->format ?? ''); + + return new self( + description: $description === '' ? null : $description, + hasExample: $attribute?->example !== null, + example: $attribute?->example, + format: $format === '' ? null : $format, + deprecated: $attribute->deprecated ?? false, + ); + } + + /** + * The promoted property a constructor parameter declares, or null when the parameter promotes nothing. + * Guarded on hasProperty() as well as isPromoted() because a class this process reflects may have been + * autoloaded from a different build than the manifest was compiled against, and a missing property must + * degrade to "no docblock" rather than raise. + */ + private static function promoted(ReflectionParameter $parameter): ?ReflectionProperty + { + $class = $parameter->getDeclaringClass(); + + if ($class === null || ! $parameter->isPromoted() || ! $class->hasProperty($parameter->getName())) { + return null; + } + + return $class->getProperty($parameter->getName()); + } + + /** + * @param list<ReflectionAttribute<ApiProperty>> $attributes + */ + private static function attribute(array $attributes): ?ApiProperty + { + return $attributes === [] ? null : $attributes[0]->newInstance(); + } + + private static function first(string ...$candidates): string + { + foreach ($candidates as $candidate) { + if (trim($candidate) !== '') { + return trim($candidate); + } + } + + return ''; + } +} diff --git a/packages/openapi/src/Schema/MemberType.php b/packages/openapi/src/Schema/MemberType.php index 0710e40..c04d023 100644 --- a/packages/openapi/src/Schema/MemberType.php +++ b/packages/openapi/src/Schema/MemberType.php @@ -20,14 +20,20 @@ */ final readonly class MemberType { + /** + * $doc defaults to an empty MemberDoc rather than to null so that every call site can apply it + * unconditionally. A member with no prose then adds no keys instead of forcing a null check into the one + * place that assembles the schema. + */ public function __construct( public ?string $type, public bool $nullable, public bool $hasDefault, public mixed $default, + public MemberDoc $doc = new MemberDoc, ) {} - public static function fromParameter(ReflectionParameter $parameter): self + public static function fromParameter(ReflectionParameter $parameter, MemberDoc $doc = new MemberDoc): self { $type = $parameter->getType(); @@ -36,13 +42,14 @@ public static function fromParameter(ReflectionParameter $parameter): self nullable: $type?->allowsNull() ?? true, hasDefault: $parameter->isDefaultValueAvailable(), default: $parameter->isDefaultValueAvailable() ? self::scalar($parameter->getDefaultValue()) : null, + doc: $doc, ); } /** A member the constructor does not declare: validated on input, but untyped as far as this generator knows. */ - public static function unknown(): self + public static function unknown(MemberDoc $doc = new MemberDoc): self { - return new self(null, true, false, null); + return new self(null, true, false, null, $doc); } public function required(): bool diff --git a/packages/openapi/tests/AttributeFixture/AdjustmentRequest.php b/packages/openapi/tests/AttributeFixture/AdjustmentRequest.php new file mode 100644 index 0000000..2ff6694 --- /dev/null +++ b/packages/openapi/tests/AttributeFixture/AdjustmentRequest.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\AttributeFixture; + +use Firefly\OpenApi\Attributes\ApiProperty; +use Firefly\Validation\Constraint\Email; +use Firefly\Validation\Constraint\NotBlank; + +/** + * A manual correction to a stock level, for when a count disagrees with the ledger. + */ +final class AdjustmentRequest +{ + public function __construct( + /** A property docblock the attribute beside it must beat. */ + #[ApiProperty(description: 'The stock-keeping unit being corrected.', example: 'ACME-001')] + #[NotBlank] + public readonly string $sku, + #[ApiProperty(description: 'Signed change to apply. Negative writes stock off.', example: -3)] + public readonly int $delta, + // #[Email] already produces `format: email`; the attribute states the more precise one, and the + // override is the point of the assertion that reads it back. + #[ApiProperty(format: 'idn-email', deprecated: true)] + #[Email] + public readonly ?string $countedBy = null, + ) {} +} diff --git a/packages/openapi/tests/AttributeFixture/InternalToolingController.php b/packages/openapi/tests/AttributeFixture/InternalToolingController.php new file mode 100644 index 0000000..f1a3800 --- /dev/null +++ b/packages/openapi/tests/AttributeFixture/InternalToolingController.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\AttributeFixture; + +use Firefly\OpenApi\Attributes\ApiIgnore; +use Firefly\OpenApi\Attributes\ApiTag; +use Firefly\Web\Attributes\GetMapping; +use Firefly\Web\Attributes\RequestMapping; +use Firefly\Web\Attributes\RestController; + +/** + * A whole controller left out of the document. + * + * It carries an #[ApiTag] with a description as well, so the test can prove that hiding the operations also + * withholds the tag: a root `tags` entry for a group with no visible operations would advertise the existence + * of exactly what #[ApiIgnore] was used to hide. + */ +#[RestController] +#[RequestMapping('/internal')] +#[ApiTag(name: 'Internal', description: 'Back-office tooling that is not part of the published contract.')] +#[ApiIgnore] +final class InternalToolingController +{ + /** @return array<string, mixed> */ + #[GetMapping('/reindex')] + public function reindex(): array + { + return []; + } +} diff --git a/packages/openapi/tests/AttributeFixture/InventoryController.php b/packages/openapi/tests/AttributeFixture/InventoryController.php new file mode 100644 index 0000000..f9651e0 --- /dev/null +++ b/packages/openapi/tests/AttributeFixture/InventoryController.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\AttributeFixture; + +use Firefly\OpenApi\Attributes\ApiIgnore; +use Firefly\OpenApi\Attributes\ApiOperation; +use Firefly\OpenApi\Attributes\ApiParameter; +use Firefly\OpenApi\Attributes\ApiResponse; +use Firefly\OpenApi\Attributes\ApiTag; +use Firefly\Validation\Valid; +use Firefly\Web\Attributes\GetMapping; +use Firefly\Web\Attributes\PathVariable; +use Firefly\Web\Attributes\PostMapping; +use Firefly\Web\Attributes\QueryParam; +use Firefly\Web\Attributes\RequestBody; +use Firefly\Web\Attributes\RequestMapping; +use Firefly\Web\Attributes\RestController; + +/** + * A class docblock that must NOT reach the document, because #[ApiTag] states both the name and the + * description and an attribute beats a docblock. + * + * It is here precisely so the test can prove the docblock LOSES: a fixture whose two sources agree would pass + * whichever one the generator actually read. + */ +#[RestController] +#[RequestMapping('/inventory')] +#[ApiTag(name: 'Warehouse', description: 'Stock levels, movements and manual adjustments.')] +final class InventoryController +{ + /** + * A summary the attribute overrides. This second paragraph must not appear as a description either. + * + * @return array<string, mixed> + */ + #[GetMapping('/{sku}')] + #[ApiOperation( + summary: 'Read one stock level', + description: 'Live and uncached: the number returned is the number the warehouse would pick against right now.', + operationId: 'stockLevel', + tags: ['Warehouse', 'Reporting'], + )] + #[ApiResponse(status: 404, description: 'No such stock-keeping unit.')] + #[ApiResponse(status: 200, description: 'The current stock level.', type: StockLevel::class)] + // `required: false` is stated here and must be DROPPED: a path parameter is required by the 3.1 + // meta-schema itself, so honouring the override would emit a document a strict validator rejects. + #[ApiParameter(name: 'sku', description: 'The stock-keeping unit to read.', example: 'ACME-001', required: false)] + #[ApiParameter(name: 'at', description: 'Read the level as it stood at this instant.', example: '2026-01-01T00:00:00Z', required: true)] + // Nothing binds `tenant`: it is not a #[PathVariable], a #[QueryParam] or a #[RequestHeader] on this + // method, so the dispatcher will never read it and it must not appear in the document either. + #[ApiParameter(name: 'tenant', description: 'A parameter this endpoint does not actually take.')] + public function level( + #[PathVariable] string $sku, + #[QueryParam(name: 'at')] ?string $at = null, + ): array { + return ['sku' => $sku, 'at' => $at]; + } + + /** + * Read the stock level as it stood at the close of a named accounting period. + * + * Declared SECOND on purpose, claiming an operationId the method above already claimed: an attribute + * chooses the id, it does not get to hand two operations the same one. + * + * @return array<string, mixed> + */ + #[GetMapping('/{sku}/closing/{period}')] + #[ApiOperation(operationId: 'stockLevel')] + public function closingLevel( + #[PathVariable] string $sku, + #[PathVariable] string $period, + ): array { + return ['sku' => $sku, 'period' => $period]; + } + + /** + * Apply a manual stock correction. + * + * The summary and description here survive: #[ApiOperation] states only `deprecated`, and an omitted + * member falls through to the docblock rather than blanking it. + * + * @return array<string, mixed> + */ + #[PostMapping('/adjustments', status: 201)] + #[ApiOperation(deprecated: true)] + public function adjust(#[Valid] #[RequestBody] AdjustmentRequest $body): array + { + return ['sku' => $body->sku]; + } + + /** + * Dump the warehouse's internal reconciliation state. + * + * A real route, deliberately undocumented: it exists for one deploy while a client migrates, and putting + * it in the published contract would invite somebody to build on it. + * + * @return array<string, mixed> + */ + #[GetMapping('/debug/reconciliation')] + #[ApiIgnore] + public function reconciliation(): array + { + return []; + } +} diff --git a/packages/openapi/tests/AttributeFixture/StockLevel.php b/packages/openapi/tests/AttributeFixture/StockLevel.php new file mode 100644 index 0000000..27b1178 --- /dev/null +++ b/packages/openapi/tests/AttributeFixture/StockLevel.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\AttributeFixture; + +/** + * The body of a successful stock read — a RESPONSE payload, which nothing in the route manifest can point at, + * because RouteDescriptor records a status and never a shape. It reaches the document only because an + * #[ApiResponse] names it, which is the case the attribute exists for. + */ +final class StockLevel +{ + public function __construct( + public readonly string $sku, + public readonly int $onHand, + ) {} +} diff --git a/packages/openapi/tests/DocFixture/CatalogController.php b/packages/openapi/tests/DocFixture/CatalogController.php new file mode 100644 index 0000000..a8573b1 --- /dev/null +++ b/packages/openapi/tests/DocFixture/CatalogController.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\DocFixture; + +use Firefly\Validation\Valid; +use Firefly\Web\Attributes\GetMapping; +use Firefly\Web\Attributes\PathVariable; +use Firefly\Web\Attributes\PostMapping; +use Firefly\Web\Attributes\QueryParam; +use Firefly\Web\Attributes\RequestBody; +use Firefly\Web\Attributes\RequestMapping; +use Firefly\Web\Attributes\RestController; + +/** + * The public product catalogue. + * + * Everything here is readable without authentication; stock reservation is the one action that changes state, + * and it changes it only for as long as the reservation lives. + * + * This fixture deliberately carries NOT ONE attribute from Firefly\OpenApi\Attributes. It is the "documented + * only by PHPDoc" half of the parity tests, so every word that reaches the generated document from here got + * there because the generator read prose a developer had already written for a human reader — which is the + * entire claim being tested. + */ +#[RestController] +#[RequestMapping('/catalog')] +final class CatalogController +{ + /** + * List the products in one category. Withdrawn lines are never included, even when their category still + * exists. + * + * The `page` cursor is opaque: echo back exactly what the previous response returned. Cursors built by + * hand are not supported and may stop resolving at any time. + * + * @return array<string, mixed> + */ + #[GetMapping('/{category}')] + public function list( + #[PathVariable] string $category, + #[QueryParam(name: 'page')] ?string $page = null, + ): array { + return ['category' => $category, 'page' => $page]; + } + + /** + * Hold stock for a shopper who has not paid yet. + * + * @return array<string, mixed> + */ + #[PostMapping('/reservations', status: 202)] + public function reserve(#[Valid] #[RequestBody] ReservationRequest $body): array + { + return ['basket' => $body->basket]; + } + + /** + * Look one product up by the barcode printed on it. + * + * @deprecated Superseded by the catalogue search endpoint, which accepts a barcode among other terms. + * + * @return array<string, mixed> + */ + #[GetMapping('/barcode/{code}')] + public function byBarcode(#[PathVariable] string $code): array + { + return ['code' => $code]; + } + + /** @return array<string, mixed> */ + #[GetMapping('/health')] + public function undocumented(): array + { + return []; + } +} diff --git a/packages/openapi/tests/DocFixture/ReservationRequest.php b/packages/openapi/tests/DocFixture/ReservationRequest.php new file mode 100644 index 0000000..ead0c5a --- /dev/null +++ b/packages/openapi/tests/DocFixture/ReservationRequest.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\DocFixture; + +use Firefly\Validation\Constraint\Max; +use Firefly\Validation\Constraint\Min; +use Firefly\Validation\Constraint\NotBlank; + +/** + * A request to hold stock for a shopper who has not paid yet. + * + * Reservations expire on their own. A caller is expected to confirm or release one rather than let it lapse, + * because a lapsed reservation is indistinguishable from an abandoned basket in the stock reports. + */ +final class ReservationRequest +{ + /** + * The three ways a member can be described, one per parameter, so the precedence between them is + * observable rather than asserted: `$basket` has only this `@param`, `$sku` has its own docblock, and + * `$minutes` has both — the closer one must win. + * + * @param string $basket the shopper's basket, as returned by POST /baskets + * @param int $minutes ignored, because the promoted property below says it better + */ + public function __construct( + #[NotBlank] + public readonly string $basket, + /** The catalogue line to hold. Exactly one line may be reserved per request. */ + #[NotBlank] + public readonly string $sku, + /** How long to hold the stock for, in minutes from now. */ + #[Min(1)] + #[Max(60)] + public readonly int $minutes = 15, + ) {} +} diff --git a/packages/openapi/tests/Generator/ApiAttributeTest.php b/packages/openapi/tests/Generator/ApiAttributeTest.php new file mode 100644 index 0000000..404fb69 --- /dev/null +++ b/packages/openapi/tests/Generator/ApiAttributeTest.php @@ -0,0 +1,189 @@ +<?php + +declare(strict_types=1); + +use Firefly\OpenApi\Tests\Support\FixtureDocument; + +/** + * The "documented by attributes" half of springdoc parity — the same merged-document assertions, over a + * fixture whose docblocks deliberately DISAGREE with its attributes. + * + * That disagreement is the design of the fixture. A controller whose two sources say the same thing passes + * whichever one the generator actually read, so InventoryController's class docblock and its `level()` + * docblock both state text that must NOT appear anywhere in the output: the assertions below are as much + * about what is absent as about what is present. + * + * @return array<string, mixed> + */ +function annotatedDocument(): array +{ + return FixtureDocument::generatorFor('AttributeFixture')->generate(); +} + +it('lets #[ApiOperation] beat the docblock for summary, description, operationId and tags', function () { + $operation = FixtureDocument::operation(annotatedDocument(), '/inventory/{sku}', 'get'); + + expect($operation['summary'])->toBe('Read one stock level') + ->and($operation['description'])->toBe('Live and uncached: the number returned is the number the warehouse would pick against right now.') + ->and($operation['operationId'])->toBe('stockLevel') + ->and($operation['tags'])->toBe(['Warehouse', 'Reporting']); +}); + +it('leaves the docblock in place for every #[ApiOperation] member the author omitted', function () { + // #[ApiOperation(deprecated: true)] and nothing else: an omitted member falls THROUGH rather than + // blanking what the docblock said, which is the whole precedence rule in one operation. + $operation = FixtureDocument::operation(annotatedDocument(), '/inventory/adjustments', 'post'); + + expect($operation['summary'])->toBe('Apply a manual stock correction.') + ->and($operation['description'])->toStartWith('The summary and description here survive') + ->and($operation['deprecated'])->toBeTrue() + // No operationId was stated, so the derivation still applies. + ->and($operation['operationId'])->toBe('inventoryAdjust'); +}); + +it('never leaks the docblock text an attribute overrode', function () { + $json = FixtureDocument::generatorFor('AttributeFixture')->toJson(); + + expect($json)->not->toContain('A summary the attribute overrides') + ->and($json)->not->toContain('A class docblock that must NOT reach the document') + ->and($json)->not->toContain('A property docblock the attribute beside it must beat'); +}); + +it('names and describes the tag from #[ApiTag] rather than from the class', function () { + /** @var list<array{name: string, description: string}> $tags */ + $tags = annotatedDocument()['tags']; + + expect($tags)->toBe([[ + 'name' => 'Warehouse', + 'description' => 'Stock levels, movements and manual adjustments.', + ]]); +}); + +it('lists a tag at the root only when something describes it', function () { + $document = annotatedDocument(); + + /** @var list<array{name: string, description: string}> $tags */ + $tags = $document['tags']; + $names = array_map(static fn (array $tag): string => $tag['name'], $tags); + + // `Reporting` is used by an operation but nothing describes it, and a root entry carrying only a name + // restates what the operation already says. + expect(FixtureDocument::operation($document, '/inventory/{sku}', 'get')['tags'])->toContain('Reporting') + ->and($names)->not->toContain('Reporting'); +}); + +it('adds an #[ApiResponse] and lets one replace a derived response of the same status', function () { + /** @var array<array-key, mixed> $responses */ + $responses = FixtureDocument::operation(annotatedDocument(), '/inventory/{sku}', 'get')['responses']; + + // Numeric statuses ascending, `default` last — never in the order the attributes happen to be written. + expect(array_map(strval(...), array_keys($responses)))->toBe(['200', '404', 'default']) + ->and($responses[200])->toBe([ + 'description' => 'The current stock level.', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/StockLevel']]], + ]) + // No `type`, so the response is documented as bodiless rather than given an invented shape. + ->and($responses[404])->toBe(['description' => 'No such stock-keeping unit.']); +}); + +it('registers an #[ApiResponse] payload type as a component like any body DTO', function () { + $document = annotatedDocument(); + + /** @var array<string, array<string, array<string, mixed>>> $components */ + $components = $document['components']; + + expect($components['schemas'])->toHaveKey('StockLevel') + ->and($components['schemas']['StockLevel']['required'])->toBe(['sku', 'onHand']); + + foreach (array_unique(FixtureDocument::refs($document)) as $ref) { + expect(FixtureDocument::resolve($document, $ref))->not->toBeNull("dangling \$ref {$ref}"); + } +}); + +it('enriches a bound parameter with #[ApiParameter] and ignores a name nothing binds', function () { + /** @var list<array<string, mixed>> $parameters */ + $parameters = FixtureDocument::operation(annotatedDocument(), '/inventory/{sku}', 'get')['parameters']; + + $byName = []; + foreach ($parameters as $parameter) { + /** @var string $name */ + $name = $parameter['name']; + $byName[$name] = $parameter; + } + + // `tenant` was claimed by an #[ApiParameter] and bound by nothing, so it is dropped: the binding plan is + // the only honest statement of what this endpoint reads, and a parameter the dispatcher never looks at + // would document an API that does not exist. + expect(array_keys($byName))->toBe(['sku', 'at']) + ->and($byName['sku']['description'])->toBe('The stock-keeping unit to read.') + ->and($byName['sku']['example'])->toBe('ACME-001') + // The query parameter's PHP signature defaults it to null, so the binding plan says optional; the + // attribute says otherwise and wins, because a document may be reshaped by its author. + ->and($byName['at']['required'])->toBeTrue() + ->and($byName['at']['example'])->toBe('2026-01-01T00:00:00Z'); +}); + +it('keeps a path parameter required whatever an #[ApiParameter] claims', function () { + /** @var list<array<string, mixed>> $parameters */ + $parameters = FixtureDocument::operation(annotatedDocument(), '/inventory/{sku}', 'get')['parameters']; + + // `required: false` on a path parameter is invalid under the 3.1 meta-schema, so the override is dropped + // rather than allowed to produce a document a strict validator rejects. + expect($parameters[0]['name'])->toBe('sku') + ->and($parameters[0]['in'])->toBe('path') + ->and($parameters[0]['required'])->toBeTrue(); +}); + +it('enriches a DTO member with #[ApiProperty] and spells the example the 3.1 way', function () { + /** @var array<string, array<string, array<string, mixed>>> $components */ + $components = annotatedDocument()['components']; + /** @var array<string, array<string, mixed>> $properties */ + $properties = $components['schemas']['AdjustmentRequest']['properties']; + + expect($properties['sku']['description'])->toBe('The stock-keeping unit being corrected.') + // 3.1 aligned the Schema Object with JSON Schema 2020-12 and deprecated the singular `example`. + ->and($properties['sku']['examples'])->toBe(['ACME-001']) + ->and($properties['sku'])->not->toHaveKey('example') + ->and($properties['delta']['examples'])->toBe([-3]); +}); + +it('lets an #[ApiProperty] format overwrite the constraint-derived one and mark a member deprecated', function () { + /** @var array<string, array<string, array<string, mixed>>> $components */ + $components = annotatedDocument()['components']; + /** @var array<string, array<string, mixed>> $properties */ + $properties = $components['schemas']['AdjustmentRequest']['properties']; + + // #[Email] compiles to `format: email`; the author said something more precise and there is only one + // `format` slot per schema. + expect($properties['countedBy']['format'])->toBe('idn-email') + ->and($properties['countedBy']['deprecated'])->toBeTrue() + // Nothing described it, so nothing is invented in place of a description. + ->and($properties['countedBy'])->not->toHaveKey('description'); +}); + +it('leaves an #[ApiIgnore] method and an #[ApiIgnore] class out of the document entirely', function () { + $document = annotatedDocument(); + + /** @var array<string, mixed> $paths */ + $paths = $document['paths']; + + expect(array_keys($paths))->toBe(['/inventory/adjustments', '/inventory/{sku}', '/inventory/{sku}/closing/{period}']); + + // A hidden controller must leave NO trace: not a path, not an orphan tag description advertising the + // group it was hidden to conceal. + $json = FixtureDocument::generatorFor('AttributeFixture')->toJson(); + + expect($json)->not->toContain('/internal') + ->and($json)->not->toContain('reconciliation') + ->and($json)->not->toContain('Back-office tooling'); +}); + +it('still enforces operationId uniqueness over an id an attribute chose', function () { + $document = annotatedDocument(); + + // Both methods declare #[ApiOperation(operationId: 'stockLevel')]. A duplicate operationId is the single + // flaw that makes most client generators abort rather than degrade, so the second claimant is suffixed: + // the attribute picks the name, the document keeps its invariant. + expect(FixtureDocument::operation($document, '/inventory/{sku}', 'get')['operationId'])->toBe('stockLevel') + ->and(FixtureDocument::operation($document, '/inventory/{sku}/closing/{period}', 'get')['operationId'])->toBe('stockLevel_2'); +}); diff --git a/packages/openapi/tests/Generator/DocBlockTest.php b/packages/openapi/tests/Generator/DocBlockTest.php new file mode 100644 index 0000000..75ce96a --- /dev/null +++ b/packages/openapi/tests/Generator/DocBlockTest.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +use Firefly\OpenApi\Generator\DocBlock; + +/** + * The parser, tested on the comment shapes that actually occur in a LaraFly controller rather than on a + * grammar. Every case here was found by pointing the generator at real code and reading what came out; the + * + * one-line `/** @return T *\/` case in particular shipped as an operation summary reading + * "@return array<string, mixed>" until this test existed. + */ +it('splits the first sentence off as the summary and keeps the rest as the description', function () { + $doc = DocBlock::parse(<<<'DOC' + /** + * List the products in one category. Withdrawn lines are never included. + * + * The cursor is opaque and must be echoed back exactly. + */ + DOC); + + expect($doc->summary)->toBe('List the products in one category.') + ->and($doc->description)->toBe("Withdrawn lines are never included.\n\nThe cursor is opaque and must be echoed back exactly.") + // prose() is the whole thing, uncut — what a tag or schema description wants. + ->and($doc->prose())->toBe("List the products in one category. Withdrawn lines are never included.\n\nThe cursor is opaque and must be echoed back exactly."); +}); + +it('treats a single-sentence comment as all summary and no description', function () { + $doc = DocBlock::parse('/** Cancel an order. */'); + + expect($doc->summary)->toBe('Cancel an order.') + ->and($doc->description)->toBe(''); +}); + +it('unwraps the editor line breaks a hard-wrapped paragraph carries', function () { + $doc = DocBlock::parse(<<<'DOC' + /** + * Reserve stock against a basket, holding it for a fixed + * window so a shopper can pay without racing anyone else. + */ + DOC); + + expect($doc->summary)->toBe('Reserve stock against a basket, holding it for a fixed window so a shopper can pay without racing anyone else.') + ->and($doc->summary)->not->toContain("\n"); +}); + +it('does not end the sentence on an internal-dot abbreviation or a decimal point', function () { + expect(DocBlock::parse('/** Cancels an order, e.g. a draft one. Refunds are separate. */')->summary) + ->toBe('Cancels an order, e.g. a draft one.') + ->and(DocBlock::parse('/** Charges 1.5 percent. Rounded half up. */')->summary) + ->toBe('Charges 1.5 percent.'); +}); + +it('reads a one-line docblock that holds nothing but a tag as empty prose', function () { + // The bug this pins: without the opener's trailing spaces being stripped, the tag line no longer starts + // at column zero, is not recognised as a tag, and becomes the operation's summary. + $doc = DocBlock::parse('/** @return array<string, mixed> */'); + + expect($doc->isEmpty())->toBeTrue() + ->and($doc->summary)->toBe('') + ->and($doc->has('return'))->toBeTrue(); +}); + +it('reports @deprecated by presence, with or without a reason', function () { + expect(DocBlock::parse("/**\n * Gone soon.\n *\n * @deprecated\n */")->has('deprecated'))->toBeTrue() + ->and(DocBlock::parse("/**\n * Gone soon.\n *\n * @deprecated use v2\n */")->has('deprecated'))->toBeTrue() + ->and(DocBlock::parse('/** Alive. */')->has('deprecated'))->toBeFalse(); +}); + +it('reads @param descriptions past a type expression that contains spaces', function () { + $doc = DocBlock::parse(<<<'DOC' + /** + * @param array<string, mixed> $payload the decoded request body + * @param int $attempts how many times to retry + * @param string $untouched + */ + DOC); + + expect($doc->params())->toBe([ + 'payload' => 'the decoded request body', + 'attempts' => 'how many times to retry', + ]); +}); + +it('joins a wrapped @param description rather than truncating it at the line break', function () { + $doc = DocBlock::parse(<<<'DOC' + /** + * @param string $cursor an opaque page cursor, echoed back exactly + * as the previous response returned it + */ + DOC); + + expect($doc->params()['cursor'])->toBe('an opaque page cursor, echoed back exactly as the previous response returned it'); +}); + +it('is empty for an absent comment, which is what every getDocComment() returns as false', function () { + expect(DocBlock::parse(false)->isEmpty())->toBeTrue() + ->and(DocBlock::parse(null)->isEmpty())->toBeTrue() + ->and(DocBlock::parse('')->isEmpty())->toBeTrue() + ->and(DocBlock::parse(false)->params())->toBe([]); +}); diff --git a/packages/openapi/tests/Generator/DocumentInfoTest.php b/packages/openapi/tests/Generator/DocumentInfoTest.php new file mode 100644 index 0000000..cad07b6 --- /dev/null +++ b/packages/openapi/tests/Generator/DocumentInfoTest.php @@ -0,0 +1,113 @@ +<?php + +declare(strict_types=1); + +use Firefly\Config\Config; +use Firefly\OpenApi\Generator\DocumentInfo; +use Firefly\OpenApi\Tests\Support\FixtureDocument; +use Illuminate\Config\Repository; + +/** + * The Info Object's optional members, read from config and emitted in the order the OpenAPI 3.1 specification + * lists them. + * + * Every member asserted here is one the spec actually defines — title, summary, description, termsOfService, + * contact, license, version, and nothing else. The License Object's `identifier`/`url` exclusivity is the + * only rule in this corner that a document can silently violate, so it gets its own case. + * + * @param array<string, mixed> $openapi + */ +function documentInfoFrom(array $openapi): DocumentInfo +{ + return DocumentInfo::fromConfig(new Config(new Repository(['firefly' => ['openapi' => $openapi]]))); +} + +it('emits nothing for an application that configured none of it', function () { + $info = documentInfoFrom([]); + + expect($info->applyTo(['title' => 'API', 'version' => '1.0.0'])) + ->toBe(['title' => 'API', 'version' => '1.0.0']); +}); + +it('reads every member OpenAPI 3.1 defines on the Info Object', function () { + $info = documentInfoFrom([ + 'summary' => 'Everything the warehouse exposes.', + 'terms-of-service' => 'https://example.test/terms', + 'contact' => ['name' => 'Platform Team', 'url' => 'https://example.test/support', 'email' => 'api@example.test'], + 'license' => ['name' => 'Apache 2.0', 'url' => 'https://www.apache.org/licenses/LICENSE-2.0'], + ]); + + expect($info->applyTo(['title' => 'API', 'version' => '1.0.0', 'description' => 'Long form.']))->toBe([ + // Spec field order, which is the only thing a human reading a committed openapi.json sees. + 'title' => 'API', + 'summary' => 'Everything the warehouse exposes.', + 'description' => 'Long form.', + 'termsOfService' => 'https://example.test/terms', + 'contact' => ['name' => 'Platform Team', 'url' => 'https://example.test/support', 'email' => 'api@example.test'], + 'license' => ['name' => 'Apache 2.0', 'url' => 'https://www.apache.org/licenses/LICENSE-2.0'], + 'version' => '1.0.0', + ]); +}); + +it('keeps the SPDX identifier and drops the url, because 3.1 says they are mutually exclusive', function () { + $info = documentInfoFrom([ + 'license' => ['name' => 'Apache 2.0', 'identifier' => 'Apache-2.0', 'url' => 'https://www.apache.org/licenses/LICENSE-2.0'], + ]); + + /** @var array<string, mixed> $applied */ + $applied = $info->applyTo(['title' => 'API', 'version' => '1.0.0']); + + expect($applied['license'])->toBe(['name' => 'Apache 2.0', 'identifier' => 'Apache-2.0']); +}); + +it('drops a license with no name, because the License Object requires one', function () { + $info = documentInfoFrom(['license' => ['url' => 'https://example.test/licence']]); + + expect($info->applyTo(['title' => 'API', 'version' => '1.0.0']))->not->toHaveKey('license'); +}); + +it('emits a contact from any single member, since the Contact Object requires none', function () { + $info = documentInfoFrom(['contact' => ['email' => 'api@example.test']]); + + /** @var array<string, mixed> $applied */ + $applied = $info->applyTo(['title' => 'API', 'version' => '1.0.0']); + + expect($applied['contact'])->toBe(['email' => 'api@example.test']); +}); + +it('treats a blank configured value as unconfigured rather than emitting an empty member', function () { + $info = documentInfoFrom([ + 'summary' => ' ', + 'terms-of-service' => '', + 'contact' => ['name' => ' '], + ]); + + expect($info->applyTo(['title' => 'API', 'version' => '1.0.0'])) + ->toBe(['title' => 'API', 'version' => '1.0.0']); +}); + +it('reaches the generated document when the generator is given one', function () { + $info = documentInfoFrom([ + 'summary' => 'The fixture API, in one line.', + 'contact' => ['email' => 'api@example.test'], + 'license' => ['name' => 'Apache 2.0', 'identifier' => 'Apache-2.0'], + ]); + + $document = FixtureDocument::generatorFor('DocFixture', info: $info)->generate(); + + expect($document['info'])->toBe([ + 'title' => 'Orders API', + 'summary' => 'The fixture API, in one line.', + 'description' => 'The fixture API.', + 'contact' => ['email' => 'api@example.test'], + 'license' => ['name' => 'Apache 2.0', 'identifier' => 'Apache-2.0'], + 'version' => '1.2.3', + ]); +}); + +it('leaves the document exactly as it was when the generator is given none', function () { + // The parameter is optional so that every existing three-argument construction keeps producing the + // document it produced before — including OpenApiAutoConfiguration's #[Bean]. + expect(FixtureDocument::generatorFor('DocFixture')->generate()['info']) + ->toBe(['title' => 'Orders API', 'version' => '1.2.3', 'description' => 'The fixture API.']); +}); diff --git a/packages/openapi/tests/Generator/DocumentedOperationTest.php b/packages/openapi/tests/Generator/DocumentedOperationTest.php new file mode 100644 index 0000000..9e83cd8 --- /dev/null +++ b/packages/openapi/tests/Generator/DocumentedOperationTest.php @@ -0,0 +1,119 @@ +<?php + +declare(strict_types=1); + +use Firefly\OpenApi\Tests\Support\FixtureDocument; + +/** + * The "documented only by PHPDoc" half of springdoc parity, asserted on the MERGED DOCUMENT rather than on + * the parser. + * + * DocFixture carries not one attribute from Firefly\OpenApi\Attributes. Everything asserted here therefore + * reached the document because the generator read comments a developer had already written for a human — the + * exact claim being made. Where the old generator put `ucfirst()` of the method name into `summary` and the + * literal string "Handled by App\Web\CatalogController::list()." into `description`, both of which are + * asserted against by name below so a regression cannot pass quietly. + * + * @return array<string, mixed> + */ +function documentedDocument(): array +{ + return FixtureDocument::generatorFor('DocFixture')->generate(); +} + +it('takes the operation summary from the docblock and the rest of it as the description', function () { + $operation = FixtureDocument::operation(documentedDocument(), '/catalog/{category}', 'get'); + + expect($operation['summary'])->toBe('List the products in one category.') + ->and($operation['description'])->toBe( + "Withdrawn lines are never included, even when their category still exists.\n\n" + .'The `page` cursor is opaque: echo back exactly what the previous response returned. Cursors ' + .'built by hand are not supported and may stop resolving at any time.' + ); +}); + +it('never emits the placeholder the description used to be', function () { + $json = FixtureDocument::generatorFor('DocFixture')->toJson(); + + expect($json)->not->toContain('Handled by '); +}); + +it('falls back to the humanised method name only when the docblock holds no prose', function () { + // `undocumented()` carries `/** @return array<string, mixed> */` and nothing else — a docblock that is + // present but says nothing about the operation must fall through exactly as an absent one does. + $operation = FixtureDocument::operation(documentedDocument(), '/catalog/health', 'get'); + + expect($operation['summary'])->toBe('Undocumented') + ->and($operation)->not->toHaveKey('description'); +}); + +it('marks an operation deprecated from the docblock @deprecated tag', function () { + $document = documentedDocument(); + + expect(FixtureDocument::operation($document, '/catalog/barcode/{code}', 'get')['deprecated'])->toBeTrue() + // Emitted only where it is true: `deprecated` defaults to false in the specification, so a live + // operation must not carry the key at all. + ->and(FixtureDocument::operation($document, '/catalog/reservations', 'post'))->not->toHaveKey('deprecated'); +}); + +it('describes the tag from the controller class docblock', function () { + /** @var list<array{name: string, description: string}> $tags */ + $tags = documentedDocument()['tags']; + + expect($tags)->toHaveCount(1) + ->and($tags[0]['name'])->toBe('Catalog') + ->and($tags[0]['description'])->toStartWith('The public product catalogue.') + // The whole class docblock, not just its first sentence: a tag description has one slot and a viewer + // renders it as a block. + ->and($tags[0]['description'])->toContain('readable without authentication'); +}); + +it('describes a DTO schema from the DTO class docblock', function () { + /** @var array<string, array<string, array<string, mixed>>> $components */ + $components = documentedDocument()['components']; + /** @var array<string, mixed> $schema */ + $schema = $components['schemas']['ReservationRequest']; + + expect($schema['description'])->toStartWith('A request to hold stock for a shopper who has not paid yet.') + ->and($schema['description'])->toContain('lapsed reservation'); +}); + +it('describes each DTO member from its own docblock, falling back to the constructor @param', function () { + /** @var array<string, array<string, array<string, mixed>>> $components */ + $components = documentedDocument()['components']; + /** @var array<string, array<string, mixed>> $properties */ + $properties = $components['schemas']['ReservationRequest']['properties']; + + expect($properties['basket']['description'])->toBe("the shopper's basket, as returned by POST /baskets") + ->and($properties['sku']['description'])->toBe('The catalogue line to hold. Exactly one line may be reserved per request.') + // `minutes` has BOTH a promoted-property docblock and a `@param` line, and the closer one wins. + ->and($properties['minutes']['description'])->toBe('How long to hold the stock for, in minutes from now.'); +}); + +it('leaves the derived schema keywords untouched while adding prose', function () { + /** @var array<string, array<string, array<string, mixed>>> $components */ + $components = documentedDocument()['components']; + /** @var array<string, array<string, mixed>> $properties */ + $properties = $components['schemas']['ReservationRequest']['properties']; + + // A description is an annotation and must not disturb the type/constraint half of the schema, which is + // still derived from the declared type and the compiled ConstraintManifest. + expect($properties['minutes'])->toBe([ + 'description' => 'How long to hold the stock for, in minutes from now.', + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 60, + 'default' => 15, + ]); +}); + +it('still produces a document whose every local $ref resolves', function () { + $document = documentedDocument(); + $refs = FixtureDocument::refs($document); + + expect($refs)->not->toBeEmpty(); + + foreach (array_unique($refs) as $ref) { + expect(FixtureDocument::resolve($document, $ref))->not->toBeNull("dangling \$ref {$ref}"); + } +}); diff --git a/packages/openapi/tests/PackageBootTest.php b/packages/openapi/tests/PackageBootTest.php index 5aebdad..2307d19 100644 --- a/packages/openapi/tests/PackageBootTest.php +++ b/packages/openapi/tests/PackageBootTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Firefly\Context\Boot\ApplicationContext; +use Firefly\OpenApi\Generator\DocumentInfo; use Firefly\OpenApi\Generator\OpenApiGenerator; use Firefly\OpenApi\OpenApiProperties; use Firefly\OpenApi\OpenApiServiceProvider; @@ -79,3 +80,47 @@ function bootOpenApiApp(array $openapi = []): Application expect($router->getRoutes()->getRoutes())->toBe([]); }); + +/** + * The Info Object's optional members are read from config by a bean, not by a test helper. DocumentInfoTest + * proves the OBJECT behaves; this proves the WIRING exists — that `firefly.openapi.license.name` in an + * application's config file reaches the generated document at all. + * + * It is a separate test because the two can fail independently, and the interesting failure is the silent + * one: DocumentInfo can be perfectly correct and perfectly unreachable if nothing constructs it. The + * generator's fourth constructor argument is optional, so a bean that forgets to pass it still compiles, + * still boots, and still produces a document — just never the configured one. + */ +it('feeds the configured Info Object members into the generated document', function () { + /** @var OpenApiGenerator $generator */ + $generator = bootOpenApiApp([ + 'title' => 'Warehouse API', + 'version' => '2.0.0', + 'summary' => 'Everything the warehouse exposes.', + 'terms-of-service' => 'https://example.test/terms', + 'contact' => ['name' => 'Platform Team', 'email' => 'api@example.test'], + 'license' => ['name' => 'Apache 2.0', 'identifier' => 'Apache-2.0'], + ])->make(OpenApiGenerator::class); + + /** @var array<string, mixed> $info */ + $info = $generator->generate()['info']; + + expect($info)->toBe([ + 'title' => 'Warehouse API', + 'summary' => 'Everything the warehouse exposes.', + 'termsOfService' => 'https://example.test/terms', + 'contact' => ['name' => 'Platform Team', 'email' => 'api@example.test'], + 'license' => ['name' => 'Apache 2.0', 'identifier' => 'Apache-2.0'], + 'version' => '2.0.0', + ]); +}); + +it('resolves a DocumentInfo bean that an application has not configured', function () { + // The bean must exist unconditionally, so that the generator's dependency is always satisfiable — an app + // that has never heard of these keys still boots, and still gets the document it got before. + $app = bootOpenApiApp(); + + expect($app->make(DocumentInfo::class))->toBeInstanceOf(DocumentInfo::class) + ->and($app->make(OpenApiGenerator::class)->generate()['info']) + ->toBe(['title' => 'API', 'version' => '0.0.0']); +}); diff --git a/packages/openapi/tests/Support/FixtureDocument.php b/packages/openapi/tests/Support/FixtureDocument.php index e7ad11d..eaecdb1 100644 --- a/packages/openapi/tests/Support/FixtureDocument.php +++ b/packages/openapi/tests/Support/FixtureDocument.php @@ -5,6 +5,7 @@ namespace Firefly\OpenApi\Tests\Support; use Firefly\Context\Scan\AppScan; +use Firefly\OpenApi\Generator\DocumentInfo; use Firefly\OpenApi\Generator\OpenApiGenerator; use Firefly\OpenApi\Generator\OperationFactory; use Firefly\OpenApi\OpenApiProperties; @@ -30,24 +31,24 @@ final class FixtureDocument /** * @return array<string, string> */ - public static function psr4(): array + public static function psr4(string $directory = 'Fixture'): array { - return ['Firefly\\OpenApi\\Tests\\Fixture\\' => dirname(__DIR__).'/Fixture']; + return ['Firefly\\OpenApi\\Tests\\'.$directory.'\\' => dirname(__DIR__).'/'.$directory]; } - public static function routes(): RouteManifest + public static function routes(string $directory = 'Fixture'): RouteManifest { - return new RouteManifest((new RouteScanner)->scan(self::psr4())); + return new RouteManifest((new RouteScanner)->scan(self::psr4($directory))); } - public static function constraints(): ConstraintManifest + public static function constraints(string $directory = 'Fixture'): ConstraintManifest { - return ConstraintManifest::fromArray((new ConstraintManifestCompiler)->toArray(AppScan::classes(self::psr4()))); + return ConstraintManifest::fromArray((new ConstraintManifestCompiler)->toArray(AppScan::classes(self::psr4($directory)))); } - public static function schemas(): DtoSchemaFactory + public static function schemas(string $directory = 'Fixture'): DtoSchemaFactory { - return new DtoSchemaFactory(self::constraints(), new ConstraintSchemaMapper); + return new DtoSchemaFactory(self::constraints($directory), new ConstraintSchemaMapper); } public static function generator(?OpenApiProperties $properties = null): OpenApiGenerator @@ -59,6 +60,51 @@ public static function generator(?OpenApiProperties $properties = null): OpenApi ); } + /** + * A generator over ONE fixture namespace, so the documentation fixtures can each be a self-contained + * surface rather than more routes bolted onto the shared one. + * + * Keeping them apart is what lets each assert on a WHOLE document — the exact tag list, the exact set of + * paths, nothing else present — which is the only way to prove a negative like "the #[ApiIgnore]d + * controller left no trace". A single shared fixture would force every such assertion to be a + * needle-in-a-haystack lookup that passes just as well when the haystack is wrong. + */ + public static function generatorFor(string $directory, ?OpenApiProperties $properties = null, ?DocumentInfo $info = null): OpenApiGenerator + { + return new OpenApiGenerator( + self::routes($directory), + $properties ?? self::properties(), + new OperationFactory(self::schemas($directory)), + $info, + ); + } + + /** + * One operation out of a generated document, or [] when the path or verb is absent — so a missing + * operation fails the assertion that asked about it rather than a type error three lines earlier. + * + * @param array<string, mixed> $document + * @return array<string, mixed> + */ + public static function operation(array $document, string $path, string $verb): array + { + /** @var mixed $node */ + $node = $document['paths'] ?? []; + + foreach ([$path, $verb] as $segment) { + if (! is_array($node) || ! array_key_exists($segment, $node)) { + return []; + } + /** @var mixed $node */ + $node = $node[$segment]; + } + + /** @var array<string, mixed> $operation */ + $operation = is_array($node) ? $node : []; + + return $operation; + } + /** * @param list<array{url: string, description?: string}> $servers * @param list<string> $exclude diff --git a/skeleton/app/Http/WelcomeController.php b/skeleton/app/Http/WelcomeController.php index 9823b1d..bbefc0c 100644 --- a/skeleton/app/Http/WelcomeController.php +++ b/skeleton/app/Http/WelcomeController.php @@ -65,9 +65,26 @@ public function index(): View 'endpoints' => $this->registeredEndpoints(), 'routes' => $this->appRoutes($base), 'tools' => $this->tools($base), + 'managementPort' => $this->managementPort(), ]); } + /** + * The port management traffic has been moved to, or null when it shares the application's port. + * + * This page must know, because when a management port IS configured the actuator and the dashboard stop + * answering here — a card linking to them from the application port would link to a 404 and quietly + * teach a developer that the feature is broken rather than that it moved. + */ + private function managementPort(): ?int + { + if (! class_exists(\Firefly\Actuator\Server\ManagementServerSettings::class)) { + return null; + } + + return \Firefly\Actuator\Server\ManagementServerSettings::fromConfig($this->config)->port; + } + /** * The other surfaces this application is serving right now. * @@ -76,12 +93,16 @@ public function index(): View * dashboard is off by default outside debug, and the API reference disappears when firefly/openapi is * not installed, so a hard-coded link would be wrong for most applications. * - * @return list<array{href: string, label: string, blurb: string}> + * @return list<array{href: string|null, label: string, blurb: string}> */ private function tools(string $actuatorBase): array { + // With a management port configured, the actuator and the dashboard answer only there — so they are + // described rather than linked, and the page says where they went. + $moved = $this->managementPort() !== null; + $tools = [[ - 'href' => '/'.$actuatorBase, + 'href' => $moved ? null : '/'.$actuatorBase, 'label' => 'Actuator', 'blurb' => 'Health, info and the endpoints you expose, as JSON.', ]]; @@ -90,9 +111,9 @@ private function tools(string $actuatorBase): array $admin = AdminSettings::fromConfig($this->config); if ($admin->enabled) { $tools[] = [ - 'href' => $admin->url(), + 'href' => $moved ? null : $admin->url(), 'label' => 'Dashboard', - 'blurb' => 'Health, beans, routes, metrics and configuration in the browser.', + 'blurb' => 'Health, beans, the bean graph, routes, metrics and configuration in the browser.', ]; } } diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 768f27b..f426a12 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -375,7 +375,8 @@ | must opt in explicitly — and should put the route behind its own auth middleware when it does. | Setting the key wins over the debug default in both directions. | - | Defaults: enabled = app.debug, base-path '/firefly', title = app.name. + | Defaults: enabled = app.debug, base-path '/firefly', title = app.name, refresh-seconds 10, + | theme 'auto', graph.max-nodes 220, pages.exclude ''. | */ @@ -383,6 +384,42 @@ // 'enabled' => env('FIREFLY_ADMIN_ENABLED', false), // 'base-path' => '/firefly', // 'title' => env('APP_NAME', 'LaraFly'), + // + // /* + // | How often a live page reloads itself. FLOORED AT 2: a shorter interval reloads faster than the + // | page renders, so the countdown would never finish and the dashboard would hammer the very + // | application it exists to observe. + // | + // | Default: 10. + // */ + // 'refresh-seconds' => 10, + // + // // 'auto' (follow the operating system) | 'light' | 'dark'. Anything unrecognised falls back to + // // 'auto' rather than rendering unstyled. Default: 'auto'. + // 'theme' => 'auto', + // + // 'graph' => [ + // /* + // | The node count past which the Bean graph page LISTS the relations instead of drawing them. + // | A diagram past a couple of hundred nodes is a hairball rather than something anyone can + // | read. Configurable — not a constant — because "unreadable" depends on the screen and the + // | application; 0 always lists. + // | + // | Default: 220. + // */ + // 'max-nodes' => 220, + // ], + // + // 'pages' => [ + // /* + // | CSV of page slugs to REFUSE. This is a refusal, not a menu preference: an excluded page is + // | hidden from the menu AND its URL 404s — hiding `env` from the menu achieves nothing if the + // | URL still answers. The index page's slug is `overview`. + // | + // | Default: '' (nothing excluded). + // */ + // 'exclude' => 'env,configprops', + // ], // ], /* diff --git a/skeleton/resources/views/welcome.blade.php b/skeleton/resources/views/welcome.blade.php index 48cd0ec..be89059 100644 --- a/skeleton/resources/views/welcome.blade.php +++ b/skeleton/resources/views/welcome.blade.php @@ -168,6 +168,8 @@ padding:15px 17px;transition:border-color .14s,background .14s; } .tools .tool:hover{border-color:var(--amber-2);background:var(--card-2)} + .tools .tool.moved{opacity:.72} + .tools .tool.moved:hover{border-color:var(--line);background:var(--card)} .tools .tool strong{display:flex;align-items:center;gap:6px;font-size:14.5px;font-weight:600} .tools .tool .go{color:var(--amber);font-size:13px} .tools .tool span{font-size:13px;color:var(--text-3);line-height:1.5} @@ -263,13 +265,31 @@ <h2>What is running</h2> <div class="tools"> @foreach ($tools as $tool) - <a class="tool" href="{{ $tool['href'] }}"> - <strong>{{ $tool['label'] }}<span class="go" aria-hidden="true">→</span></strong> - <span>{{ $tool['blurb'] }}</span> - <code>{{ $tool['href'] }}</code> - </a> + @if ($tool['href'] === null) + {{-- Moved to the management port: described, never linked, because the link would 404. --}} + <div class="tool moved"> + <strong>{{ $tool['label'] }}</strong> + <span>{{ $tool['blurb'] }}</span> + <code>port {{ $managementPort }}</code> + </div> + @else + <a class="tool" href="{{ $tool['href'] }}"> + <strong>{{ $tool['label'] }}<span class="go" aria-hidden="true">→</span></strong> + <span>{{ $tool['blurb'] }}</span> + <code>{{ $tool['href'] }}</code> + </a> + @endif @endforeach </div> + + @if ($managementPort !== null) + <p class="tip" style="margin-top:12px"> + <b>Management traffic is on port {{ $managementPort }}.</b> The actuator and the dashboard + answer only there, so they are not reachable from this page. Run + <code>php artisan firefly:serve --management</code> alongside your application to reach them + in development. + </p> + @endif </section> <section> From 3b671872f02f80f327a18ddda29c3830db27d194 Mon Sep 17 00:00:00 2001 From: Andres Contreras <andres.contreras@soon.es> Date: Thu, 3 Sep 2026 16:48:45 -0700 Subject: [PATCH 16/31] fix(admin): the bean graph was structurally incapable of showing framework wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced against a running skeleton before touching anything: 42 nodes, 41 #[Bean] products missing entirely, 21 dangling dependencies, and exactly ONE edge drawn. Not "sparse" — the model had no way to represent most of what an application wires. THREE KINDS OF BEAN, ONE OF THEM MODELLED A LaraFly application has components, #[Bean] products, and #[ConfigProperties] DTOs. Only components were nodes. But a framework's wiring lives almost entirely in the second kind: an auto-configuration is a #[Configuration] whose #[Bean] methods produce MeterRegistry, TransactionTemplate, AggregateTracker and so on, so every edge pointing at one of those pointed at a node that did not exist. #[ConfigProperties] DTOs had the same problem from the other side — App\GreetingProperties appeared as an unresolved dependency of GreetingService rather than as the bean it is. All three are nodes now, with `produces` edges from a configuration to what it makes, drawn quieter than `injects` because that is structure rather than a dependency the author wrote. Competing producers of one type keep separate nodes keyed `Declaring::method()`, since collapsing them onto the type would hide exactly the ambiguity #[Primary]/#[Qualifier] exists to resolve. Same application, after: 84 nodes (42 components, 41 beans, 1 config), 80 edges, 18 unresolved — and the remaining 18 are genuine Laravel container bindings (the Request, a connection), which the page lists rather than hides, because "why is my bean not in the graph" is the question it exists to answer. A VISUALIZER THAT CAN BE READ Layout: a pure layered layout is wrong here. Dependency depth is shallow and wide, so most beans landed on one level and the drawing came out 9184px across in a single row — which fit() then scaled to 11%. Each level is now wrapped into its own grid, giving a compact rectangle while arrows still read downward. Module colouring with a legend that filters, pan and zoom, click-to-focus that lights a bean's edges and dims the rest, and an inspector listing what it depends on and what depends on it, each entry clickable. Three bugs fixed along the way, all found by driving the page rather than reading it: - `@stack('scripts')` had been dropped from the layout when it was rewritten, so every @push('scripts') block was SILENTLY DISCARDED. The graph's pan, zoom, selection and filtering never ran, with no error anywhere to say why. - fit() mixed CSS pixels with viewBox units and under-scaled the drawing into a corner. The svg is now a 1:1 pixel canvas with the transform as the only scaling, and fit() has a readability floor — fitting 84 nodes exactly lands near 0.45, where an 11px label renders at 5px. - shortOf() searched for two backslashes where a PHP namespace separator is one, so the inspector showed fully-qualified names in a 268px panel. WIDE SCREENS Panels no longer stretch to match a taller neighbour (align-items:start), which was the biggest source of dead space; free-text cells stop growing at a readable measure instead of putting a label at x=270 and its value at x=1855 on a 1920 screen; and the overview's grid opens more columns past 1500px. 66 admin tests, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../admin/resources/views/graph.blade.php | 411 ++++++++++++++---- .../admin/resources/views/layout.blade.php | 87 +++- .../admin/resources/views/overview.blade.php | 6 +- packages/admin/src/BeanGraph.php | 224 +++++----- packages/admin/src/BeanGraphIndex.php | 232 ++++++++++ .../admin/src/Data/DataBrowserSettings.php | 105 +++++ packages/admin/src/Data/DataColumn.php | 99 +++++ packages/admin/src/Data/DataResource.php | 55 +++ .../admin/src/Data/DataResourceRegistry.php | 233 ++++++++++ packages/admin/src/Data/DataSchema.php | 115 +++++ .../admin/src/Data/RepositoryIntrospector.php | 279 ++++++++++++ packages/admin/src/Web/AdminAction.php | 8 +- packages/admin/tests/BeanGraphTest.php | 132 +++++- 13 files changed, 1778 insertions(+), 208 deletions(-) create mode 100644 packages/admin/src/BeanGraphIndex.php create mode 100644 packages/admin/src/Data/DataBrowserSettings.php create mode 100644 packages/admin/src/Data/DataColumn.php create mode 100644 packages/admin/src/Data/DataResource.php create mode 100644 packages/admin/src/Data/DataResourceRegistry.php create mode 100644 packages/admin/src/Data/DataSchema.php create mode 100644 packages/admin/src/Data/RepositoryIntrospector.php diff --git a/packages/admin/resources/views/graph.blade.php b/packages/admin/resources/views/graph.blade.php index eced842..a568cbb 100644 --- a/packages/admin/resources/views/graph.blade.php +++ b/packages/admin/resources/views/graph.blade.php @@ -3,47 +3,77 @@ @section('body') @php use Firefly\Admin\BeanGraph; + use Firefly\Admin\Format; + + $counts = $graph->kindCounts(); + $modules = $graph->modules(); + + // A stable colour per module, assigned by position so the same application always draws the same + // picture. Hues are spread around the wheel and kept away from the semantic red/green the rest of + // the dashboard reserves for status. + $hues = [212, 265, 28, 172, 320, 45, 190, 288, 96, 240, 12, 150]; + $moduleHue = []; + foreach (array_values($modules) as $i => $module) { + $moduleHue[$module] = $hues[$i % count($hues)]; + } - // Group by level for the layered drawing. Levels come from the model; the view only positions. $byLevel = []; foreach ($graph->nodes as $node) { $byLevel[$node['level']][] = $node; } ksort($byLevel); - $nodeW = 186; $nodeH = 42; $gapX = 22; $gapY = 74; - $widest = 0; - foreach ($byLevel as $row) { $widest = max($widest, count($row)); } - $canvasW = max(720, $widest * ($nodeW + $gapX)); - $canvasH = max(240, count($byLevel) * ($nodeH + $gapY)); + // Cluster same-module nodes within a layer so related things end up adjacent rather than scattered. + foreach ($byLevel as $level => $row) { + usort($row, fn (array $a, array $b): int => [BeanGraph::moduleOf($a['id']), $a['label']] <=> [BeanGraph::moduleOf($b['id']), $b['label']]); + $byLevel[$level] = $row; + } + + // LAYOUT. A pure layered layout is wrong for this graph: dependency depth is shallow and wide, so + // most beans land on one or two levels and a stock skeleton produced a single row 54 nodes and + // 9184px across — which fit() then scaled to 11%, i.e. unreadable. Each LEVEL is therefore wrapped + // into a grid of its own, so the drawing stays a compact rectangle while arrows still read downward + // from dependents to dependencies. + $nodeW = 168; $nodeH = 40; $gapX = 14; $gapY = 20; $levelGap = 46; + $perRow = max(4, (int) ceil(sqrt(max(1, count($graph->nodes)))) + 2); - // Centre each level, then remember every node's box so the edges can be drawn between them. $at = []; - foreach ($byLevel as $level => $row) { - $rowW = count($row) * ($nodeW + $gapX) - $gapX; - $startX = ($canvasW - $rowW) / 2; - foreach (array_values($row) as $i => $node) { - $at[$node['id']] = [ - 'x' => $startX + $i * ($nodeW + $gapX), - 'y' => $level * ($nodeH + $gapY) + 16, - ]; + $canvasW = $perRow * ($nodeW + $gapX) - $gapX + 40; + $y = 20; + + foreach ($byLevel as $row) { + $rows = array_chunk($row, $perRow); + foreach ($rows as $chunk) { + $rowW = count($chunk) * ($nodeW + $gapX) - $gapX; + $startX = ($canvasW - $rowW) / 2; + foreach (array_values($chunk) as $i => $node) { + $at[$node['id']] = ['x' => $startX + $i * ($nodeW + $gapX), 'y' => $y]; + } + $y += $nodeH + $gapY; } + $y += $levelGap - $gapY; } + + $canvasH = max(260, $y + 20); + @endphp <div class="head"> <h1>Bean graph</h1> - <p>How your beans depend on one another. A constructor asks for a <em>type</em>, so an edge through an - interface is drawn to the bean that actually implements it and labelled with the interface.</p> + <p>Every bean this application wired, and what each one depends on. A constructor asks for a + <em>type</em>, so an edge through an interface is drawn to the bean that implements it and + labelled with the interface.</p> </div> <dl class="stats"> <div class="stat"><dt>Beans</dt><dd>{{ count($graph->nodes) }}</dd></div> + <div class="stat"><dt>Components</dt><dd>{{ $counts[BeanGraph::KIND_COMPONENT] }}</dd></div> + <div class="stat"><dt>#[Bean] products</dt><dd>{{ $counts[BeanGraph::KIND_BEAN] }}</dd></div> + <div class="stat"><dt>Config DTOs</dt><dd>{{ $counts[BeanGraph::KIND_CONFIG] }}</dd></div> <div class="stat"><dt>Relations</dt><dd>{{ count($graph->edges) }}</dd></div> <div class="stat"><dt>Layers</dt><dd>{{ count($byLevel) }}</dd></div> <div class="stat"> <dt>Cycles</dt> - <dd>@if ($graph->cycles === []){{ 0 }}@else<span class="chip down">{{ count($graph->cycles) }}</span>@endif</dd> + <dd>@if ($graph->cycles === [])0 @else<span class="chip down">{{ count($graph->cycles) }}</span>@endif</dd> </div> - <div class="stat"><dt>Unresolved</dt><dd>{{ count($graph->unresolved) }}</dd></div> </dl> @if ($graph->cycles !== []) @@ -51,12 +81,12 @@ interface is drawn to the bean that actually implements it and labelled with the @include('firefly-admin::_panel-head', ['title' => 'Circular dependencies', 'count' => count($graph->cycles)]) <div class="tw"> <table> - <thead><tr><th>From</th><th>Depends on</th></tr></thead> + <thead><tr><th>Bean</th><th>Depends on</th></tr></thead> <tbody> @foreach ($graph->cycles as $cycle) <tr> - <td class="mono">{{ Firefly\Admin\Format::shortClass($cycle['from']) }}</td> - <td class="mono">{{ Firefly\Admin\Format::shortClass($cycle['to']) }}</td> + <td class="mono">{{ Format::shortClass($cycle['from']) }}</td> + <td class="mono">{{ Format::shortClass($cycle['to']) }}</td> </tr> @endforeach </tbody> @@ -64,15 +94,18 @@ interface is drawn to the bean that actually implements it and labelled with the </div> <p class="note" style="padding:0 14px 12px;margin:0">The container has no cycle detection, so a cycle among eager singletons exhausts memory at boot rather than reporting itself. Break one of - these edges — usually by injecting an interface and letting the other side depend on that.</p> + these edges — usually by depending on an interface and letting the other side provide it.</p> </div> @endif <div class="panel" style="margin-top:16px"> - @include('firefly-admin::_panel-head', [ - 'title' => 'Wiring', 'count' => count($graph->nodes).' beans', - 'filter' => 'graph-body', 'placeholder' => 'Highlight a bean…', - ]) + <header> + <h2>Wiring</h2> + <span class="spacer"></span> + <input class="filter" type="search" id="graph-find" placeholder="Find a bean…" aria-label="Find a bean"> + <button class="tool" type="button" id="g-fit" title="Fit the whole graph">Fit</button> + <button class="tool" type="button" id="g-reset" title="Clear the selection and filters">Reset</button> + </header> @if ($graph->nodes === []) @include('firefly-admin::_empty', [ @@ -85,48 +118,68 @@ interface is drawn to the bean that actually implements it and labelled with the 'body' => 'This application has '.count($graph->nodes).' beans. A diagram past '.$settings->graphMaxNodes.' nodes is a hairball rather than something you can read, so the relations are listed below instead. Raise <code>firefly.admin.graph.max-nodes</code> to draw it anyway.', ]) @else - <div class="canvas"> - <svg viewBox="0 0 {{ (int) $canvasW }} {{ (int) $canvasH }}" width="{{ (int) $canvasW }}" height="{{ (int) $canvasH }}" - role="img" aria-label="Bean dependency graph, {{ count($graph->nodes) }} beans and {{ count($graph->edges) }} relations"> - <defs> - <marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> - <path d="M0,0 L8,4 L0,8 z" fill="currentColor"/> - </marker> - </defs> - - <g class="edges"> - @foreach ($graph->edges as $edge) - @continue (! isset($at[$edge['from']], $at[$edge['to']])) - @php - $a = $at[$edge['from']]; $b = $at[$edge['to']]; - $x1 = $a['x'] + $nodeW / 2; $y1 = $a['y'] + $nodeH; - $x2 = $b['x'] + $nodeW / 2; $y2 = $b['y']; - $mid = ($y1 + $y2) / 2; - @endphp - <path class="edge {{ $edge['via'] !== null ? 'via' : '' }}" - data-from="{{ $edge['from'] }}" data-to="{{ $edge['to'] }}" - d="M{{ round($x1, 1) }},{{ round($y1, 1) }} C{{ round($x1, 1) }},{{ round($mid, 1) }} {{ round($x2, 1) }},{{ round($mid, 1) }} {{ round($x2, 1) }},{{ round($y2, 1) }}" - marker-end="url(#arrow)"> - @if ($edge['via'] !== null) - <title>via {{ $edge['via'] }} - @endif - - @endforeach - - - - @foreach ($graph->nodes as $node) - @php $pos = $at[$node['id']]; @endphp - - {{ $node['id'] }} — {{ $node['stereotype'] }}, {{ $node['scope'] }} · {{ $node['in'] }} in, {{ $node['out'] }} out - - {{ \Illuminate\Support\Str::limit($node['label'], 24) }} - {{ $node['stereotype'] }} · {{ $node['in'] }}↑ {{ $node['out'] }}↓ +
+ @foreach ($modules as $module) + + @endforeach +
+ +
+
+ + + + + + + + + @foreach ($graph->edges as $edge) + @continue (! isset($at[$edge['from']], $at[$edge['to']])) + @php + $a = $at[$edge['from']]; $b = $at[$edge['to']]; + $x1 = $a['x'] + $nodeW / 2; $y1 = $a['y'] + $nodeH; + $x2 = $b['x'] + $nodeW / 2; $y2 = $b['y']; + $mid = ($y1 + $y2) / 2; + @endphp + + {{ Format::shortClass($edge['from']) }} → {{ Format::shortClass($edge['to']) }}{{ $edge['via'] !== null ? ' (via '.Format::shortClass($edge['via']).')' : '' }} + + @endforeach + + + @foreach ($graph->nodes as $node) + @php + $pos = $at[$node['id']]; + $module = BeanGraph::moduleOf($node['id']); + @endphp + + {{ $node['id'] }}{{ $node['detail'] !== '' ? ' — '.$node['detail'] : '' }} + + + {{ \Illuminate\Support\Str::limit($node['label'], 22) }} + {{ $node['kind'] }} · {{ $node['in'] }}↑ {{ $node['out'] }}↓ + + @endforeach - @endforeach - - + + +
Drag to pan · scroll to zoom · click a bean to focus it
+
+ +
@endif @@ -139,18 +192,19 @@ interface is drawn to the bean that actually implements it and labelled with the @if ($graph->edges === []) @include('firefly-admin::_empty', [ 'title' => 'No relations found', - 'body' => 'Every bean here is constructed without depending on another bean. Constructor parameters typed as scalars are configuration, not wiring, and are deliberately not edges.', + 'body' => 'Every bean here is built without depending on another. Constructor parameters typed as scalars are configuration, not wiring, and are deliberately not edges.', ]) @else
- + @foreach ($graph->edges as $edge) - - - + + + + @endforeach @@ -164,12 +218,219 @@ interface is drawn to the bean that actually implements it and labelled with the @include('firefly-admin::_panel-head', ['title' => 'Provided outside the container', 'count' => count($graph->unresolved)])
@foreach ($graph->unresolved as $type) - {{ Firefly\Admin\Format::shortClass($type) }} + {{ Format::shortClass($type) }} @endforeach

These constructor types are satisfied by a Laravel container binding rather than a scanned bean — the request, the config repository, a - connection — so they are not drawn as nodes.

+ database connection — so they are not drawn as nodes.

@endif + + @push('scripts') + + @endpush @endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index a5ddbbf..e811a30 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -195,9 +195,17 @@ .panel > header .spacer{flex:1} .panel > header .meta{font-family:var(--mono);font-size:11.5px;color:var(--ink-3);font-variant-numeric:tabular-nums} - .grid{display:grid;gap:16px} - .grid.two{grid-template-columns:repeat(auto-fit,minmax(340px,1fr))} - .grid.three{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))} + /* align-items:start so a short panel does not stretch to match a tall neighbour and leave a void + under its own content — the single biggest source of dead space on a wide screen. */ + .grid{display:grid;gap:16px;align-items:start} + .grid.two{grid-template-columns:repeat(auto-fit,minmax(min(100%,380px),1fr))} + .grid.three{grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr))} + /* A dashboard on a 1920 screen should show MORE, not the same two panels stretched to 840px each. + auto-fit only creates as many tracks as there are children, so the overview supplies enough + panels to fill them. */ + @media(min-width:1500px){ + .grid.two{grid-template-columns:repeat(auto-fit,minmax(min(100%,440px),1fr))} + } /* ── stat strip ──────────────────────────────────────────────────── */ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr)); @@ -227,6 +235,10 @@ td.tight{width:1%;white-space:nowrap} .dim{color:var(--ink-3)} .wrap{overflow-wrap:anywhere} + /* Long free-text cells stop growing past a readable measure instead of stretching the row to the + full window width, which on a 1920 screen put a label at x=270 and its value at x=1855. */ + td.text{max-width:64ch} + td.num.pin{width:1%} /* A fully-qualified class name has no spaces, so overflow-wrap:anywhere breaks it mid-word — "SecurityHeadersFilte / r". Showing the short name on its own line and eliding the namespace under @@ -279,18 +291,66 @@ .act:hover{border-color:var(--accent);color:var(--accent)} /* ── bean graph ──────────────────────────────────────────────────── */ + .legend{display:flex;flex-wrap:wrap;gap:6px;padding:11px 14px;border-bottom:1px solid var(--line);background:var(--panel-2)} + .mod{ + display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 9px;border-radius:999px; + border:1px solid var(--line-2);background:transparent;color:var(--ink-2);cursor:pointer; + font-family:var(--mono);font-size:11px; + } + .mod i{width:8px;height:8px;border-radius:2px;background:hsl(var(--hue) 62% 46%);flex:none} + .mod:hover{border-color:var(--ink-3);color:var(--ink)} + .mod[aria-pressed="false"]{opacity:.42;text-decoration:line-through} + + .graph{display:grid;grid-template-columns:minmax(0,1fr) 268px} + @media(max-width:1100px){.graph{grid-template-columns:1fr}} + .graph .canvas{position:relative;height:min(72vh,720px);overflow:hidden;background:var(--panel-2);cursor:grab;touch-action:none} + .graph .canvas.grabbing{cursor:grabbing} + .graph .canvas svg{width:100%;height:100%;display:block} + .hint-bar{ + position:absolute;left:10px;bottom:8px;font-size:11px;color:var(--ink-3); + background:color-mix(in srgb, var(--panel) 84%, transparent);padding:3px 8px;border-radius:6px; + pointer-events:none; + } + .inspect{border-left:1px solid var(--line);padding:12px 14px;overflow:hidden auto;height:min(72vh,720px);background:var(--panel);min-width:0} + @media(max-width:1100px){.inspect{border-left:0;border-top:1px solid var(--line);height:auto;max-height:320px}} + .inspect .blank{color:var(--ink-3);font-size:13px;padding:18px 0} + .inspect .who{margin-bottom:12px} + .inspect .who strong{display:block;font-size:14.5px} + .inspect .who code{display:block;margin-top:4px;font-size:10.5px;overflow-wrap:anywhere;background:none;border:0;padding:0;color:var(--ink-3)} + .inspect h4{margin:12px 0 5px;font-size:10px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3)} + .inspect h4 span{color:var(--ink-2);letter-spacing:0} + .inspect ul{list-style:none;margin:0;padding:0} + .inspect li button{ + display:block;width:100%;text-align:left;background:none;border:0;padding:3px 0;cursor:pointer; + font-family:var(--mono);font-size:11.5px;color:var(--accent); + overflow-wrap:anywhere;line-height:1.4; + } + .inspect li button:hover{text-decoration:underline} + .inspect .none{margin:0;font-size:12.5px;color:var(--ink-3)} .canvas{overflow:auto;padding:14px;background:var(--panel-2);max-height:70vh} .canvas svg{display:block;margin-inline:auto} - .edges .edge{fill:none;stroke:var(--line-2);stroke-width:1.4;color:var(--line-2);transition:stroke .12s,opacity .12s} + .edges .edge{fill:none;stroke:var(--line-2);stroke-width:1.3;color:var(--line-2);transition:stroke .12s,opacity .12s} .edges .edge.via{stroke-dasharray:4 3} - .edges .edge.lit{stroke:var(--accent);color:var(--accent);stroke-width:2} - .edges .edge.dimmed{opacity:.15} - .nodes .node rect{fill:var(--panel);stroke:var(--line-2);stroke-width:1.2;transition:stroke .12s,fill .12s} - .nodes .node text{font-family:var(--mono);font-size:11.5px;fill:var(--ink);pointer-events:none} - .nodes .node text.sub{font-size:10px;fill:var(--ink-3)} + /* A `produces` edge is structure, not a dependency the author wrote — drawn quieter so the wiring + the reader came to see stays the loudest thing on the canvas. */ + .edges .edge.produces{stroke-dasharray:1 4;opacity:.55} + .edges .edge.lit{stroke:var(--accent);color:var(--accent);stroke-width:2;opacity:1} + .edges .edge.dimmed{opacity:.08} + .edges .edge.off{display:none} + .nodes .node{cursor:pointer} + .nodes .node rect{fill:var(--panel);stroke:var(--line-2);stroke-width:1.1;transition:stroke .12s,fill .12s} + .nodes .node .stripe{fill:hsl(var(--hue) 62% 46%);stroke:none} + .nodes .node text{font-family:var(--mono);font-size:11px;fill:var(--ink);pointer-events:none} + .nodes .node text.sub{font-size:9.5px;fill:var(--ink-3)} + /* A #[Bean] product is a value a factory returns, not a class the scanner found — dashed says so. */ + .nodes .node.k-bean rect{stroke-dasharray:3 2} + .nodes .node.k-config rect{fill:var(--hover)} .nodes .node:hover rect,.nodes .node.lit rect{stroke:var(--accent);fill:var(--accent-soft)} - .nodes .node.dimmed{opacity:.25} + .nodes .node.picked rect{stroke:var(--accent);stroke-width:2} + .nodes .node.dimmed{opacity:.22} + .nodes .node.off{display:none} + .nodes .node:focus-visible rect{stroke:var(--accent);stroke-width:2} [hidden]{display:none!important} @@ -476,5 +536,12 @@ function focus(id) { }); })(); + +{{-- + Page-specific scripts. Without this stack a view's @push('scripts') block is silently DISCARDED — which + is exactly what happened to the bean graph: its pan/zoom, selection and module filtering were pushed + here, nothing rendered them, and the page looked static with no error anywhere to say why. +--}} +@stack('scripts') diff --git a/packages/admin/resources/views/overview.blade.php b/packages/admin/resources/views/overview.blade.php index b7e9b88..f87f7b2 100644 --- a/packages/admin/resources/views/overview.blade.php +++ b/packages/admin/resources/views/overview.blade.php @@ -51,7 +51,7 @@ - - + @endforeach @@ -128,7 +128,7 @@ @foreach (array_slice($metrics, 0, 8) as $metric) - + @endforeach diff --git a/packages/admin/src/BeanGraph.php b/packages/admin/src/BeanGraph.php index 04b30ca..ec45faa 100644 --- a/packages/admin/src/BeanGraph.php +++ b/packages/admin/src/BeanGraph.php @@ -5,29 +5,49 @@ namespace Firefly\Admin; /** - * Turns the beans catalogue into a directed graph a browser can draw and a person can reason about. + * The application's wiring as a directed graph a browser can draw and a person can reason about. * - * THE HARD PART IS NOT DRAWING, IT IS RESOLVING. A constructor asks for a TYPE, and that type is very often - * an interface — `EventPublisher`, `HealthIndicator`, `Cache` — while the bean that satisfies it is a - * concrete class that merely implements it. An edge list built naively from constructor types therefore - * points at nodes that do not exist, and the graph comes out as a field of disconnected dots. Every - * dependency here is resolved through an interface index first, so `PostgresEventPublisher` is what - * `EventPublisher` actually links to, and the edge is marked `via` so the reader can see the indirection - * rather than being quietly shown something they did not write. + * WHAT COUNTS AS A NODE, AND WHY THE FIRST VERSION WAS NEARLY EMPTY. A LaraFly application has three kinds + * of bean, and the first version of this class only knew about one: * - * Layering is a longest-path assignment over the resolved edges: a node sits one level below the deepest - * thing that depends on it, so arrows flow consistently downward and the eye can follow a chain. Cycles - * cannot hang it — the walk carries its own visited set and simply stops, and the offending edge is reported - * so a genuine circular dependency shows up as a fact about the application rather than a hung page. + * component a scanned #[Component]/#[Service]/#[Repository]/#[RestController] class + * bean a value produced by a #[Bean] factory method on a #[Configuration] + * config a #[ConfigProperties] DTO bound from configuration + * + * Only components were nodes. But a FRAMEWORK's wiring lives almost entirely in the second kind — an + * auto-configuration is a #[Configuration] whose #[Bean] methods produce MeterRegistry, TransactionTemplate, + * AggregateTracker and so on — so every edge pointing at one of those pointed at a node that did not exist. + * Measured on a stock skeleton: 42 nodes, 41 #[Bean] products missing, 21 dangling dependencies, and exactly + * ONE edge drawn. The graph was not "sparse", it was structurally incapable of showing framework wiring. + * + * THE SECOND REASON EDGES VANISH IS INTERFACES. A constructor asks for a TYPE, and that type is usually an + * interface — EventPublisher, HealthIndicator, Cache — while the bean satisfying it is a concrete class or a + * factory return. Every dependency is therefore resolved through an interface index, and the edge records + * the interface in `via` so the reader sees the indirection rather than being quietly shown something they + * did not write. + * + * Layering is a longest-path assignment over the resolved edges, so a node sits below everything that + * depends on it and arrows read downward. The walk carries its own visited set, so a cycle terminates and + * the edge that closed it is REPORTED — which matters, because the container has no cycle detection and a + * cycle among eager singletons exhausts memory at boot. */ final class BeanGraph { - /** A graph past this many nodes is a hairball, not a diagram, so the view offers a filter instead. */ - public const MAX_RENDERABLE = 220; + public const KIND_COMPONENT = 'component'; + + public const KIND_BEAN = 'bean'; + + public const KIND_CONFIG = 'config'; + + /** A dependency the consumer declared and the container satisfies. */ + public const EDGE_INJECTS = 'injects'; + + /** A #[Configuration] to the value one of its #[Bean] methods produces. */ + public const EDGE_PRODUCES = 'produces'; /** - * @param list $nodes - * @param list $edges + * @param list $nodes + * @param list $edges * @param list $cycles * @param list $unresolved */ @@ -40,34 +60,27 @@ public function __construct( /** * @param array $beans rows as BeansCatalog publishes them + * @param array $configProperties rows as the configprops endpoint publishes them, keyed by class */ - public static function fromCatalog(array $beans): self + public static function build(array $beans, array $configProperties = []): self { - [$rows, $byInterface] = self::index($beans); - - $edges = []; - $unresolved = []; - foreach ($rows as $class => $row) { - foreach ($row['dependencies'] as $dependency) { - $target = isset($rows[$dependency]) ? $dependency : ($byInterface[$dependency] ?? null); - - if ($target === null || $target === $class) { - // A type nothing in the container provides: a framework contract satisfied by a binding - // rather than a bean, or a class the scan never saw. Reported, not silently dropped — - // "why is my bean not in the graph" is exactly the question this page has to answer. - if ($target === null) { - $unresolved[] = $dependency; - } + $index = new BeanGraphIndex; - continue; - } + foreach ($beans as $row) { + if (! is_array($row) || ! is_string($row['class'] ?? null)) { + continue; + } + $index->addComponent($row); + } - $edges[] = ['from' => $class, 'to' => $target, 'via' => $target === $dependency ? null : $dependency]; + foreach ($configProperties as $class => $row) { + if (is_array($row) && is_string($row['class'] ?? $class)) { + $index->addConfigProperties(is_string($row['class'] ?? null) ? $row['class'] : (string) $class); } } - $edges = self::dedupe($edges); - [$levels, $cycles] = self::levels(array_keys($rows), $edges); + [$edges, $unresolved] = $index->edges(); + [$levels, $cycles] = self::levels($index->ids(), $edges); $degree = []; foreach ($edges as $edge) { @@ -76,96 +89,82 @@ public static function fromCatalog(array $beans): self } $nodes = []; - foreach ($rows as $class => $row) { + foreach ($index->nodes() as $id => $node) { $nodes[] = [ - 'id' => $class, - 'label' => Format::shortClass($class), - 'namespace' => rtrim(Format::namespaceOf($class), '\\'), - 'stereotype' => $row['stereotype'], - 'scope' => $row['scope'], - 'level' => $levels[$class] ?? 0, - 'in' => $degree[$class]['in'] ?? 0, - 'out' => $degree[$class]['out'] ?? 0, + ...$node, + 'level' => $levels[$id] ?? 0, + 'in' => $degree[$id]['in'] ?? 0, + 'out' => $degree[$id]['out'] ?? 0, ]; } usort($nodes, static fn (array $a, array $b): int => [$a['level'], $a['label']] <=> [$b['level'], $b['label']]); - return new self($nodes, $edges, $cycles, array_values(array_unique($unresolved))); - } - - public function isRenderable(): bool - { - return count($this->nodes) <= self::MAX_RENDERABLE; + return new self($nodes, $edges, $cycles, $unresolved); } /** + * Kept for the older two-argument shape. + * * @param array $beans - * @return array{0: array}>, 1: array} */ - private static function index(array $beans): array + public static function fromCatalog(array $beans): self { - $rows = []; - $byInterface = []; - - foreach ($beans as $bean) { - if (! is_array($bean) || ! is_string($bean['class'] ?? null)) { - continue; - } - - $class = $bean['class']; - $rows[$class] = [ - 'stereotype' => is_string($bean['stereotype'] ?? null) ? $bean['stereotype'] : '', - 'scope' => is_string($bean['scope'] ?? null) ? $bean['scope'] : '', - 'dependencies' => array_values(array_filter( - is_array($bean['dependencies'] ?? null) ? $bean['dependencies'] : [], - static fn (mixed $d): bool => is_string($d) && $d !== '', - )), - ]; + return self::build($beans); + } - foreach (is_array($bean['interfaces'] ?? null) ? $bean['interfaces'] : [] as $interface) { - // First implementor wins, deterministically: the catalogue is emitted in scan order, so the - // same application always draws the same graph. An interface with several implementors is a - // real ambiguity the container resolves with #[Primary]/#[Qualifier], and the graph says so - // by listing the edge as `via` rather than pretending the choice was obvious. - if (is_string($interface) && ! isset($byInterface[$interface])) { - $byInterface[$interface] = $class; - } - } + /** @return array node count per kind, for the page's summary */ + public function kindCounts(): array + { + $counts = [self::KIND_COMPONENT => 0, self::KIND_BEAN => 0, self::KIND_CONFIG => 0]; + foreach ($this->nodes as $node) { + $counts[$node['kind']] = ($counts[$node['kind']] ?? 0) + 1; } - return [$rows, $byInterface]; + return $counts; } /** - * @param list $edges - * @return list + * The namespace roots present, most-populated first — the drawing colours by module, and a legend has to + * name them. + * + * @return list */ - private static function dedupe(array $edges): array + public function modules(): array { - $seen = []; - $out = []; - foreach ($edges as $edge) { - $key = $edge['from'].'>'.$edge['to']; - if (! isset($seen[$key])) { - $seen[$key] = true; - $out[] = $edge; - } + $counts = []; + foreach ($this->nodes as $node) { + $module = self::moduleOf($node['id']); + $counts[$module] = ($counts[$module] ?? 0) + 1; } - return $out; + arsort($counts); + + return array_keys($counts); + } + + /** The first two namespace segments — `Firefly\Observability`, `App\Http` — which is how a reader groups. */ + public static function moduleOf(string $id): string + { + $parts = explode('\\', ltrim($id, '\\')); + + return match (true) { + count($parts) <= 1 => '(global)', + count($parts) === 2 => $parts[0], + default => $parts[0].'\\'.$parts[1], + }; } /** - * Longest-path layering, so a node always sits below everything that depends on it and arrows read - * downward. Depth is memoised and the walk carries a visited set, so a cycle terminates instead of - * recursing forever — and the edge that closed it is reported. + * Longest-path layering, so a node always sits below everything that depends on it. Depth is memoised and + * the walk carries a visited set, so a cycle terminates instead of recursing forever — and the edge that + * closed it is reported. * - * @param list $classes - * @param list $edges + * @param list $ids + * @param list $edges * @return array{0: array, 1: list} */ - private static function levels(array $classes, array $edges): array + private static function levels(array $ids, array $edges): array { $out = []; foreach ($edges as $edge) { @@ -180,7 +179,7 @@ private static function levels(array $classes, array $edges): array return $depth[$node]; } if (isset($path[$node])) { - return 0; // the caller records the closing edge + return 0; } $path[$node] = true; @@ -197,37 +196,28 @@ private static function levels(array $classes, array $edges): array return $depth[$node] = $deepest; }; - foreach ($classes as $class) { - $walk($class, []); + foreach ($ids as $id) { + $walk($id, []); } // Depth counts how far a node's longest chain of dependencies runs; the drawing wants the opposite, - // with dependents on top. Flip it so level 0 is the thing nothing depends on. + // with dependents on top. Flip it so level 0 is what nothing depends on. $max = $depth === [] ? 0 : max($depth); $levels = []; - foreach ($depth as $class => $value) { - $levels[$class] = $max - $value; + foreach ($depth as $id => $value) { + $levels[$id] = $max - $value; } - return [$levels, self::dedupeCycles($cycles)]; - } - - /** - * @param list $cycles - * @return list - */ - private static function dedupeCycles(array $cycles): array - { $seen = []; - $out = []; + $unique = []; foreach ($cycles as $cycle) { $key = $cycle['from'].'>'.$cycle['to']; if (! isset($seen[$key])) { $seen[$key] = true; - $out[] = $cycle; + $unique[] = $cycle; } } - return $out; + return [$levels, $unique]; } } diff --git a/packages/admin/src/BeanGraphIndex.php b/packages/admin/src/BeanGraphIndex.php new file mode 100644 index 0000000..e43f11e --- /dev/null +++ b/packages/admin/src/BeanGraphIndex.php @@ -0,0 +1,232 @@ + */ + private array $nodes = []; + + /** @var array interface or produced type => the node id that satisfies it */ + private array $satisfiedBy = []; + + /** @var list, type: string}> */ + private array $pending = []; + + /** @var array how many factory methods produce each type */ + private array $producerCount = []; + + /** + * @param array $row + */ + public function addComponent(array $row): void + { + /** @var string $class */ + $class = $row['class']; + + $this->put($class, [ + 'id' => $class, + 'label' => Format::shortClass($class), + 'namespace' => rtrim(Format::namespaceOf($class), '\\'), + 'kind' => BeanGraph::KIND_COMPONENT, + 'stereotype' => is_string($row['stereotype'] ?? null) ? $row['stereotype'] : '', + 'scope' => is_string($row['scope'] ?? null) ? $row['scope'] : '', + 'detail' => '', + ]); + + foreach ($this->strings($row['interfaces'] ?? null) as $interface) { + $this->satisfy($interface, $class); + } + + $this->pending[] = [ + 'from' => $class, + 'dependencies' => $this->strings($row['dependencies'] ?? null), + 'type' => BeanGraph::EDGE_INJECTS, + ]; + + foreach ($this->producers($row['produces'] ?? null) as $produced) { + $this->producerCount[$produced['type']] = ($this->producerCount[$produced['type']] ?? 0) + 1; + } + + foreach ($this->producers($row['produces'] ?? null) as $produced) { + $this->addBean($class, $produced); + } + } + + public function addConfigProperties(string $class): void + { + // A #[ConfigProperties] DTO is bound and injectable but is neither scanned as a component nor + // produced by a factory, so nothing else here would ever create a node for it — which is why + // `App\GreetingProperties` showed up as an unresolved dependency of GreetingService rather than as + // the bean it is. + $this->put($class, [ + 'id' => $class, + 'label' => Format::shortClass($class), + 'namespace' => rtrim(Format::namespaceOf($class), '\\'), + 'kind' => BeanGraph::KIND_CONFIG, + 'stereotype' => 'config-properties', + 'scope' => 'Singleton', + 'detail' => 'bound from configuration', + ]); + + $this->satisfy($class, $class); + } + + /** + * @param array{type: string, method: string, dependencies: list} $produced + */ + private function addBean(string $declaring, array $produced): void + { + $contested = ($this->producerCount[$produced['type']] ?? 0) > 1; + $id = $contested ? $declaring.'::'.$produced['method'].'()' : $produced['type']; + + $this->put($id, [ + 'id' => $id, + 'label' => Format::shortClass($produced['type']), + 'namespace' => rtrim(Format::namespaceOf($produced['type']), '\\'), + 'kind' => BeanGraph::KIND_BEAN, + 'stereotype' => 'bean', + 'scope' => 'Singleton', + 'detail' => Format::shortClass($declaring).'::'.$produced['method'].'()', + ]); + + // The produced type resolves to this node. With competitors, first-writer-wins gives the bare type a + // stable owner while each competitor keeps its own node — the same shape the container itself has, + // where the type key aliases the #[Primary] winner and every candidate stays reachable by name. + $this->satisfy($produced['type'], $id); + + $this->pending[] = ['from' => $declaring, 'dependencies' => [$id], 'type' => BeanGraph::EDGE_PRODUCES]; + $this->pending[] = ['from' => $id, 'dependencies' => $produced['dependencies'], 'type' => BeanGraph::EDGE_INJECTS]; + } + + /** @return list */ + public function ids(): array + { + return array_keys($this->nodes); + } + + /** @return array */ + public function nodes(): array + { + return $this->nodes; + } + + /** + * Every declared dependency resolved onto the node set, plus the types nothing here provides. + * + * An unresolved type is reported rather than dropped: it is almost always a Laravel container binding + * (the Request, the config repository, a database connection) rather than a bean, and "why is my bean + * not in the graph" is exactly the question this page exists to answer. + * + * @return array{0: list, 1: list} + */ + public function edges(): array + { + $edges = []; + $seen = []; + $unresolved = []; + + foreach ($this->pending as $entry) { + foreach ($entry['dependencies'] as $dependency) { + $target = $this->resolve($dependency); + + if ($target === null) { + $unresolved[] = $dependency; + + continue; + } + + if ($target === $entry['from']) { + continue; + } + + $key = $entry['from'].'>'.$target.'>'.$entry['type']; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $edges[] = [ + 'from' => $entry['from'], + 'to' => $target, + 'via' => $target === $dependency ? null : $dependency, + 'type' => $entry['type'], + ]; + } + } + + return [$edges, array_values(array_unique($unresolved))]; + } + + private function resolve(string $type): ?string + { + return isset($this->nodes[$type]) ? $type : ($this->satisfiedBy[$type] ?? null); + } + + /** First writer wins, so the same application always draws the same graph. */ + private function satisfy(string $type, string $nodeId): void + { + $this->satisfiedBy[$type] ??= $nodeId; + } + + /** + * @param array{id: string, label: string, namespace: string, kind: string, stereotype: string, scope: string, detail: string} $node + */ + private function put(string $id, array $node): void + { + $this->nodes[$id] ??= $node; + } + + /** + * @return list}> + */ + private function producers(mixed $produces): array + { + if (! is_array($produces)) { + return []; + } + + $out = []; + foreach ($produces as $entry) { + if (! is_array($entry) || ! is_string($entry['type'] ?? null) || $entry['type'] === '') { + continue; + } + + $out[] = [ + 'type' => $entry['type'], + 'method' => is_string($entry['method'] ?? null) ? $entry['method'] : 'bean', + 'dependencies' => $this->strings($entry['dependencies'] ?? null), + ]; + } + + return $out; + } + + /** @return list */ + private function strings(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_filter( + $value, + static fn (mixed $item): bool => is_string($item) && $item !== '', + )); + } +} diff --git a/packages/admin/src/Data/DataBrowserSettings.php b/packages/admin/src/Data/DataBrowserSettings.php new file mode 100644 index 0000000..f92cec9 --- /dev/null +++ b/packages/admin/src/Data/DataBrowserSettings.php @@ -0,0 +1,105 @@ + $excluded resource slugs hidden from the menu and refused by every operation + */ + public function __construct( + public bool $enabled = false, + public bool $writable = false, + public int $pageSize = 25, + public int $maxPageSize = 200, + public array $excluded = [], + ) {} + + public static function fromConfig(Config $config): self + { + $max = min(self::PAGE_SIZE_CEILING, max(1, $config->int('firefly.admin.data.max-page-size', 200))); + + return new self( + enabled: $config->bool('firefly.admin.data.enabled', false), + writable: $config->bool('firefly.admin.data.writable', false), + pageSize: min($max, max(1, $config->int('firefly.admin.data.page-size', 25))), + maxPageSize: $max, + excluded: self::csv($config->string('firefly.admin.data.exclude', '')), + ); + } + + /** + * Writing requires BOTH gates. Kept as a predicate rather than a precomputed flag so the two config keys + * stay separately readable on the settings object — a page that shows "browser: on, writes: off" is + * telling the operator something a single collapsed boolean could not. + */ + public function canWrite(): bool + { + return $this->enabled && $this->writable; + } + + /** + * Whether a resource may be reached at all. + * + * `firefly.admin.data.exclude` is a hard refusal, not a menu preference — the resource is hidden AND + * every operation on it is refused, the same contract AdminSettings::allows() gives page slugs. An + * application that hides `user` because the table holds PII has achieved nothing if the row URL still + * answers. + */ + public function allows(string $slug): bool + { + return ! in_array($slug, $this->excluded, true); + } + + /** Clamp a caller-supplied page size into [1, maxPageSize]; null means "use the configured default". */ + public function clampPageSize(?int $requested): int + { + if ($requested === null) { + return $this->pageSize; + } + + return min($this->maxPageSize, max(1, $requested)); + } + + /** @return list */ + private static function csv(string $value): array + { + return array_values(array_filter( + array_map(static fn (string $part): string => strtolower(trim($part)), explode(',', $value)), + static fn (string $part): bool => $part !== '', + )); + } +} diff --git a/packages/admin/src/Data/DataColumn.php b/packages/admin/src/Data/DataColumn.php new file mode 100644 index 0000000..ecae68c --- /dev/null +++ b/packages/admin/src/Data/DataColumn.php @@ -0,0 +1,99 @@ + Actuator and a second copy of a masking list is how a masking list rots (see that class's own + * docblock for the argument). A column called `api_token` is masked in the listing, in the detail view, and + * is refused as an update target; see DataBrowser::update() for why the refusal matters as much as the mask. + */ +final readonly class DataColumn +{ + public const string TYPE_STRING = 'string'; + + public const string TYPE_INT = 'int'; + + public const string TYPE_BOOL = 'bool'; + + public const string TYPE_DATETIME = 'datetime'; + + public const string TYPE_JSON = 'json'; + + public function __construct( + public string $name, + public string $type = self::TYPE_STRING, + public bool $nullable = true, + public bool $identifier = false, + public bool $sensitive = false, + ) {} + + /** + * The named constructor every derivation path goes through, so sensitivity can never be forgotten by a + * caller that happened to build a DataColumn by hand. + */ + public static function of(string $name, string $type = self::TYPE_STRING, bool $nullable = true, bool $identifier = false): self + { + return new self( + name: $name, + type: self::normalizeType($type), + nullable: $nullable, + identifier: $identifier, + sensitive: SensitiveValueMasker::isSensitive($name), + ); + } + + /** + * A column may be written from the browser only when it is neither the identifier nor a secret. + * + * The identifier is excluded because re-keying a row from a generic form is not an edit, it is a + * different row: foreign keys pointing at the old value do not follow, and the browser has no way to know + * which ones exist. The secret is excluded because its DISPLAYED value is `******` — round-tripping a + * rendered form would write the mask over the real credential, which is a data-loss bug the masking + * itself created. Both refusals are enforced again in DataBrowser::update(); this predicate exists so the + * view can render the field as read-only instead of offering an edit that will be rejected. + */ + public function isEditable(): bool + { + return ! $this->identifier && ! $this->sensitive; + } + + /** `created_at` => `Created at`. Snake and kebab both split; nothing else is guessed. */ + public function label(): string + { + $words = preg_split('/[_\-]+/', $this->name) ?: [$this->name]; + + return ucfirst(implode(' ', array_filter($words, static fn (string $word): bool => $word !== ''))); + } + + /** Any type name outside the closed vocabulary degrades to `string` rather than reaching the view. */ + private static function normalizeType(string $type): string + { + return in_array($type, [self::TYPE_STRING, self::TYPE_INT, self::TYPE_BOOL, self::TYPE_DATETIME, self::TYPE_JSON], true) + ? $type + : self::TYPE_STRING; + } +} diff --git a/packages/admin/src/Data/DataResource.php b/packages/admin/src/Data/DataResource.php new file mode 100644 index 0000000..24811da --- /dev/null +++ b/packages/admin/src/Data/DataResource.php @@ -0,0 +1,55 @@ +entityClass + */ + public function isEloquentBacked(): bool + { + return $this->eloquent && $this->entityClass !== null; + } + + /** The short class name of whatever the resource is "of", for headings and breadcrumbs. */ + public function shortName(): string + { + $class = $this->entityClass ?? $this->repositoryClass; + $position = strrpos($class, '\\'); + + return $position === false ? $class : substr($class, $position + 1); + } +} diff --git a/packages/admin/src/Data/DataResourceRegistry.php b/packages/admin/src/Data/DataResourceRegistry.php new file mode 100644 index 0000000..0aa90fd --- /dev/null +++ b/packages/admin/src/Data/DataResourceRegistry.php @@ -0,0 +1,233 @@ +|null */ + private ?array $resources = null; + + public function __construct( + private readonly ?BeansCatalog $catalog, + private readonly RepositoryIntrospector $introspector, + private readonly DataBrowserSettings $settings, + ) {} + + /** + * Every browsable resource, ordered by label so the menu is stable across boots. + * + * @return list + */ + public function all(): array + { + if ($this->resources !== null) { + return $this->resources; + } + + if (! $this->settings->enabled || $this->catalog === null) { + return $this->resources = []; + } + + $candidates = []; + $seen = []; + foreach ($this->catalog->all() as $bean) { + $class = $bean['class']; + if (isset($seen[$class]) || ! in_array(CrudRepository::class, $bean['interfaces'], true) || ! class_exists($class)) { + continue; + } + + $seen[$class] = true; + $candidates[] = $this->describe($class, in_array(PagingAndSortingRepository::class, $bean['interfaces'], true)); + } + + $resources = []; + foreach ($this->resolveSlugs($candidates) as $resource) { + if ($this->settings->allows($resource->slug)) { + $resources[] = $resource; + } + } + + usort($resources, static fn (DataResource $a, DataResource $b): int => [$a->label, $a->slug] <=> [$b->label, $b->slug]); + + return $this->resources = $resources; + } + + public function get(string $slug): ?DataResource + { + foreach ($this->all() as $resource) { + if ($resource->slug === $slug) { + return $resource; + } + } + + return null; + } + + /** + * @param class-string $class + * @return array{class: class-string, entity: class-string|null, table: string|null, paged: bool, eloquent: bool} + */ + private function describe(string $class, bool $paged): array + { + $model = $this->introspector->modelOf($class); + $eloquent = $model !== null + && is_a($class, EloquentRepository::class, true) + && is_a($model, Model::class, true); + + return [ + 'class' => $class, + 'entity' => $model ?? $this->introspector->entityOf($class), + 'table' => $eloquent && is_a($model, Model::class, true) ? $this->tableOf($model) : null, + 'paged' => $paged, + 'eloquent' => $eloquent, + ]; + } + + /** + * The model's table name, from a bare instance. + * + * Constructing the model is safe and is what EloquentRepository itself does to read the key name: an + * Eloquent constructor takes an optional attribute array and touches no connection. It is still guarded, + * because a model with a hand-written constructor is legal and a discovery pass must not be able to fail + * on one — the resource simply loses its table name and, with it, schema-derived columns. + * + * @param class-string $model + */ + private function tableOf(string $model): ?string + { + try { + return (new $model)->getTable(); + } catch (Throwable) { + return null; + } + } + + /** + * Assign slugs and labels, qualifying every member of a colliding group rather than suffixing one. + * + * @param list $candidates + * @return list + */ + private function resolveSlugs(array $candidates): array + { + $counts = []; + foreach ($candidates as $candidate) { + $base = self::baseSlug($candidate['entity'], $candidate['class']); + $counts[$base] = ($counts[$base] ?? 0) + 1; + } + + $resources = []; + foreach ($candidates as $candidate) { + $named = $candidate['entity'] ?? $candidate['class']; + $base = self::baseSlug($candidate['entity'], $candidate['class']); + $collides = ($counts[$base] ?? 0) > 1; + + $resources[] = new DataResource( + slug: $collides ? self::qualifiedSlug($named) : $base, + label: $collides ? self::label($base).' ('.self::namespaceOf($named).')' : self::label($base), + repositoryClass: $candidate['class'], + entityClass: $candidate['entity'], + table: $candidate['table'], + paged: $candidate['paged'], + eloquent: $candidate['eloquent'], + ); + } + + return $resources; + } + + /** + * @param class-string|null $entity + * @param class-string $repository + */ + private static function baseSlug(?string $entity, string $repository): string + { + if ($entity !== null) { + return self::kebab(self::shortName($entity)); + } + + // No entity type to name the resource after, so name it after the repository with the two + // conventions the framework's own sample uses stripped: EloquentWalletRepository => wallet. + $short = self::shortName($repository); + $short = preg_replace('/^Eloquent/', '', $short) ?? $short; + $short = preg_replace('/Repository$/', '', $short) ?? $short; + + return self::kebab($short === '' ? self::shortName($repository) : $short); + } + + private static function qualifiedSlug(string $class): string + { + $parts = array_map(self::kebab(...), explode('\\', trim($class, '\\'))); + + return implode('-', array_filter($parts, static fn (string $part): bool => $part !== '')); + } + + private static function shortName(string $class): string + { + $position = strrpos($class, '\\'); + + return $position === false ? $class : substr($class, $position + 1); + } + + private static function namespaceOf(string $class): string + { + $position = strrpos($class, '\\'); + + return $position === false ? '' : substr($class, 0, $position); + } + + /** `OrderLine` => `order-line`, `APIKey` => `api-key`. */ + private static function kebab(string $name): string + { + $spaced = preg_replace(['/([a-z\d])([A-Z])/', '/([A-Z]+)([A-Z][a-z])/'], '$1-$2', $name) ?? $name; + + return strtolower((string) preg_replace('/[^A-Za-z0-9]+/', '-', $spaced)); + } + + /** `order-line` => `Order Line`. */ + private static function label(string $slug): string + { + return ucwords(str_replace('-', ' ', $slug)); + } +} diff --git a/packages/admin/src/Data/DataSchema.php b/packages/admin/src/Data/DataSchema.php new file mode 100644 index 0000000..2fe404f --- /dev/null +++ b/packages/admin/src/Data/DataSchema.php @@ -0,0 +1,115 @@ + $columns + */ + public function __construct( + public array $columns, + public ?string $identifier = null, + public string $source = self::SOURCE_NONE, + ) {} + + public static function empty(): self + { + return new self([], null, self::SOURCE_NONE); + } + + public function isEmpty(): bool + { + return $this->columns === []; + } + + public function has(string $name): bool + { + return $this->column($name) !== null; + } + + public function column(string $name): ?DataColumn + { + foreach ($this->columns as $column) { + if ($column->name === $name) { + return $column; + } + } + + return null; + } + + /** @return list */ + public function names(): array + { + return array_map(static fn (DataColumn $column): string => $column->name, $this->columns); + } + + /** + * The columns a free-text search may look in: string-typed and not a secret. + * + * Secrets are excluded from search for the same reason they are masked — a search box that answers "yes, + * some row's api_token starts with sk_live_9" is an oracle, and an operator can walk one character at a + * time. Non-string columns are excluded because a LIKE over an integer or a timestamp is a per-driver + * coercion (sqlite says yes, Postgres says no) and a search box that explodes on one backend and works on + * another is worse than one that only searches text. + * + * @return list + */ + public function searchable(): array + { + return array_values(array_map( + static fn (DataColumn $column): string => $column->name, + array_filter( + $this->columns, + static fn (DataColumn $column): bool => $column->type === DataColumn::TYPE_STRING && ! $column->sensitive, + ), + )); + } + + /** + * The columns an ORDER BY may name. JSON is excluded because ordering a serialized blob sorts its text, + * which looks like it worked and means nothing. + * + * @return list + */ + public function sortable(): array + { + return array_values(array_map( + static fn (DataColumn $column): string => $column->name, + array_filter($this->columns, static fn (DataColumn $column): bool => $column->type !== DataColumn::TYPE_JSON), + )); + } + + /** The identifier column itself, when one was derived AND is present in the column list. */ + public function identifierColumn(): ?DataColumn + { + return $this->identifier === null ? null : $this->column($this->identifier); + } +} diff --git a/packages/admin/src/Data/RepositoryIntrospector.php b/packages/admin/src/Data/RepositoryIntrospector.php new file mode 100644 index 0000000..9e3ce86 --- /dev/null +++ b/packages/admin/src/Data/RepositoryIntrospector.php @@ -0,0 +1,279 @@ + */ + private array $models = []; + + /** @var array */ + private array $entities = []; + + /** @var array> */ + private array $fields = []; + + /** + * The `$model` class-string an EloquentRepository subclass declares, or null when there is none. + * + * Read from the class's default property values, never from an instance: `getDefaultProperties()` reports + * a protected property's initialiser without running the constructor, so this is safe to call for every + * discovered repository during a menu render. The abstract base's own `protected string $model;` has no + * initialiser and is therefore absent from the result — exactly the desired outcome, since the base + * manages nothing. + * + * @param class-string $repositoryClass + * @return class-string|null + */ + public function modelOf(string $repositoryClass): ?string + { + if (array_key_exists($repositoryClass, $this->models)) { + return $this->models[$repositoryClass]; + } + + return $this->models[$repositoryClass] = $this->readModel($repositoryClass); + } + + /** + * @param class-string $repositoryClass + * @return class-string|null + */ + private function readModel(string $repositoryClass): ?string + { + try { + $reflection = new ReflectionClass($repositoryClass); + + if (! $reflection->hasProperty('model') || $reflection->getProperty('model')->isStatic()) { + return null; + } + + $model = $reflection->getDefaultProperties()['model'] ?? null; + } catch (Throwable) { + return null; + } + + return is_string($model) && class_exists($model) ? $model : null; + } + + /** + * The entity class a non-Eloquent repository manages, inferred from the RETURN TYPE it declares. + * + * A repository that means to be browsable narrows `findById(): ?Wallet` (the lumen sample does exactly + * this, and PHP's covariant-return rule is what makes it possible while the parameter stays `mixed`). + * `save()` is consulted as a second source because a repository may narrow one and not the other. The + * base signatures return `?object` / `object`, which carries no information and is rejected, so this + * never reports a bogus entity — it reports null and the resource falls back to a schema-less listing. + * + * @param class-string $repositoryClass + * @return class-string|null + */ + public function entityOf(string $repositoryClass): ?string + { + if (array_key_exists($repositoryClass, $this->entities)) { + return $this->entities[$repositoryClass]; + } + + return $this->entities[$repositoryClass] = $this->readEntity($repositoryClass); + } + + /** + * @param class-string $repositoryClass + * @return class-string|null + */ + private function readEntity(string $repositoryClass): ?string + { + try { + $reflection = new ReflectionClass($repositoryClass); + } catch (Throwable) { + return null; + } + + foreach (['findById', 'save'] as $method) { + if (! $reflection->hasMethod($method)) { + continue; + } + + $entity = $this->classFromReturnType($reflection->getMethod($method)); + if ($entity !== null) { + return $entity; + } + } + + return null; + } + + /** @return class-string|null */ + private function classFromReturnType(ReflectionMethod $method): ?string + { + $type = $method->getReturnType(); + if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { + return null; + } + + $name = $type->getName(); + + // `object`, `static` and `self` are the uninformative answers the base class already gives. + return $name !== 'object' && $name !== 'static' && $name !== 'self' && class_exists($name) ? $name : null; + } + + /** + * The declared fields of a plain entity, in declaration order: promoted constructor parameters first + * (that is the order the author wrote the record in, and the closest thing a PHP class has to a column + * order), then any remaining public properties. + * + * Promoted parameters are included at EVERY visibility while plain properties are included only when + * public. That asymmetry is deliberate: a promoted parameter is part of the type's published construction + * contract — you cannot build the object without supplying it — so it is a field of the record whatever + * its visibility, whereas a private non-promoted property is genuine internal state a browser has no + * business rendering. + * + * @param class-string $entityClass + * @return list + */ + public function fieldsOf(string $entityClass): array + { + if (isset($this->fields[$entityClass])) { + return $this->fields[$entityClass]; + } + + try { + $reflection = new ReflectionClass($entityClass); + } catch (Throwable) { + return $this->fields[$entityClass] = []; + } + + /** @var array $fields */ + $fields = []; + + foreach ($reflection->getConstructor()?->getParameters() ?? [] as $parameter) { + if ($parameter->isPromoted()) { + $fields[$parameter->getName()] = $this->describe($parameter->getName(), $parameter->getType()); + } + } + + foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic() || isset($fields[$property->getName()])) { + continue; + } + + $fields[$property->getName()] = $this->describe($property->getName(), $property->getType()); + } + + return $this->fields[$entityClass] = array_values($fields); + } + + /** + * Read one field off an entity instance, tolerating both shapes an entity comes in: a promoted property + * at any visibility, and a class that exposes only accessors. + * + * `ReflectionProperty::getValue()` ignores accessibility from PHP 8.1 onward (setAccessible() became a + * no-op), so no mutation of the reflection object is needed — nothing here can leave a property + * permanently accessible to anything else. An uninitialised typed property reads as null rather than + * throwing, because "not yet set" is a real state of a hydrated-from-nothing entity and a listing must + * render it as an empty cell, not as a 500. + */ + public function read(object $entity, string $field): mixed + { + try { + $reflection = new ReflectionObject($entity); + + if ($reflection->hasProperty($field)) { + $property = $reflection->getProperty($field); + + return $property->isStatic() || ! $property->isInitialized($entity) ? null : $property->getValue($entity); + } + + foreach ([$field, 'get'.ucfirst($field), 'is'.ucfirst($field)] as $candidate) { + if ($reflection->hasMethod($candidate)) { + $method = $reflection->getMethod($candidate); + if ($method->isPublic() && ! $method->isStatic() && $method->getNumberOfRequiredParameters() === 0) { + return $method->invoke($entity); + } + } + } + } catch (Throwable) { + return null; + } + + return null; + } + + /** + * Map a declared PHP type onto the closed display vocabulary. A union or intersection has no single + * display type — `int|string|null` is the framework's own identifier type — so it degrades to `string`, + * which renders every member correctly and formats none of them wrongly. + * + * @return array{name: string, type: string, nullable: bool} + */ + private function describe(string $name, ?ReflectionType $type): array + { + if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) { + return ['name' => $name, 'type' => DataColumn::TYPE_STRING, 'nullable' => $type->allowsNull()]; + } + + if (! $type instanceof ReflectionNamedType) { + return ['name' => $name, 'type' => DataColumn::TYPE_STRING, 'nullable' => true]; + } + + return ['name' => $name, 'type' => $this->displayType($type), 'nullable' => $type->allowsNull()]; + } + + private function displayType(ReflectionNamedType $type): string + { + if ($type->isBuiltin()) { + return match ($type->getName()) { + 'int' => DataColumn::TYPE_INT, + 'bool' => DataColumn::TYPE_BOOL, + 'array', 'iterable' => DataColumn::TYPE_JSON, + default => DataColumn::TYPE_STRING, + }; + } + + $name = $type->getName(); + + return is_a($name, DateTimeInterface::class, true) ? DataColumn::TYPE_DATETIME : DataColumn::TYPE_STRING; + } +} diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 6d2d7a4..dd109db 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -82,7 +82,13 @@ private function data(string $slug): array 'metrics' => ['metrics' => $this->metrics()], 'http' => ['exchanges' => $this->exchanges()], 'beans' => ['beans' => $this->listOf('beans', 'beans')], - 'graph' => ['graph' => BeanGraph::fromCatalog($this->listOf('beans', 'beans'))], + // #[ConfigProperties] DTOs are bound and injectable but are neither scanned as components nor + // produced by a factory, so the beans catalogue alone cannot see them — they arrived as + // unresolved dependencies instead of as the beans they are. + 'graph' => ['graph' => BeanGraph::build( + $this->listOf('beans', 'beans'), + $this->subArray($this->payload('configprops'), 'beans'), + )], 'conditions' => $this->payload('conditions') + ['positiveMatches' => [], 'negativeMatches' => []], 'mappings' => ['mappings' => $this->listOf('mappings', 'mappings')], 'scheduled' => ['tasks' => $this->listOf('scheduledtasks', 'tasks')], diff --git a/packages/admin/tests/BeanGraphTest.php b/packages/admin/tests/BeanGraphTest.php index 0161ece..30c2e5d 100644 --- a/packages/admin/tests/BeanGraphTest.php +++ b/packages/admin/tests/BeanGraphTest.php @@ -36,7 +36,9 @@ function bean(string $class, array $dependencies = [], array $interfaces = []): it('links a dependency on a concrete class straight to that bean', function () { $graph = graphOf([bean('App\\Controller', ['App\\Service']), bean('App\\Service')]); - expect($graph->edges)->toBe([['from' => 'App\\Controller', 'to' => 'App\\Service', 'via' => null]]); + expect($graph->edges)->toBe([ + ['from' => 'App\\Controller', 'to' => 'App\\Service', 'via' => null, 'type' => BeanGraph::EDGE_INJECTS], + ]); }); // The reason a naive edge list produces a field of disconnected dots: constructors ask for INTERFACES, and @@ -48,7 +50,7 @@ function bean(string $class, array $dependencies = [], array $interfaces = []): ]); expect($graph->edges)->toBe([ - ['from' => 'App\\Publisher', 'to' => 'App\\KafkaTransport', 'via' => 'App\\Contracts\\Transport'], + ['from' => 'App\\Publisher', 'to' => 'App\\KafkaTransport', 'via' => 'App\\Contracts\\Transport', 'type' => BeanGraph::EDGE_INJECTS], ]); }); @@ -129,3 +131,129 @@ function bean(string $class, array $dependencies = [], array $interfaces = []): expect($graph->nodes[0]['label'])->toBe('OrderService') ->and($graph->nodes[0]['namespace'])->toBe('App\\Domain'); }); + +// ───────────────────────────────────────────────────────────────────────────────────────────────────── +// #[Bean] PRODUCTS. The graph's original blind spot: a framework's wiring lives almost entirely in +// #[Configuration] classes whose #[Bean] methods produce the collaborators everything else injects. Only +// declaring classes were nodes, so on a stock skeleton 41 of 42 relations pointed at nodes that did not +// exist and exactly ONE edge was drawn. +// ───────────────────────────────────────────────────────────────────────────────────────────────────── + +/** + * @param list}> $produces + * @param list $dependencies + * @return array + */ +function configuration(string $class, array $produces, array $dependencies = []): array +{ + return [ + 'class' => $class, + 'stereotype' => 'configuration', + 'scope' => 'Singleton', + 'name' => null, + 'interfaces' => [], + 'beans' => array_map(static fn (array $p): string => $p['method'], $produces), + 'dependencies' => $dependencies, + 'produces' => $produces, + ]; +} + +it('makes every #[Bean] product a node of its own', function () { + $graph = BeanGraph::build([ + configuration('App\\Config', [ + ['type' => 'App\\MeterRegistry', 'method' => 'meters', 'dependencies' => []], + ['type' => 'App\\Tracer', 'method' => 'tracer', 'dependencies' => []], + ]), + ]); + + $ids = array_column($graph->nodes, 'id'); + + expect($ids)->toContain('App\\MeterRegistry') + ->and($ids)->toContain('App\\Tracer') + ->and($graph->kindCounts()[BeanGraph::KIND_BEAN])->toBe(2) + ->and($graph->kindCounts()[BeanGraph::KIND_COMPONENT])->toBe(1); +}); + +it('draws a produces edge from the configuration to each product', function () { + $graph = BeanGraph::build([ + configuration('App\\Config', [['type' => 'App\\MeterRegistry', 'method' => 'meters', 'dependencies' => []]]), + ]); + + expect($graph->edges)->toBe([ + ['from' => 'App\\Config', 'to' => 'App\\MeterRegistry', 'via' => null, 'type' => BeanGraph::EDGE_PRODUCES], + ]); +}); + +// The whole point: a component injecting a type that a factory produces must LINK to it. This is the case +// that produced 21 dangling dependencies before #[Bean] products became nodes. +it('links a consumer to the #[Bean] product it injects', function () { + $graph = BeanGraph::build([ + bean('App\\Filter', ['App\\MeterRegistry']), + configuration('App\\Config', [['type' => 'App\\MeterRegistry', 'method' => 'meters', 'dependencies' => []]]), + ]); + + $injects = array_values(array_filter($graph->edges, static fn (array $e): bool => $e['type'] === BeanGraph::EDGE_INJECTS)); + + expect($injects)->toBe([ + ['from' => 'App\\Filter', 'to' => 'App\\MeterRegistry', 'via' => null, 'type' => BeanGraph::EDGE_INJECTS], + ])->and($graph->unresolved)->toBe([]); +}); + +it('draws what a factory method itself depends on', function () { + $graph = BeanGraph::build([ + configuration('App\\Config', [ + ['type' => 'App\\Bus', 'method' => 'bus', 'dependencies' => ['App\\Clock']], + ]), + bean('App\\Clock'), + ]); + + expect($graph->edges)->toContain( + ['from' => 'App\\Bus', 'to' => 'App\\Clock', 'via' => null, 'type' => BeanGraph::EDGE_INJECTS], + ); +}); + +// Two factories producing one type is the shape the container now requires #[Primary]/#[Qualifier] to +// disambiguate. Collapsing them onto the type would hide exactly the ambiguity a reader came to look at. +it('keeps competing producers as separate nodes', function () { + $graph = BeanGraph::build([ + configuration('App\\Config', [ + ['type' => 'App\\Cache', 'method' => 'memory', 'dependencies' => []], + ['type' => 'App\\Cache', 'method' => 'redis', 'dependencies' => []], + ]), + ]); + + $ids = array_column($graph->nodes, 'id'); + + expect($ids)->toContain('App\\Config::memory()') + ->and($ids)->toContain('App\\Config::redis()') + ->and($graph->kindCounts()[BeanGraph::KIND_BEAN])->toBe(2); +}); + +// A #[ConfigProperties] DTO is bound and injectable but is neither scanned nor produced, so nothing else +// creates a node for it — it showed up as an unresolved dependency instead of the bean it is. +it('makes a #[ConfigProperties] DTO a node so its consumers link to it', function () { + $graph = BeanGraph::build( + [bean('App\\GreetingService', ['App\\GreetingProperties'])], + ['App\\GreetingProperties' => ['class' => 'App\\GreetingProperties', 'prefix' => 'greeting']], + ); + + expect($graph->kindCounts()[BeanGraph::KIND_CONFIG])->toBe(1) + ->and($graph->unresolved)->toBe([]) + ->and($graph->edges)->toBe([ + ['from' => 'App\\GreetingService', 'to' => 'App\\GreetingProperties', 'via' => null, 'type' => BeanGraph::EDGE_INJECTS], + ]); +}); + +it('groups a node under the first two namespace segments', function () { + expect(BeanGraph::moduleOf('Firefly\\Observability\\Metrics\\Counter'))->toBe('Firefly\\Observability') + ->and(BeanGraph::moduleOf('App\\Service'))->toBe('App') + ->and(BeanGraph::moduleOf('Bare'))->toBe('(global)'); +}); + +it('orders modules by how many nodes they hold', function () { + $graph = BeanGraph::build([ + bean('Big\\Mod\\A'), bean('Big\\Mod\\B'), bean('Big\\Mod\\C'), bean('Small\\Mod\\A'), + ]); + + expect($graph->modules()[0])->toBe('Big\\Mod'); +}); From e9ede70a2e77e03101311257a482692ec6ec5442 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 18:00:01 -0700 Subject: [PATCH 17/31] feat(admin): a Django-admin-style data browser, wired into the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovers every repository the application declared — a bean implementing CrudRepository — and browses the records behind it: a resource index, a paginated and sortable listing with search, a record view, and edit and delete. It reads THROUGH the repositories rather than dumping tables, so what appears is what the application's own data layer returns. TWO GATES, BOTH VERIFIED BY DRIVING THEM `firefly.admin.data.enabled` defaults to FALSE and `firefly.admin.data.writable` to false on top of it. The dashboard as a whole already defaults to app.debug, and that is not a strong enough gate for customer records — beans and configuration are one thing, the rows in your orders table are another. Exercised against a real sqlite-backed repository with three seeded orders rather than trusting a docblock: with writes off, an update and a delete both leave the row untouched while reads keep working and the edit form disappears; with the browser off, every read AND every write answers 404 — a write used to redirect, which tells the caller the request was understood and merely declined, a different fact from "this does not exist". Sensitive column names are masked: `api_token` renders as ****** and the seeded `sk_live_…` value appears nowhere in the response. Create is deliberately absent. A generic form over arbitrary entities cannot honour constructor invariants, and one that silently bypassed them would be worse than not having it. ROW VALUES ARE RAW; THE COLUMN TYPE IS THE HINT The engine reads Eloquent entities through getAttributes(), so a bool column arrives as int 1 from sqlite and a json column as a string. The views therefore switch on DataColumn::$type and never on the value's PHP type — the reviewer's report was explicit about this and it is the kind of thing that looks fine until a driver changes underneath it. TWO BUGS FOUND BY DRIVING IT DataSchema::searchable()/sortable() return column NAMES, not DataColumn objects; the listing treated them as objects and every list view 500'd with "Attempt to read property name on string". Caught because the listing was exercised end to end rather than only the record page, which happened to work. A stray packages/admin/src/Data/MemberType.php — a byte-identical copy of the openapi package's class, still declaring `namespace Firefly\OpenApi\Schema` — had been written into the wrong package by a concurrent agent. PSR-4 means composer never loads a class from there, so nothing broke at runtime and no package suite failed; it surfaced only as a duplicate class in repo-wide static analysis. Removed, and tests/Psr4LayoutTest.php now checks every src file declares the namespace its package prefix and directory imply — a misfiled class is also how a package silently acquires a dependency deptrac cannot see, because the file claims to belong to the other layer. 1996 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- CHANGELOG.md | 30 ++ README.md | 12 + book/book.es.yaml | 7 + book/book.yaml | 7 + book/src-es/11-observability-actuator.md | 85 ++- book/src/11-observability-actuator.md | 85 ++- deptrac.yaml | 9 + docs/README.md | 3 +- docs/index.md | 6 +- docs/modules/admin.md | 9 +- docs/modules/bean-graph.md | 170 ++++-- docs/modules/data-browser.md | 357 +++++++++++++ docs/modules/data.md | 12 + docs/modules/openapi.md | 392 ++++++++++++-- packages/admin/composer.json | 2 + .../resources/views/data-disabled.blade.php | 11 + .../resources/views/data-index.blade.php | 50 ++ .../admin/resources/views/data-list.blade.php | 141 +++++ .../resources/views/data-missing.blade.php | 12 + .../resources/views/data-record.blade.php | 88 ++++ .../admin/resources/views/layout.blade.php | 22 + .../admin/src/Boot/AdminRouteRegistrar.php | 31 ++ packages/admin/src/Data/DataBrowser.php | 492 ++++++++++++++++++ packages/admin/src/Data/DataListing.php | 90 ++++ packages/admin/src/Data/DataQueryEngine.php | 456 ++++++++++++++++ packages/admin/src/Data/DataRecord.php | 53 ++ packages/admin/src/Data/DataResource.php | 2 - .../admin/src/Data/DataResourceRegistry.php | 14 +- packages/admin/src/Data/DataSchemaFactory.php | 211 ++++++++ packages/admin/src/Data/DataWriteOutcome.php | 23 + packages/admin/src/Data/DataWriteResult.php | 75 +++ .../admin/src/Data/RepositoryIntrospector.php | 12 +- packages/admin/src/Web/AdminAction.php | 117 +++++ packages/admin/src/Web/AdminPage.php | 9 +- .../admin/tests/Data/DataBrowserEdgeTest.php | 234 +++++++++ .../admin/tests/Data/DataBrowserReadTest.php | 226 ++++++++ .../tests/Data/DataBrowserSettingsTest.php | 64 +++ .../admin/tests/Data/DataBrowserWriteTest.php | 221 ++++++++ .../admin/tests/Data/DataDiscoveryTest.php | 163 ++++++ .../admin/tests/Data/Fixtures/AdminRecord.php | 40 ++ .../Data/Fixtures/AdminRecordRepository.php | 18 + .../tests/Data/Fixtures/Alt/AdminRecord.php | 24 + .../Fixtures/Alt/AdminRecordRepository.php | 15 + .../tests/Data/Fixtures/BrokenRepository.php | 77 +++ .../admin/tests/Data/Fixtures/GhostRecord.php | 23 + .../Data/Fixtures/GhostRecordRepository.php | 15 + packages/admin/tests/Data/Fixtures/Money.php | 18 + .../tests/Data/Fixtures/NotARepository.php | 14 + .../tests/Data/Fixtures/OrphanRecord.php | 25 + .../Data/Fixtures/OrphanRecordRepository.php | 15 + packages/admin/tests/Data/Fixtures/Pair.php | 14 + .../tests/Data/Fixtures/PairRepository.php | 97 ++++ .../admin/tests/Data/Fixtures/PlainNote.php | 26 + .../Data/Fixtures/PlainNoteRepository.php | 119 +++++ .../Data/Fixtures/ScopedNoteRepository.php | 110 ++++ packages/admin/tests/Data/Fixtures/Widget.php | 30 ++ .../tests/Data/Fixtures/WidgetRepository.php | 124 +++++ .../tests/Data/Fixtures/WidgetStatus.php | 12 + .../Data/Support/DataBrowserTestCase.php | 255 +++++++++ packages/admin/tests/DataBrowserOffTest.php | 43 ++ packages/admin/tests/DataBrowserPageTest.php | 36 ++ .../tests/Support/DataBrowserOffTestCase.php | 14 + .../tests/Support/DataBrowserTestCase.php | 36 ++ .../Command/Make/MakeControllerCommand.php | 197 ++++++- packages/cli/stubs/controller-request.stub | 50 ++ packages/cli/stubs/controller-resource.stub | 109 ++++ packages/cli/stubs/controller.stub | 23 +- .../Make/GeneratedStubIntegrityTest.php | 203 +++++++- .../tests/Command/Make/MakeCommandsTest.php | 36 +- .../tests/Skeleton/SkeletonExampleTest.php | 272 ++++++++++ .../Skeleton/SkeletonScannedBootTest.php | 60 +++ packages/cli/tests/Support/SkeletonApp.php | 107 ++++ .../tests/Support/SkeletonExampleTestCase.php | 110 ++++ .../Support/SkeletonScannedBootTestCase.php | 57 ++ .../src/Generator/OpenApiGenerator.php | 115 +++- .../src/Generator/OperationFactory.php | 11 +- .../openapi/src/Schema/DtoSchemaFactory.php | 70 ++- packages/openapi/src/Schema/ElementTypes.php | 209 ++++++++ .../tests/Generator/NestedSchemaTest.php | 276 ++++++++++ .../KeywordFixture/KeywordController.php | 29 ++ .../tests/KeywordFixture/KeywordRequest.php | 29 ++ .../tests/NestedFixture/CategoryNode.php | 21 + .../NestedFixture/CreateOrderRequest.php | 35 ++ .../tests/NestedFixture/Fulfilment.php | 16 + .../tests/NestedFixture/LineOptionRequest.php | 30 ++ .../tests/NestedFixture/OrderController.php | 34 ++ .../tests/NestedFixture/OrderLineRequest.php | 23 + skeleton/app/Http/AddressPayload.php | 47 ++ skeleton/app/Http/OrderController.php | 147 ++++++ skeleton/app/Http/OrderLinePayload.php | 39 ++ skeleton/app/Http/OrderRequest.php | 70 +++ skeleton/app/Orders/Address.php | 24 + skeleton/app/Orders/Order.php | 60 +++ skeleton/app/Orders/OrderLine.php | 26 + skeleton/app/Orders/OrderRepository.php | 89 ++++ skeleton/app/Orders/OrderService.php | 78 +++ skeleton/tests/Feature/OrderTest.php | 177 +++++++ skeleton/tests/Feature/WelcomeTest.php | 18 +- tests/Psr4LayoutTest.php | 64 +++ 99 files changed, 8017 insertions(+), 147 deletions(-) create mode 100644 docs/modules/data-browser.md create mode 100644 packages/admin/resources/views/data-disabled.blade.php create mode 100644 packages/admin/resources/views/data-index.blade.php create mode 100644 packages/admin/resources/views/data-list.blade.php create mode 100644 packages/admin/resources/views/data-missing.blade.php create mode 100644 packages/admin/resources/views/data-record.blade.php create mode 100644 packages/admin/src/Data/DataBrowser.php create mode 100644 packages/admin/src/Data/DataListing.php create mode 100644 packages/admin/src/Data/DataQueryEngine.php create mode 100644 packages/admin/src/Data/DataRecord.php create mode 100644 packages/admin/src/Data/DataSchemaFactory.php create mode 100644 packages/admin/src/Data/DataWriteOutcome.php create mode 100644 packages/admin/src/Data/DataWriteResult.php create mode 100644 packages/admin/tests/Data/DataBrowserEdgeTest.php create mode 100644 packages/admin/tests/Data/DataBrowserReadTest.php create mode 100644 packages/admin/tests/Data/DataBrowserSettingsTest.php create mode 100644 packages/admin/tests/Data/DataBrowserWriteTest.php create mode 100644 packages/admin/tests/Data/DataDiscoveryTest.php create mode 100644 packages/admin/tests/Data/Fixtures/AdminRecord.php create mode 100644 packages/admin/tests/Data/Fixtures/AdminRecordRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/Alt/AdminRecord.php create mode 100644 packages/admin/tests/Data/Fixtures/Alt/AdminRecordRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/BrokenRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/GhostRecord.php create mode 100644 packages/admin/tests/Data/Fixtures/GhostRecordRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/Money.php create mode 100644 packages/admin/tests/Data/Fixtures/NotARepository.php create mode 100644 packages/admin/tests/Data/Fixtures/OrphanRecord.php create mode 100644 packages/admin/tests/Data/Fixtures/OrphanRecordRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/Pair.php create mode 100644 packages/admin/tests/Data/Fixtures/PairRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/PlainNote.php create mode 100644 packages/admin/tests/Data/Fixtures/PlainNoteRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/ScopedNoteRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/Widget.php create mode 100644 packages/admin/tests/Data/Fixtures/WidgetRepository.php create mode 100644 packages/admin/tests/Data/Fixtures/WidgetStatus.php create mode 100644 packages/admin/tests/Data/Support/DataBrowserTestCase.php create mode 100644 packages/admin/tests/DataBrowserOffTest.php create mode 100644 packages/admin/tests/DataBrowserPageTest.php create mode 100644 packages/admin/tests/Support/DataBrowserOffTestCase.php create mode 100644 packages/admin/tests/Support/DataBrowserTestCase.php create mode 100644 packages/cli/stubs/controller-request.stub create mode 100644 packages/cli/stubs/controller-resource.stub create mode 100644 packages/cli/tests/Skeleton/SkeletonExampleTest.php create mode 100644 packages/cli/tests/Skeleton/SkeletonScannedBootTest.php create mode 100644 packages/cli/tests/Support/SkeletonApp.php create mode 100644 packages/cli/tests/Support/SkeletonExampleTestCase.php create mode 100644 packages/cli/tests/Support/SkeletonScannedBootTestCase.php create mode 100644 packages/openapi/src/Schema/ElementTypes.php create mode 100644 packages/openapi/tests/Generator/NestedSchemaTest.php create mode 100644 packages/openapi/tests/KeywordFixture/KeywordController.php create mode 100644 packages/openapi/tests/KeywordFixture/KeywordRequest.php create mode 100644 packages/openapi/tests/NestedFixture/CategoryNode.php create mode 100644 packages/openapi/tests/NestedFixture/CreateOrderRequest.php create mode 100644 packages/openapi/tests/NestedFixture/Fulfilment.php create mode 100644 packages/openapi/tests/NestedFixture/LineOptionRequest.php create mode 100644 packages/openapi/tests/NestedFixture/OrderController.php create mode 100644 packages/openapi/tests/NestedFixture/OrderLineRequest.php create mode 100644 skeleton/app/Http/AddressPayload.php create mode 100644 skeleton/app/Http/OrderController.php create mode 100644 skeleton/app/Http/OrderLinePayload.php create mode 100644 skeleton/app/Http/OrderRequest.php create mode 100644 skeleton/app/Orders/Address.php create mode 100644 skeleton/app/Orders/Order.php create mode 100644 skeleton/app/Orders/OrderLine.php create mode 100644 skeleton/app/Orders/OrderRepository.php create mode 100644 skeleton/app/Orders/OrderService.php create mode 100644 skeleton/tests/Feature/OrderTest.php create mode 100644 tests/Psr4LayoutTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f991a..e79d876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,36 @@ needs npm or a CDN. layer's HTML rendering, security's fail-open note and full config table, observability's cross-process registry, and the "compilation lands in M15 — until then bind the manifest yourself" caveat that five module guides still carried. +- **Documentation for the rebuilt bean graph, the data browser and the OpenAPI schema pipeline.** A new + [Data Browser](docs/modules/data-browser.md) guide covers `firefly/admin`'s Django-admin-style view over the + data layer: what it discovers (every bean whose scan-time interface list contains `CrudRepository`, read from + the compiled `BeansCatalog` rather than a fresh scan, so it can never offer a resource the container never + registered), why `firefly.admin.data.enabled` defaults to **`false`** and deliberately does *not* follow + `app.debug` or `firefly.admin.enabled` (beans and config are facts about the application; these are facts + about its **users**), why writes need `firefly.admin.data.writable` **on top of that** (visibility and custody + are different decisions), and why **there is no `create()`** and never will be — an aggregate's invariants live + in its constructor, and a form built from a column list can only satisfy them by writing columns the domain + model considers impossible. Also documented: the four listing paths and the honest cost of the unpaged one, + search bound-never-interpolated, columns derived from the resource rather than from a row, the closed + five-value display-type vocabulary and why `decimal` maps to `string`, the identifier/secret write refusals + enforced twice, and why no rendered error text is ever an exception message. + [Bean Graph](docs/modules/bean-graph.md) is rewritten for the three node kinds — components, `#[Bean]` + **products** and `#[ConfigProperties]` DTOs — plus the `injects`/`produces` edge distinction, the identity + rule for a contested `#[Bean]` type, and why cycles are reported rather than fatal; the stale "`#[Bean]` + factory-method parameters are not drawn" limitation is gone, because they are. + [OpenAPI](docs/modules/openapi.md) gains a full "How a request DTO becomes a schema" section: the three + sources and why the compiled manifest beats the `#[Constraint]` attributes, why no `additionalProperties: + false` is emitted, the complete **attribute → compiled rule → JSON Schema keyword** table mapped from + `ConstraintSchemaMapper` (correcting `#[Negative]`, which produces `exclusiveMaximum`, not + `exclusiveMinimum`), first-writer-wins, the 3.1 nullable spelling, the one-`pattern`-slot `allOf` fallback, + `list` element types read from the constructor docblock via the same `dtos` table `ArgumentResolver` + hydrates from, and the narrowed `{}`-vs-`[]` rewrite now that a constructor default genuinely does emit an + empty list. The `firefly.openapi.*` config table also gains the five optional Info Object keys that were + shipping undocumented — `summary`, `terms-of-service`, `contact.*` and `license.*`, with the rule that + `license.name` gates the whole object and `license.identifier` wins over `license.url`, since 3.1 makes the + two mutually exclusive. *LaraFly by Example* is extended in **both** languages: Chapter 11 gains a "three + kinds of node" section for the graph and a data-browser section placed deliberately beside the access-model + argument it contradicts. Every fenced PHP listing still passes `php -l` (220 per language). ### Fixed - **`packages/security` — method security failed OPEN.** Both enforcement sites treat "no rule for this diff --git a/README.md b/README.md index d19dfe6..faf2012 100644 --- a/README.md +++ b/README.md @@ -634,6 +634,15 @@ pages the JSON surface deliberately keeps unexposed — which makes its own URL **must put the route behind its own auth middleware**. Read [the access model](docs/modules/admin.md#access-the-whole-security-boundary) before you do. +The package also ships a Django-admin-style [**data browser**](docs/modules/data-browser.md) over your own +`CrudRepository` beans — discovered from the compiled bean catalogue, so nothing is registered by hand. It is +gated *separately*: `firefly.admin.data.enabled` defaults to **`false`** and deliberately does **not** follow +`app.debug` or `firefly.admin.enabled`, because beans and configuration are facts about the application while +this page shows facts about its **users**. Writes need `firefly.admin.data.writable` on top of that, and there +is deliberately no create — an aggregate's invariants live in its constructor, not in a column list. Discovery, +reads and both writes are complete and usable from your own code today; the dashboard page that renders them is +[not routed yet](docs/modules/data-browser.md#known-latent). + `composer require firefly/openapi` mounts `GET /openapi.json` and a console at `/openapi`, both generated from the same `RouteManifest` the dispatcher dispatches from and the same `ConstraintManifest` the validator validates with — no annotation dialect, and nothing that can drift. `php artisan firefly:openapi --output=` @@ -750,6 +759,7 @@ its own installable Composer package with its own tests and its own [module guid | Operations | [Observability](docs/modules/observability.md) — Prometheus-format metrics, `/actuator/prometheus` | `firefly/observability` | | Operations | [Admin Dashboard](docs/modules/admin.md) — the browser dashboard over the actuator, read in-process | `firefly/admin` | | Operations | [Bean Graph](docs/modules/bean-graph.md) — the dashboard's drawn dependency graph, with cycle reporting | `firefly/admin` | +| Operations | [Data Browser](docs/modules/data-browser.md) — the dashboard's database browser over `CrudRepository` beans, off by default | `firefly/admin` | | Testing | [Testing](docs/modules/testing.md) — `FireflyTestCase`, recording doubles, Pest expectations | `firefly/testing` | | Testing | [Integration Testing](docs/modules/integration-testing.md) — `@group integration`, testcontainers | `firefly/testing` | | Tooling | [Installer](docs/modules/installer.md) — the global `firefly new` scaffolding tool | `firefly/installer` | @@ -760,6 +770,8 @@ is the 28th unit, a `type: project` create-project template at the top level. --- +## Documentation + Start at the **[documentation table of contents](docs/README.md)** — it groups every guide by topic. Highlights: - [Getting Started](docs/getting-started.md) — the full quickstart, adding LaraFly to an existing app. diff --git a/book/book.es.yaml b/book/book.es.yaml index 878aada..3c9a410 100644 --- a/book/book.es.yaml +++ b/book/book.es.yaml @@ -29,6 +29,13 @@ labels: # 4A (sustituye por completo la antigua sección de "la bandera de CDN", ya que `viewer.style` reemplaza a # `viewer.cdn`). Separar cualquiera de los dos en un capítulo propio habría desligado una funcionalidad # del paquete al que pertenece, y habría movido una referencia "Capítulo N" sin beneficio para el lector. +# Tarea B8: de nuevo ningún capítulo nuevo, y por la misma razón. El grafo de beans reconstruido (los +# productos #[Bean] y los DTOs #[ConfigProperties] son ahora nodos, no solo las clases declarantes) y el +# nuevo navegador de base de datos son ambos páginas de firefly/admin, que el Capítulo 11 ya presenta, así +# que ambos amplían ese capítulo en lugar de reclamar uno propio. El navegador de datos en particular +# PERTENECE junto a la sección del modelo de acceso contra la que argumenta: todo su punto de diseño es que +# `firefly.admin.data.enabled` deliberadamente NO hereda el valor por defecto `app.debug` que la sección +# anterior justifica, y separarlos habría eliminado la comparación que hace legible la decisión. front: - {id: title, file: 00-front/00-title.md, nav: false} - {id: copyright, file: 00-front/00-copyright.md, nav: false} diff --git a/book/book.yaml b/book/book.yaml index a73d5d2..298c805 100644 --- a/book/book.yaml +++ b/book/book.yaml @@ -28,6 +28,13 @@ labels: # replaces the old "CDN flag" section outright, since `viewer.style` supersedes `viewer.cdn`). Splitting # either into a chapter of its own would have separated a feature from the package it belongs to, and # would have moved a "Chapter N" reference for no reader benefit. +# Task B8: no new chapter, again, and for the same reason. The rebuilt bean graph (#[Bean] products and +# #[ConfigProperties] DTOs are now nodes, not just declaring classes) and the new database browser are both +# pages of firefly/admin, which Chapter 11 already introduces, so both extend that chapter rather than +# claiming one of their own. The data browser in particular BELONGS beside the access-model section it +# argues against: its whole design point is that `firefly.admin.data.enabled` deliberately does NOT inherit +# the `app.debug` default the section above it justifies, and separating the two would have removed the +# comparison that makes the decision legible. front: - {id: title, file: 00-front/00-title.md, nav: false} - {id: copyright, file: 00-front/00-copyright.md, nav: false} diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index 00c7710..fd3e684 100644 --- a/book/src-es/11-observability-actuator.md +++ b/book/src-es/11-observability-actuator.md @@ -767,6 +767,31 @@ final class ComponentDescriptor Esa última frase es una decisión de diseño en la que merece la pena detenerse. Un parámetro de constructor tipado `string $name` es configuración; dibujarlo como una arista enterraría las relaciones que importan bajo ruido de `string`/`int`. Un parámetro de **clase anulable o con valor por defecto** *sí* se conserva, porque un colaborador opcional sigue siendo una relación. +#### Tres clases de nodo, y por qué la primera versión estaba casi vacía + +Antes que las aristas, los nodos — porque la primera versión de esta página los entendió mal de una forma de la que merece la pena aprender. Una aplicación LaraFly tiene **tres clases de bean**, y las tres tienen que ser nodos: + +| Clase | Qué es | De dónde sale | +|---|---|---| +| `component` | Una clase escaneada `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` | El catálogo de beans | +| `bean` | Un valor **producido por un método fábrica `#[Bean]`** de una `#[Configuration]` | Las filas `produces` del catálogo | +| `config` | Un DTO `#[ConfigProperties]` enlazado desde la configuración | El endpoint `configprops` | + +Al principio solo la primera clase era un nodo, y la consecuencia no fue cosmética. El cableado de un framework vive casi por completo en la segunda clase: una autoconfiguración es una `#[Configuration]` cuyos métodos `#[Bean]` producen `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` y demás. Con solo las clases declarantes como nodos, cada arista que apuntaba a uno de esos productos apuntaba a un nodo que no existía. Medido sobre un esqueleto de serie: **42 nodos, 41 productos `#[Bean]` ausentes, 21 dependencias colgando y exactamente una arista dibujada.** La página no mostraba un grafo disperso — era estructuralmente incapaz de mostrar el cableado del framework. + +La tercera clase es el mismo error en miniatura. Un DTO `#[ConfigProperties]` está enlazado y es inyectable, pero no se escanea como componente ni lo produce una fábrica, así que nada en el catálogo de beans puede verlo: `App\GreetingProperties` aparecía como *dependencia no resuelta* de `GreetingService` en lugar de como el bean que es. Por eso la página lee el endpoint `configprops` junto al catálogo. + +De modo que también hay dos clases de arista, y dicen cosas distintas: + +| Arista | De → a | Significado | +|---|---|---| +| `injects` | Un bean → algo de lo que declaró depender | El consumidor lo pidió; el contenedor lo satisface | +| `produces` | Una `#[Configuration]` → el valor que devuelve uno de sus métodos `#[Bean]` | Esta clase es de donde sale ese bean | + +Las aristas `injects` se recogen de los parámetros del **constructor** de un componente *y* de los parámetros de cada **método fábrica `#[Bean]`** — el producto depende de lo que su fábrica pidiera. Esa unión es el cableado; los constructores por sí solos son una fracción de él. + +Una sutileza sobre la identidad. Un producto `#[Bean]` se identifica normalmente por el **tipo que produce**, porque esa es la clave que enlaza el contenedor y la clave que pide todo consumidor. Pero cuando dos métodos fábrica producen el mismo tipo — la forma que las reglas `#[Primary]`/`#[Qualifier]` del Capítulo 2 existen para desambiguar — el tipo por sí solo los colapsaría en un único nodo y ocultaría justo la ambigüedad por la que abriste la página. Así que cada competidor recibe `Declarante::metodo()` como identidad propia y el tipo desnudo resuelve al primero de ellos, reflejando al contenedor, donde la clave de tipo es un alias del ganador mientras todo candidato sigue alcanzable por nombre. + #### Lo difícil no es dibujar, es resolver Un constructor pide un **tipo**, y ese tipo es muy a menudo una interfaz — `EventPublisher`, `HealthIndicator`, `Cache` — mientras que el bean que lo satisface es una clase concreta que meramente la implementa. Una lista de aristas construida ingenuamente a partir de los tipos del constructor apunta entonces a nodos que no existen, y el grafo sale como un campo de puntos desconectados. Pregúntate a qué debería dibujar una flecha la dependencia de `WalletService` sobre `WalletRepository`: no al puerto, que es una interfaz sin bean propio, sino a `EloquentWalletRepository`, que es lo que de verdad se va a construir. @@ -825,8 +850,8 @@ Dos límites se declaran en la página en lugar de ocultarse: !!! tip "Léelo junto a la página de Condiciones" Las dos responden mitades complementarias de toda sorpresa de auto-configuración. **Condiciones** dice *si* un bean del framework se registró o se echó atrás, y sobre qué condición. **El grafo** dice a qué está cableado el bean que sí ganó, y a través de qué interfaz. Una arista `EventPublisher` apuntando a `InMemoryEventPublisher` cuando configuraste `firefly.eda.provider=rabbitmq` se ve de un vistazo en el grafo; Condiciones nombra entonces el `#[ConditionalOnProperty]` que no casó. -!!! note "Lo que el grafo todavía no dibuja" - Las aristas salen únicamente de las `dependencies` del constructor. `BeansCatalog` publica además los parámetros propios de cada método fábrica `#[Bean]` (bajo `produces`), pero `BeanGraph` no los lee, así que una clase `#[Configuration]` aparece con las aristas que declara *su propio constructor* y el cableado que hacen sus métodos `#[Bean]` no se dibuja. Eso sub-dibuja específicamente las clases de auto-configuración del framework; tus beans `#[Service]`/`#[Repository]`, que cablean por constructor, se dibujan completos. +!!! note "Lo que el grafo sigue sin decidir por ti" + Dos límites merecen conocerse, y ninguno es una carencia de datos. **`#[Primary]`/`#[Qualifier]` no dirigen el índice** — gana quien escriba primero en orden de escaneo, tanto para una interfaz con varios implementadores como para la clave de tipo desnuda de un `#[Bean]` disputado. Cada competidor sigue teniendo su propio nodo y la arista se marca `via`, así que la ambigüedad es visible en la página, pero el destino dibujado puede no ser el que resuelve el contenedor. Y **un tipo no resuelto se reporta, nunca se explica**: la página puede decirte que un tipo lo provee algo fuera del contenedor, pero no *qué* enlace lo provee, porque un enlace del contenedor de Laravel no lleva descriptor que leer. --- @@ -896,6 +921,56 @@ También se echa atrás en silencio en un caso más, fácil de pasar por alto. B !!! warning "Tres cosas que el panel solo puede mostrarte de *este* proceso" Bajo PHP-FPM cada petición es un proceso distinto, y tres páginas heredan eso. **Cambiar un nivel de log** llama al mismo endpoint que `POST /actuator/loggers/{name}`, que muta los manejadores de Monolog del proceso actual — la siguiente petición es otro proceso, así que cambia `logging.channels` para cualquier cosa que deba persistir. Las **métricas** son solo tan duraderas como el registro: el `SimpleMeterRegistry` por defecto guarda los medidores en memoria de proceso, así que el panel ve solo su propia petición salvo que `firefly.observability.metrics.store` apunte a un almacén de caché. Y los **detalles de salud** siguen ocultos en la respuesta JSON `/actuator/health` hasta que `firefly.management.endpoint.health.show-details` sea `always`, aunque la propia página de Salud del panel lea los indicadores directamente. +### El navegador de datos, y por qué no hereda ese valor por defecto + +`firefly/admin` incluye una superficie más, y es la única de este capítulo cuya puerta está escrita de forma distinta a todas las que has visto. Es un **navegador de base de datos** al estilo del admin de Django sobre la capa de datos del Capítulo 5 — listado, detalle, búsqueda, ordenación y paginación sobre tus propios repositorios — al que se llega a través de un único `DataBrowser` que `DataBrowser::forContainer($container)` ensambla desde el contenedor de la aplicación. + +Descubre qué navegar igual que el resto del panel descubre todo lo demás — desde el catálogo compilado. **Todo bean cuya lista de interfaces de tiempo de escaneo contenga `CrudRepository` es un recurso navegable.** No se registra nada ni se declara nada: un repositorio que escribas es navegable en cuanto el contenedor lo tiene, y uno que borres deja de serlo sin que nadie edite una lista. Cada fila de `BeansCatalog` ya lleva el cierre completo de interfaces que `ComponentScanner` registró con `class_implements()`, así que «¿es este bean un repositorio, y además pagina?» son dos llamadas a `in_array()` sobre datos que el proceso ya tiene — sin reflexión y, de forma decisiva, sin posibilidad de ofrecer un recurso que el contenedor nunca registró. + +Ahora la puerta: + +```php +final readonly class DataBrowserSettings +{ + public static function fromConfig(Config $config): self + { + $max = min(self::PAGE_SIZE_CEILING, max(1, $config->int('firefly.admin.data.max-page-size', 200))); + + return new self( + enabled: $config->bool('firefly.admin.data.enabled', false), + writable: $config->bool('firefly.admin.data.writable', false), + pageSize: min($max, max(1, $config->int('firefly.admin.data.page-size', 25))), + maxPageSize: $max, + excluded: self::csv($config->string('firefly.admin.data.exclude', '')), + ); + } + + /** Escribir requiere AMBAS puertas. */ + public function canWrite(): bool + { + return $this->enabled && $this->writable; + } +} +``` + +Fíjate en los dos valores por defecto, y compáralos con el `$config->bool('app.debug', false)` de `AdminSettings` unas páginas más arriba. El panel sigue a `app.debug`, y el argumento para eso era sólido **para lo que el panel muestra**: beans, condiciones, mapeos y configuración resuelta son hechos sobre la *aplicación*, y una aplicación que ya sirve trazas de pila ya ha publicado hechos de esa clase. + +Esta página muestra hechos sobre los **usuarios** de la aplicación. Esa es una divulgación categóricamente mayor, y los errores que la exponen son los ordinarios, los que hoy no cuestan nada: una bandera de depuración olvidada en un entorno de staging que comparte base de datos con producción, un `.env` copiado a una máquina que debía ser interna, un portátil tunelizado para una demo. Cada uno se convierte en una divulgación de registros de clientes en cuanto hay un navegador de datos atado a `app.debug`. Así que la puerta es aparte, explícita y está cerrada — **`app.debug` no puede abrirla, y `firefly.admin.enabled` tampoco.** Las tres deben ser ciertas. + +Las escrituras necesitan entonces una *segunda* clave, y por sí sola no sirve de nada. Leer la fila equivocada es una divulgación; borrarla es pérdida de datos sin deshacer, desde un formulario, sobre una sesión que puede no ser más que «debug estaba encendido». Encender el navegador es una decisión sobre **visibilidad**; encender las escrituras es una decisión sobre **custodia**. Si las colapsas en una sola clave, quien quería mirar una tabla ha armado también el botón de borrar. + +!!! warning "No hay create, y no es un hueco que se rellene más adelante" + Un formulario de creación genérico sobre una entidad arbitraria es una promesa que el navegador no puede cumplir, y el Capítulo 6 explica por qué: **el constructor de un agregado es donde viven sus invariantes.** Un `Order` que debe tener al menos una línea, un `Wallet` cuyo saldo empieza a cero en la divisa en que se abrió, un objeto de valor que rechaza un IBAN mal formado — un formulario construido a partir de una lista de columnas no conoce ninguno. Solo hay dos maneras de construir la fila: llamar al constructor, que necesita argumentos que el formulario no puede aportar con los tipos ni el orden correctos; o escribir las columnas directamente en la tabla, lo que produce una fila que el modelo de dominio considera imposible y con la que toda lectura posterior tiene que apañarse. Lo segundo es lo que hace de verdad una implementación de «simplemente inserta las columnas», y es *peor que no tener botón*, porque parece que funcionó. `update()` sí se ofrece porque opera sobre una fila que ya satisface sus invariantes; `delete()` porque eliminar no necesita invariante alguna. Crear pertenece a tu propio código, donde está el constructor. + +Merece la pena llevarse otras dos decisiones de esta sección, porque ambas parecen un detalle y no lo son. + +**El identificador y cualquier secreto enmascarado se rechazan como destino de una actualización** — y se rechazan dos veces, una para que la vista pueda dibujar el campo como solo lectura y otra en la ruta de escritura, de modo que un POST fabricado a mano no alcance lo que el formulario no ofrecía. Recodificar la clave de una fila desde un formulario genérico no es una edición, es otra fila, y las claves foráneas que apuntaban al valor antiguo no la siguen. El valor *mostrado* de un secreto es `******`, así que devolver un formulario renderizado escribiría la máscara sobre la credencial real — un fallo de pérdida de datos creado por el propio enmascarado. Los secretos se excluyen de la **búsqueda** por una razón emparentada: una caja que responde «sí, el `api_token` de alguna fila empieza por `sk_live_9`» es un oráculo que un operador puede recorrer carácter a carácter. + +**Ningún texto de error que la página muestre es jamás un mensaje de excepción.** La `QueryException` de Laravel convierte a cadena el SQL fallido *y sus bindings* dentro de `getMessage()`, así que reproducirlo publicaría el esquema y los valores enlazados — que en una búsqueda sobre una tabla de usuarios es la propia consulta del operador, y en una consulta de detalle es una clave primaria. Toda razón es una frase fija compuesta en la capa del navegador, más como mucho el nombre de clase de la excepción; el mensaje se queda en la excepción, donde el log puede tenerlo. Por eso mismo nada en la capa lanza hacia su llamador: las lecturas responden con un listado que lleva una razón, las escrituras con uno de cuatro resultados (`Done`, `Refused`, `NotFound`, `Failed`), y una vista que dibuja una página de administración nunca tiene que ser a prueba de excepciones para mantenerse en pie. + +!!! note "La ruta de listado que obtienes depende de la interfaz que implementaste" + Un `PagingAndSortingRepository` se pagina **en la base de datos**: el repositorio hace el desplazamiento, el límite, el `ORDER BY` y el `COUNT`, y el coste es independiente del tamaño de la tabla. Un `CrudRepository` simple no puede expresar nada de eso, así que el navegador llama a `findAll()`, ordena y corta **en PHP**, y descarta todas las filas menos 25 — lo que con diez mil filas es una página lenta y con diez millones es un agotamiento de memoria que mata al worker, al *primer* clic. La interfaz no tiene límite, ni desplazamiento, ni conteo con predicado, así que las opciones honestas eran «negarse a navegar repositorios que no pueden paginar» o «navegarlos y decir lo que cuesta». LaraFly hace lo segundo, y esto es el decirlo. Implementa `PagingAndSortingRepository` en todo lo que pretendas navegar contra una tabla real. + !!! laravel "Paridad con Laravel" Laravel puro no trae ningún endpoint de comprobación de salud ni de métricas en absoluto — la mayoría de los equipos o bien improvisan una ruta `/health` a mano o recurren a un paquete de terceros, normalmente emparejado con la extensión `ext-prometheus`. `firefly/actuator` y `firefly/observability` son análogos de primera parte y con pocas dependencias de Spring Boot Actuator y Micrometer respectivamente: endpoints de framework montados sobre el mismo `Router` que tu app ya usa, comprobaciones de salud que reutilizan por debajo los propios `DB`/`Log`/config de Laravel, y un exportador Prometheus en PHP puro sin requisito de extensión. Ambos paquetes son dependencias Composer opcionales y ambos son seguros por defecto — una app que añade `firefly/actuator` obtiene `health`/`info` y nada más hasta que configure más. `firefly/admin` completa el conjunto como análogo de Spring Boot Admin, con la diferencia de que no es una aplicación de monitorización aparte que despliegas y en la que registras instancias: son vistas Blade dentro de la propia aplicación sobre la que informan, que es por lo que puede leer el registro directamente y por lo que su modelo de acceso importa tanto como importa. @@ -917,9 +992,13 @@ También se echa atrás en silencio en un caso más, fácil de pasar por alto. B | `ObservabilityAutoConfiguration` `#[Order(500)]` | El mismo truco de precedencia que la costura de seguridad del Capítulo 10: registra `cqrsMetrics()` antes de que `CqrsAutoConfiguration` evalúe su `#[ConditionalOnMissingBean]` | | `firefly/admin` | Un panel Blade renderizado en el servidor en `/firefly`; trece páginas, y una cuyo endpoint no está registrado o está apagado se oculta del menú en lugar de enlazarse | | `AdminEndpointReader` | Invoca cada `ActuatorEndpoint` **en proceso** desde el `ActuatorRegistry`, sorteando `ExposureModel` — así el panel muestra lo que la superficie HTTP no expone, y un endpoint que lanza degrada un solo panel | -| `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado: aristas de constructor resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y el diagrama suprimido pasados `firefly.admin.graph.max-nodes` (220) | +| `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado sobre **tres clases de nodo** — componentes, productos `#[Bean]` y DTOs `#[ConfigProperties]` — con aristas `injects`/`produces` resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y el diagrama suprimido pasados `firefly.admin.graph.max-nodes` (220) | +| Los productos `#[Bean]` como nodos | El cableado de un framework vive en métodos fábrica, no en constructores; con solo las clases declarantes como nodos, un esqueleto de serie dibujaba **una** arista de 42 beans | | `ComponentDescriptor::$dependencies` | Las aristas del grafo, registradas por `ComponentScanner` en tiempo de **escaneo** — solo tipos de clase e interfaz, porque un parámetro escalar es configuración, no cableado | | `firefly.admin.enabled` | Toma por defecto `app.debug`; un valor explícito gana en ambas direcciones, y encenderlo con debug apagado te obliga a poner tu propio middleware de autenticación delante de la ruta | +| `firefly.admin.data.enabled` | La puerta propia del navegador de datos, con valor por defecto **`false`** — *no* sigue a `app.debug` ni a `firefly.admin.enabled`, porque esta página muestra hechos sobre los usuarios de la aplicación y no sobre la aplicación | +| `firefly.admin.data.writable` | Una **segunda** puerta, también `false` e inútil por sí sola: visibilidad y custodia son decisiones distintas, y una sola clave armaría el botón de borrar para quien solo quería mirar una tabla | +| Sin `create()` | Permanente, no pendiente: las invariantes de un agregado viven en su constructor, y un formulario construido a partir de una lista de columnas no puede satisfacerlas — escribir las columnas de todos modos produce una fila que el dominio considera imposible | --- diff --git a/book/src/11-observability-actuator.md b/book/src/11-observability-actuator.md index b894fce..a48940e 100644 --- a/book/src/11-observability-actuator.md +++ b/book/src/11-observability-actuator.md @@ -767,6 +767,31 @@ final class ComponentDescriptor That last sentence is a design decision worth pausing on. A constructor parameter typed `string $name` is configuration; drawing it as an edge would bury the relationships that matter under `string`/`int` noise. A **nullable or defaulted class** parameter *is* kept, because an optional collaborator is still a relationship. +#### Three kinds of node, and why the first version was nearly empty + +Before the edges, the nodes — because the first version of this page got them wrong in a way worth learning from. A LaraFly application has **three kinds of bean**, and all three have to be nodes: + +| Kind | What it is | Where it comes from | +|---|---|---| +| `component` | A scanned `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` class | The beans catalogue | +| `bean` | A value **produced by a `#[Bean]` factory method** on a `#[Configuration]` | The catalogue's `produces` rows | +| `config` | A `#[ConfigProperties]` DTO bound from configuration | The `configprops` endpoint | + +Only the first kind was a node to begin with, and the consequence was not a cosmetic one. A framework's wiring lives almost entirely in the second kind: an auto-configuration is a `#[Configuration]` whose `#[Bean]` methods produce `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` and the rest. With only declaring classes as nodes, every edge pointing at one of those products pointed at a node that did not exist. Measured on a stock skeleton: **42 nodes, 41 `#[Bean]` products missing, 21 dangling dependencies, and exactly one edge drawn.** The page was not showing a sparse graph — it was structurally incapable of showing framework wiring at all. + +The third kind is the same mistake in miniature. A `#[ConfigProperties]` DTO is bound and injectable, but it is neither scanned as a component nor produced by a factory, so nothing in the beans catalogue can see it: `App\GreetingProperties` turned up as an *unresolved dependency* of `GreetingService` rather than as the bean it is. That is why the page reads the `configprops` endpoint alongside the catalogue. + +So there are two kinds of edge, too, and they say different things: + +| Edge | From → to | Meaning | +|---|---|---| +| `injects` | A bean → something it declared a dependency on | The consumer asked for it; the container satisfies it | +| `produces` | A `#[Configuration]` → the value one of its `#[Bean]` methods returns | This class is where that bean comes from | + +`injects` edges are collected from a component's **constructor** parameters *and* from every `#[Bean]` **factory method's** parameters — the product depends on whatever its factory asked for. That union is the wiring; constructors alone are a fraction of it. + +One subtlety about identity. A `#[Bean]` product is normally identified by the **type it produces**, because that is the key the container binds and the key every consumer asks for. But when two factory methods produce the same type — the shape that Chapter 2's `#[Primary]`/`#[Qualifier]` rules exist to disambiguate — the type alone would collapse them into a single node and hide exactly the ambiguity you opened the page to see. So each competitor gets `Declaring::method()` as its own id and the bare type resolves to the first of them, mirroring the container, where the type key aliases the winner while every candidate stays reachable by name. + #### The hard part is not drawing, it is resolving A constructor asks for a **type**, and that type is very often an interface — `EventPublisher`, `HealthIndicator`, `Cache` — while the bean that satisfies it is a concrete class that merely implements it. An edge list built naively from constructor types therefore points at nodes that do not exist, and the graph comes out as a field of disconnected dots. Ask yourself what `WalletService`'s dependency on `WalletRepository` should draw an arrow *to*: not to the port, which is an interface with no bean of its own, but to `EloquentWalletRepository`, which is the thing that will actually be constructed. @@ -825,8 +850,8 @@ Two limits are stated in the page rather than hidden: !!! tip "Read it next to the Conditions page" The two answer complementary halves of every auto-configuration surprise. **Conditions** says *whether* a framework bean was registered or backed off, and on which condition. **The graph** says what the bean that did win is wired to, and through which interface. An `EventPublisher` edge pointing at `InMemoryEventPublisher` when you configured `firefly.eda.provider=rabbitmq` is one glance on the graph; Conditions then names the `#[ConditionalOnProperty]` that did not match. -!!! note "What the graph does not draw yet" - Edges come from constructor `dependencies` only. `BeansCatalog` also publishes each `#[Bean]` factory method's own parameters (under `produces`), but `BeanGraph` does not read them, so a `#[Configuration]` class appears with the edges *its own constructor* declares and the wiring its `#[Bean]` methods perform is not drawn. That under-draws framework auto-configuration classes specifically; your `#[Service]`/`#[Repository]` beans, which wire through constructors, are drawn in full. +!!! note "What the graph still does not decide for you" + Two limits are worth knowing, and neither is a gap in the data. **`#[Primary]`/`#[Qualifier]` do not steer the index** — first writer in scan order wins, both for an interface with several implementors and for the bare type key of a contested `#[Bean]`. Every competitor still gets its own node and the edge is marked `via`, so the ambiguity is visible on the page, but the drawn target may not be the one the container resolves. And **an unresolved type is reported, never explained**: the page can tell you a type is provided outside the container, but not *which* binding provides it, because a Laravel container binding carries no descriptor to read. --- @@ -896,6 +921,56 @@ It also backs off silently in one more case that is easy to miss. Blade is requi !!! warning "Three things the dashboard can only show you about *this* process" Under PHP-FPM every request is a different process, and three pages inherit that. **Changing a log level** calls the same endpoint `POST /actuator/loggers/{name}` does, which mutates the current process's Monolog handlers — the next request is a different process, so change `logging.channels` for anything that must persist. **Metrics** are only as durable as the registry: the default `SimpleMeterRegistry` keeps meters in process memory, so the dashboard sees only its own request unless `firefly.observability.metrics.store` points at a cache store. And **health details** stay hidden on the JSON `/actuator/health` response until `firefly.management.endpoint.health.show-details` is `always`, even though the dashboard's own Health page reads the indicators directly. +### The data browser, and why it does not inherit that default + +`firefly/admin` ships one more surface, and it is the only one in this chapter whose gate is written differently from every other gate you have seen. It is a Django-admin-style **database browser** over the data layer of Chapter 5 — listing, detail, search, sorting and paging over your own repositories — reached through a single `DataBrowser` that `DataBrowser::forContainer($container)` assembles from the application container. + +It discovers what to browse the same way the rest of the dashboard discovers everything — from the compiled catalogue. **Every bean whose scan-time interface list contains `CrudRepository` is a browsable resource.** Nothing is registered, nothing is declared: a repository you write is browsable the moment the container has it, and one you delete stops being browsable without anyone editing a list. Each row of `BeansCatalog` already carries the full interface closure `ComponentScanner` recorded with `class_implements()`, so "is this bean a repository, and does it also page?" is two `in_array()` calls over data the process already holds — no reflection, and, decisively, no chance of offering a resource the container never registered. + +Now the gate: + +```php +final readonly class DataBrowserSettings +{ + public static function fromConfig(Config $config): self + { + $max = min(self::PAGE_SIZE_CEILING, max(1, $config->int('firefly.admin.data.max-page-size', 200))); + + return new self( + enabled: $config->bool('firefly.admin.data.enabled', false), + writable: $config->bool('firefly.admin.data.writable', false), + pageSize: min($max, max(1, $config->int('firefly.admin.data.page-size', 25))), + maxPageSize: $max, + excluded: self::csv($config->string('firefly.admin.data.exclude', '')), + ); + } + + /** Writing requires BOTH gates. */ + public function canWrite(): bool + { + return $this->enabled && $this->writable; + } +} +``` + +Look at the two defaults, and compare them with `AdminSettings`'s `$config->bool('app.debug', false)` a few pages up. The dashboard follows `app.debug`, and the argument for that was sound **for what the dashboard shows**: beans, conditions, mappings and resolved configuration are facts about the *application*, and an application already serving stack traces has already published facts of that kind. + +This page shows facts about the application's **users**. That is a categorically bigger disclosure, and the mistakes that expose it are the ordinary ones that cost nothing today: a debug flag left on in a staging environment that shares a database with production, a `.env` copied to a box that was supposed to be internal, a laptop tunnelled for a demo. Each becomes a customer-record disclosure the moment a data browser is wired to `app.debug`. So the gate is separate, explicit and off — **`app.debug` cannot switch it on, and neither can `firefly.admin.enabled`.** All three must be true. + +Writes then need a *second* key, and it is ineffective on its own. Reading the wrong row is a disclosure; deleting it is data loss with no undo, from a form, over a session that may be nothing more than "debug was on". Turning on the browser is a decision about **visibility**; turning on writes is a decision about **custody**. Collapse them into one key and the operator who wanted to look at a table has also armed the delete button. + +!!! warning "There is no create, and that is not a gap to be filled in later" + A generic create form over an arbitrary entity is a promise the browser cannot keep, and Chapter 6 is the reason why: **an aggregate's constructor is where its invariants live.** An `Order` that must have at least one line, a `Wallet` whose balance starts at zero in the currency it was opened in, a value object that rejects a malformed IBAN — a form built from a column list knows none of them. There are only two ways to build the row: call the constructor, which needs arguments the form cannot supply in the right types or the right order; or write the columns straight to the table, which produces a row the domain model considers impossible and which every later read then has to cope with. The second is what a "just insert the columns" implementation actually does, and it is *worse than having no button*, because it looks like it worked. `update()` is offered because it operates on a row that already satisfies its invariants; `delete()` because removal needs no invariant at all. Creation belongs in your own code, where the constructor is. + +Two more decisions are worth carrying out of this section, because both are the sort of thing that reads as a detail and is not. + +**The identifier and any masked secret are refused as update targets** — and refused twice, once so the view can render the field read-only and again in the write path, so a hand-crafted POST cannot reach what the form would not offer. Re-keying a row from a generic form is not an edit, it is a different row, and the foreign keys pointing at the old value do not follow. A secret's *displayed* value is `******`, so round-tripping a rendered form would write the mask over the real credential — a data-loss bug the masking itself created. Secrets are excluded from **search** for a related reason: a box that answers "yes, some row's `api_token` starts with `sk_live_9`" is an oracle an operator can walk one character at a time. + +**No error text the page renders is ever an exception message.** Laravel's `QueryException` stringifies the failing SQL *and its bindings* into `getMessage()`, so echoing it would publish the schema and the bound values — which on a search over a users table is the operator's own query, and on a detail lookup is a primary key. Every reason is a fixed sentence composed in the browser layer, plus at most the exception's class name; the message stays in the exception, where the log can have it. This is also why nothing in the layer throws at its caller: reads answer with a listing carrying a reason, writes with one of four outcomes (`Done`, `Refused`, `NotFound`, `Failed`), and a view rendering an admin page never has to be exception-safe to stay on its feet. + +!!! note "The listing path you get depends on the interface you implemented" + A `PagingAndSortingRepository` is paged **in the database**: the repository does the offset, the limit, the `ORDER BY` and the `COUNT`, and the cost is independent of table size. A plain `CrudRepository` cannot express any of that, so the browser calls `findAll()`, sorts and slices **in PHP**, and throws away all but 25 rows — which on ten thousand rows is a slow page and on ten million is an out-of-memory that kills the worker, on the *first* click. The interface has no limit, no offset and no count-with-predicate, so the honest options were "refuse to browse repositories that cannot page" or "browse them and say what it costs". LaraFly does the second, and this is the saying. Implement `PagingAndSortingRepository` on anything you intend to browse against a real table. + !!! laravel "Laravel parity" Plain Laravel ships no health-check or metrics endpoint at all — most teams either hand-roll a `/health` route or reach for a third-party package, usually paired with the `ext-prometheus` extension. `firefly/actuator` and `firefly/observability` are first-party, dependency-light analogues of Spring Boot Actuator and Micrometer respectively: framework endpoints mounted on the same `Router` your app already uses, health checks that reuse Laravel's own `DB`/`Log`/config underneath, and a pure-PHP Prometheus exporter with no extension requirement. Both packages are opt-in Composer dependencies and both are secure-by-default — an app that adds `firefly/actuator` gets `health`/`info` and nothing else until it configures more. `firefly/admin` completes the set as the analogue of Spring Boot Admin, with the difference that it is not a separate monitoring application you deploy and register instances with: it is Blade views inside the application it reports on, which is why it can read the registry directly and why its access model matters as much as it does. @@ -917,9 +992,13 @@ It also backs off silently in one more case that is easy to miss. Blade is requi | `ObservabilityAutoConfiguration` `#[Order(500)]` | The same precedence trick as Chapter 10's security seam: registers `cqrsMetrics()` before `CqrsAutoConfiguration` evaluates its `#[ConditionalOnMissingBean]` | | `firefly/admin` | A server-rendered Blade dashboard at `/firefly`; thirteen pages, and one whose endpoint is unregistered or switched off is hidden from the menu rather than linked | | `AdminEndpointReader` | Invokes each `ActuatorEndpoint` **in-process** from `ActuatorRegistry`, bypassing `ExposureModel` — so the dashboard shows what the HTTP surface does not expose, and a throwing endpoint degrades one panel | -| `BeanGraph` | Turns the beans catalogue into a drawn dependency graph: constructor edges resolved through an interface index (marked `via`), longest-path layering, cycles reported rather than hung on, and the diagram suppressed past `firefly.admin.graph.max-nodes` (220) | +| `BeanGraph` | Turns the beans catalogue into a drawn dependency graph over **three kinds of node** — components, `#[Bean]` products and `#[ConfigProperties]` DTOs — with `injects`/`produces` edges resolved through an interface index (marked `via`), longest-path layering, cycles reported rather than hung on, and the diagram suppressed past `firefly.admin.graph.max-nodes` (220) | +| `#[Bean]` products as nodes | A framework's wiring lives in factory methods, not constructors; with only declaring classes as nodes a stock skeleton drew **one** edge out of 42 beans | | `ComponentDescriptor::$dependencies` | The graph's edges, recorded by `ComponentScanner` at **scan** time — class and interface types only, because a scalar parameter is configuration, not wiring | | `firefly.admin.enabled` | Defaults to `app.debug`; an explicit value wins in both directions, and turning it on with debug off obliges you to put your own auth middleware in front of the route | +| `firefly.admin.data.enabled` | The data browser's own gate, defaulting to **`false`** — it does *not* follow `app.debug` or `firefly.admin.enabled`, because this page shows facts about the application's users rather than about the application | +| `firefly.admin.data.writable` | A **second** gate, also `false` and ineffective alone: visibility and custody are different decisions, and one key would arm the delete button for whoever wanted to look at a table | +| No `create()` | Permanent, not pending: an aggregate's invariants live in its constructor, and a form built from a column list cannot satisfy them — writing the columns anyway produces a row the domain considers impossible | --- diff --git a/deptrac.yaml b/deptrac.yaml index 1d6c419..a9dbd73 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -341,6 +341,14 @@ deptrac: # its pages on the illuminate Router through a Context BootPass at a configurable base path, and reuses # Web's ProblemDetailsRenderer for its own failures (=> Web). It reads Config, carries Container/Context # attributes and extends AutoConfigure's base. NOTHING depends on it — it is a leaf, and an optional one. + # + # The => Data edge is the Django-style database browser (src/Data): it discovers browsable resources by + # looking for beans whose scan-time interface list contains Data's CrudRepository, and reads them through + # the ports Data already publishes — findPaged(Pageable)/Page/Sort for a page, findBySpecificationPaged + # for a searched page, findById/existsById/deleteById/save for a record. It consumes those ports and adds + # nothing to them; the direction is Admin -> Data and NEVER the reverse, exactly as with Actuator and Web. + # No Domain edge: the browser reflects over whatever entity a repository declares and knows nothing about + # Entity/AggregateRoot. Admin: - Kernel - Container @@ -349,6 +357,7 @@ deptrac: - AutoConfigure - Web - Actuator + - Data # OpenApi is a top-of-stack capability (like Web/Cqrs/Security/Actuator): it generates an OpenAPI 3.1 # document from manifests that already exist, so it is nearly all READS. It reads Web's RouteManifest/ diff --git a/docs/README.md b/docs/README.md index 51e57b2..cab8295 100644 --- a/docs/README.md +++ b/docs/README.md @@ -91,7 +91,8 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa | [Actuator](modules/actuator.md) | `firefly/actuator` — health/info/beans endpoints, the Spring-Boot-Actuator analogue | | [Observability](modules/observability.md) | `firefly/observability` — the `MeterRegistry`, Prometheus/Micrometer-JSON exposition, CQRS metrics | | [Admin Dashboard](modules/admin.md) | `firefly/admin` — the browser dashboard over the actuator; reads its endpoints in-process, so its own URL is the security boundary | -| [Bean Graph](modules/bean-graph.md) | The dashboard's drawn dependency graph — interface-resolved edges, longest-path layering, cycle reporting | +| [Bean Graph](modules/bean-graph.md) | The dashboard's drawn dependency graph — components, `#[Bean]` products and `#[ConfigProperties]` DTOs as nodes, interface-resolved edges, longest-path layering, cycle reporting | +| [Data Browser](modules/data-browser.md) | A Django-style database browser over `CrudRepository` beans — **off by default**, writes behind a second gate, and no create. The model layer ships today; the dashboard page is not routed yet | ### Testing diff --git a/docs/index.md b/docs/index.md index 320e172..0b54600 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,7 +28,9 @@ request after that runs against plain PHP arrays — no runtime reflection on th and method security (`#[PreAuthorize]`) enforced with no proxy magic. - **Production-ready out of the box** — an Actuator surface (health/info/beans) and a Prometheus/Micrometer-style metrics core, both secured by the same config as everything else, plus a server-rendered - [admin dashboard](modules/admin.md) over them with a drawn [bean graph](modules/bean-graph.md). + [admin dashboard](modules/admin.md) over them with a drawn [bean graph](modules/bean-graph.md), and an + opt-in, off-by-default [data browser](modules/data-browser.md) over your own repositories — shipping today as + a library, with its dashboard page still to land. - **An API document that cannot drift** — [`firefly/openapi`](modules/openapi.md) generates OpenAPI 3.1 from the same compiled manifests the dispatcher and the validator read, and serves the official Swagger UI from your own origin — no annotation dialect, no npm, no CDN. @@ -68,7 +70,7 @@ Module guides are grouped by concern under [`modules/`](modules/error-handling.m | **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [Messaging](modules/messaging.md) | | **CQRS** | [Command/Query](modules/cqrs.md) | | **Security** | [Security](modules/security.md) | -| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) | +| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) | | **Testing** | [Testing](modules/testing.md) · [Integration Testing](modules/integration-testing.md) | | **Tooling** | [Installer](modules/installer.md) | diff --git a/docs/modules/admin.md b/docs/modules/admin.md index eb60e2b..24f6b47 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -222,6 +222,10 @@ Under PHP-FPM every request is a different process, and three pages inherit that | `firefly.admin.graph.max-nodes` | `220` | The ceiling past which the [bean graph](bean-graph.md) lists relations instead of drawing them. Clamped to a minimum of `0`, which suppresses the diagram entirely. | | `firefly.admin.pages.exclude` | `''` | CSV of page slugs to refuse. This is a **refusal, not a menu preference**: an excluded page is hidden *and* its URL 404s — hiding `env` from the menu achieves nothing if the URL still answers. Use `overview` for the index page. | +The `firefly.admin.data.*` keys are documented separately, in [Data Browser](data-browser.md#configuration-fireflyadmindata), +because the browser is gated independently of everything above: `firefly.admin.enabled` does **not** switch it on, +and neither does `app.debug`. + ## Laravel comparison | Concern | Plain Laravel | LaraFly (`firefly/admin`) | @@ -244,5 +248,6 @@ Under PHP-FPM every request is a different process, and three pages inherit that --- See also: [Actuator](actuator.md) for the endpoints themselves, [Observability](observability.md) for the metrics -and HTTP-exchange stores the dashboard renders, and [Bean Graph](bean-graph.md) for the one page that is more than -a table. +and HTTP-exchange stores the dashboard renders, [Bean Graph](bean-graph.md) for the one page that is more than +a table, and [Data Browser](data-browser.md) for the Django-style view over your own repositories — which is +**off by default and does not inherit `firefly.admin.enabled`**. diff --git a/docs/modules/bean-graph.md b/docs/modules/bean-graph.md index f4e39dc..bf68131 100644 --- a/docs/modules/bean-graph.md +++ b/docs/modules/bean-graph.md @@ -12,11 +12,57 @@ installed attached itself to. composer require firefly/admin # the graph is a page of the dashboard, not a package of its own ``` -## Where the edges come from +## What counts as a node -Nothing is reflected at request time. `ComponentScanner` records, at **scan** time, the class and interface types -each component's constructor asks for, and `ComponentDescriptor::$dependencies` carries them through the compiled -manifest exactly like every other scanned fact: +A LaraFly application has **three kinds of bean**, and all three are nodes: + +| Kind | What it is | Where the node comes from | +|---|---|---| +| `component` | A scanned `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` class | The beans catalogue | +| `bean` | A **value produced by a `#[Bean]` factory method** on a `#[Configuration]` | The `produces` rows of the catalogue | +| `config` | A `#[ConfigProperties]` DTO bound from configuration | The `configprops` endpoint | + +That list is the whole design, and it is worth saying why, because the first version of this page only knew about +the first kind and was therefore *structurally incapable* of showing framework wiring. + +A framework's wiring lives almost entirely in the second kind. An auto-configuration is a `#[Configuration]` whose +`#[Bean]` methods produce `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` and so on. When only declaring +classes were nodes, every edge pointing at one of those products pointed at a node that did not exist. Measured on +a stock skeleton: **42 nodes, 41 `#[Bean]` products missing, 21 dangling dependencies, and exactly one edge drawn.** +The graph was not sparse — it was a field of disconnected dots with the mechanism removed. + +The third kind is a smaller version of the same mistake. A `#[ConfigProperties]` DTO is bound and injectable but is +neither scanned as a component nor produced by a factory, so nothing in the beans catalogue can see it: it showed +up as an *unresolved dependency* of the service that injects it rather than as the bean it is. It is read from the +`configprops` endpoint alongside the catalogue for exactly that reason. + +### The identity of a `#[Bean]` product + +Usually the produced **type** is the identity, because that is the key the container binds and the key every +consumer asks for. `MeterRegistry` is the node; the `#[Configuration]` that made it is recorded on the node as a +detail (`ObservabilityAutoConfiguration::meterRegistry()`), not as its name. + +When **two factory methods produce the same type** — the shape that requires `#[Primary]`/`#[Qualifier]` to +disambiguate — the type alone would collapse them into one node and hide exactly the ambiguity the reader came to +look at. So each competitor gets `Declaring::method()` as its id, and the bare type resolves to the first of them. +That mirrors the container itself, where the type key aliases the winner and every candidate stays reachable by +name. + +## What counts as an edge + +Two kinds, and they mean different things: + +| Edge | From → to | Meaning | +|---|---|---| +| `injects` | A bean → something it declared a dependency on | The consumer asked for it; the container satisfies it | +| `produces` | A `#[Configuration]` → the value one of its `#[Bean]` methods returns | This class is where that bean comes from | + +`injects` edges are drawn for a component's **constructor** parameters *and* for a `#[Bean]` **factory method's** +parameters — the product depends on what its factory asked for. That union is where a framework's wiring actually +lives, and a graph built from constructors alone draws almost nothing. + +Nothing is reflected at request time to work any of this out. `ComponentScanner` records the types at **scan** time +and they ride the compiled manifest exactly like every other scanned fact: ```php /** @@ -30,30 +76,32 @@ manifest exactly like every other scanned fact: public array $dependencies = [], ``` -`ActuatorRouteRegistrar` snapshots the condition-filtered registry into `BeansCatalog` at boot, and `BeanGraph` -turns that catalogue into nodes and edges. A `string $name` parameter is configuration, not wiring, and is not an -edge. A **nullable or defaulted** class parameter *is* an edge — an optional collaborator is still a relationship. +A `string $name` parameter is configuration, not wiring, and is not an edge. A **nullable or defaulted** class +parameter *is* an edge — an optional collaborator is still a relationship. + +## Why an edge through an interface is labelled with that interface -The field is declared last with a default, so a manifest compiled before it existed still rehydrates; an app -running on an old `bootstrap/cache/firefly/` gets a graph of nodes with no edges until the next `firefly:cache`. +A constructor asks for a **type**, and that type is very often an interface — `EventPublisher`, +`HealthIndicator`, `Cache` — while the bean that satisfies it is a concrete class, or the return of a factory +method. An edge list built naively from declared types therefore points at nodes that do not exist. -## The hard part is not drawing, it is resolving +So every dependency is resolved through an index of *what satisfies what* — a component's `interfaces`, and every +`#[Bean]` method's produced type — before it becomes an edge. `PostgresEventPublisher` is what `EventPublisher` +links to. -A constructor asks for a **type**, and that type is very often an interface — `EventPublisher`, `HealthIndicator`, -`Cache` — while the bean that satisfies it is a concrete class that merely implements it. An edge list built -naively from constructor types therefore points at nodes that do not exist, and the graph comes out as a field of -disconnected dots. +The edge then records the interface it went through, in a member called **`via`**, and both surfaces show it: the +diagram's edge `` reads `Consumer → Target (via EventPublisher)`, and the **Relations** table has a +*Wired by* column naming the interface, or `—` when the constructor named the concrete type. -Every dependency is resolved through an interface index first, so `PostgresEventPublisher` is what `EventPublisher` -actually links to. The edge is then marked **`via`** with the interface it went through, so the reader can see the -indirection rather than being quietly shown something they did not write. The **Relations** table under the diagram -has a *Wired by* column that spells it out for every edge: the interface name, or the literal `class` when the -constructor named the concrete type. +That label is the honesty in the whole page. Without it the reader is shown a relationship they never wrote — +`WalletService → EloquentWalletRepository` is *true*, but what they wrote was `WalletRepository`, and the gap +between the two is precisely where a mis-wiring hides. With it, the indirection is visible and the port they +depend on is named. The index is built in catalogue order and **first implementor wins**, deterministically — the catalogue is emitted -in scan order, so the same application always draws the same graph. An interface with several implementors is a -real ambiguity that the container resolves with `#[Primary]`/`#[Qualifier]`, and the graph says so by listing the -edge as `via` rather than pretending the choice was obvious. +in scan order, so the same application always draws the same graph rather than reshuffling between machines. An +interface with several implementors is a real ambiguity that the container resolves with `#[Primary]`/`#[Qualifier]`, +and the graph says so by listing the edge as `via` rather than pretending the choice was obvious. ## Layers, and why arrows read downward @@ -62,33 +110,47 @@ thing it depends on, and the levels are then flipped so that level 0 holds the t result is that a node always sits below everything that depends on it, arrows flow consistently downward, and the eye can follow a chain from a controller to the repository at the bottom of it. -Depth is memoised, and the walk carries its own visited set, so a **cycle terminates instead of recursing -forever** — and the edge that closed it is reported rather than swallowed. +Within a level the nodes are **clustered by module** — the first two namespace segments, `Firefly\Observability`, +`App\Http` — so related things end up adjacent rather than scattered, and each module gets a stable colour assigned +by position, with a legend whose entries toggle. Hues are kept away from the red and green the rest of the +dashboard reserves for status. + +Each level is then **wrapped into a grid of its own** rather than laid out as one row. A pure layered layout is +wrong for this graph: dependency depth is shallow and wide, so most beans land on one or two levels — a stock +skeleton produced a single row 54 nodes and 9184px across, which the fit-to-view control then scaled to 11%, i.e. +unreadable. Wrapping keeps the drawing a compact rectangle while arrows still read downward from dependents to +dependencies. + +## Cycles are reported, not fatal -## Cycles are reported as a fact about your application +Depth is memoised and the walk carries its own visited set, so a **cycle terminates instead of recursing forever**. +The edge that closed it is collected, and when the walk finds one a *Circular dependencies* panel appears above the +diagram listing every closing edge, with the **Cycles** stat turned red. -When the walk finds one, a *Circular dependencies* panel appears above the diagram listing every closing edge, and -the **Cycles** stat turns red. +Reporting rather than throwing is the deliberate choice, and it is worth being explicit about why. A cycle is a +fact about *your application*, not a malfunction of the page that drew it — and the page is very often the only +thing that can tell you. The container has no cycle detection of its own, so a cycle among eager singletons does +not produce a helpful error: it exhausts memory at boot. If this page refused to render on finding one, the single +tool capable of naming the two classes involved would go dark at exactly the moment you needed it, and you would be +back to a process that died with no message. -This is worth more than it looks. The container has no cycle detection of its own, so a cycle among eager -singletons does not produce a helpful error — it exhausts memory at boot. A page that names the two classes -involved turns "the app died with no message" into a five-second diagnosis. The page's own advice is the right -one: break one of the edges, usually by injecting an interface and letting the other side depend on that. +So it renders, draws everything else, and names the closing edges. The panel's own advice is the right one: break +one of these edges, usually by depending on an interface and letting the other side provide it. ## The panels | Panel | Shows | Notes | |---|---|---| -| Stats | Beans, Relations, Layers, Cycles, Unresolved | Cycles renders as a red chip when non-zero | -| Circular dependencies | Every closing edge, `from` → `depends on` | Only rendered when there is at least one | -| Wiring | The layered SVG diagram | Filter box highlights a bean by name | -| Relations | Every edge as `Bean` / `Depends on` / `Wired by` | Always rendered, filterable — this is the fallback when the diagram is suppressed | -| Provided outside the container | Constructor types nothing in the container provides | Chips, with the full type as a tooltip | +| Stats | Beans, Components, `#[Bean]` products, Config DTOs, Relations, Layers, Cycles | Cycles renders as a red chip when non-zero | +| Circular dependencies | Every closing edge, `Bean` → `Depends on` | Only rendered when there is at least one | +| Wiring | The layered SVG diagram — module legend with toggles, a find box, Fit/Reset controls, drag-to-pan and scroll-to-zoom, and an inspector panel showing a selected bean's dependencies and dependents | Suppressed past the node ceiling | +| Relations | Every edge as `Bean` / `Depends on` / `Wired by` | Always rendered, filterable — the fallback when the diagram is suppressed | +| Provided outside the container | Declared types nothing in the container provides | Chips, with the full type as a tooltip | -Each node is a rounded box carrying the bean's short class name, its stereotype, and its in/out degree (`3↑ 1↓`); -its `<title>` carries the fully-qualified class, scope and both degrees, so hovering identifies it exactly. Edges -are cubic Bézier curves with an arrow marker; an edge that went through an interface carries a `<title>` reading -`via <Interface>`. +Each node is a rounded box carrying the bean's short name, its kind, and its in/out degree; its `<title>` carries +the fully-qualified identity and, for a `#[Bean]` product, the factory method that produced it. Edges are curves +with an arrow marker, styled by edge type, and an edge that went through an interface carries the interface in its +title. The diagram is plain inline SVG generated server-side — no JavaScript graph library, no layout engine, no network request. It is the same "no npm step, no CDN" rule the [rest of the dashboard](admin.md#no-build-step) follows. @@ -101,11 +163,11 @@ diagram past a couple of hundred nodes is a hairball, not something a person can would be a worse answer than declining to. It is configurable because "unreadable" depends on the screen and the application — raise it to draw a bigger graph anyway, or set it to `0` to always get the list. -**"Provided outside the container" is not a warning.** Those are constructor types satisfied by a Laravel container -binding rather than a scanned bean — the `Request`, the config repository, a database connection, a framework -contract. They are listed rather than silently dropped precisely because *"why is my bean not in the graph"* is the -question this page has to be able to answer. A type appearing there is usually correct; a type appearing there that -you expected to be a bean of yours means your scan did not see it, and `firefly.scan.paths` is the first thing to +**"Provided outside the container" is not a warning.** Those are declared types satisfied by a Laravel container +binding rather than a bean — the `Request`, the config repository, a database connection, a framework contract. +They are listed rather than silently dropped precisely because *"why is my bean not in the graph"* is the question +this page has to be able to answer. A type appearing there is usually correct; a type appearing there that you +expected to be a bean of yours means your scan did not see it, and `firefly.scan.paths` is the first thing to check. ## Reading it against the Conditions page @@ -121,16 +183,14 @@ is visible in one glance on the graph, and Conditions then tells you which `#[Co ## Known-latent -- **`#[Bean]` factory-method parameters are not drawn.** `BeansCatalog` already publishes them (under `produces`, - recorded on each `BeanDescriptor`), but `BeanGraph` builds edges from constructor `dependencies` only. So a - `#[Configuration]` class appears as a node with the edges *its own constructor* declares, and the wiring its - `#[Bean]` methods perform is not yet drawn. This under-draws framework auto-configuration classes specifically, - and not application `#[Service]`/`#[Repository]` beans, which wire through constructors. -- **`#[Primary]`/`#[Qualifier]` do not steer the interface index.** First implementor in scan order wins. The edge - is marked `via` so the indirection is visible, but on an interface with several implementors the drawn target may - not be the one the container resolves. -- **No layout beyond layering.** Nodes are centred within their level in the order they came out of the sort - (`level`, then label); there is no crossing-minimisation pass, so a dense graph has crossing edges. +- **`#[Primary]`/`#[Qualifier]` do not steer the index.** First writer in scan order wins, both for an interface + with several implementors and for the bare type key of a contested `#[Bean]`. Every competitor still gets its own + node and the edge is marked `via`, so the ambiguity is visible — but the drawn target may not be the one the + container resolves. +- **No crossing minimisation.** Nodes are ordered within a level by module and then label, and levels are wrapped + into grids; there is no pass that reorders them to reduce edge crossings, so a dense graph has crossing edges. +- **An unresolved type is reported, never explained.** The page can say a type is provided outside the container; + it cannot say by *which* binding, because a Laravel container binding carries no descriptor to read. --- diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md new file mode 100644 index 0000000..e01b1da --- /dev/null +++ b/docs/modules/data-browser.md @@ -0,0 +1,357 @@ +# Data Browser + +The data browser is a Django-admin-style view over your application's own data, built on top of the LaraFly +[data layer](data.md) and shipped with the [admin dashboard](admin.md). + +It is **off by default, and it does not inherit the dashboard's default.** Read +[The two gates](#the-two-gates) before you switch it on — that section is the point of this page. + +!!! warning "Today this is a library, not a URL" + Discovery, schema derivation, reads and both writes are complete, tested and usable from your own code via + `DataBrowser::forContainer()`. The Blade page and the route that would put it in the dashboard's menu have + **not landed** — `firefly/admin` registers no data-browser page, and `admin.md`'s page list is still + thirteen. Setting `firefly.admin.data.enabled` therefore opens an API, not a screen. See + [Known-latent](#known-latent). + +```bash +composer require firefly/admin # the browser is part of the dashboard, not a package of its own +``` + +`DataBrowser` is the single entry point, and `DataBrowser::forContainer($container)` assembles one from the +application container in a line. Discovery, schema derivation, reads and the two writes all go through it, and +every one of them is behind the gates below. + +## What it discovers + +Nothing is registered, declared or configured. The browsable resources are **every bean whose scan-time +interface list contains `CrudRepository`** — which means a repository you wrote is browsable the moment the +container has it, and one you delete stops being browsable without anyone editing a list. + +The source is `BeansCatalog`, the boot-time snapshot of the condition-filtered bean registry — the same rows +`/actuator/beans` serves. Each row already carries `class`, `stereotype` and the **full interface closure** +`ComponentScanner` recorded with `class_implements()` at scan time, so "is this bean a repository, and does it +also page?" is two `in_array()` calls over data the process already holds. + +Re-deriving that by reflecting over every registered class at request time would be slower, would break the +framework's reflection-free boot contract for no gain, and — the decisive point — would find classes the +**container never registered**, so the menu would offer resources that cannot be resolved. The catalogue is the +definition of *what this application actually wired*, which is exactly the question a browser is asking. + +Each discovered resource carries the capability flags every later path branches on: + +| Flag | Means | Consequence | +|---|---|---| +| `paged` | The repository implements `PagingAndSortingRepository` | A page can be asked for by page number; the database does the offset, limit, `ORDER BY` and `COUNT` | +| `eloquent` | It is an `EloquentRepository` whose `$model` resolved to a real model class | Schema-derived columns, SQL-side search and sort, and any write at all | + +A resource with neither is still listable — it is just expensive and read-only. + +### Slugs are derived from the class, not from a counter + +A slug is what addresses a resource — in a URL an operator bookmarks, in a link another page renders — so it +must not move because an unrelated repository was added. The base slug is the kebab-cased short name of the **entity** (falling back +to the repository's own name with the conventional `Eloquent` prefix and `Repository` suffix stripped), which +depends on nothing but that class. + +A genuine collision — two `Wallet` entities in different namespaces — is resolved by **qualifying both sides** +with their full namespace rather than by suffixing one with `-2`. An index suffix depends on scan order, so the +loser's URL would change if the winner were ever removed; a namespace-qualified slug is a property of the class +alone. Labels are disambiguated the same way, because a menu with two entries both reading "Wallet" is not a +menu. + +## The two gates + +### `firefly.admin.data.enabled` defaults to **`false`** + +The dashboard itself follows `app.debug`, and [the argument for that](admin.md#access-the-whole-security-boundary) +is sound *for what the dashboard shows*: beans, conditions, mappings and resolved configuration are facts about +the **application**, and an application already serving stack traces has already published facts of that kind. + +This page shows facts about the application's **users**. + +That is a categorically bigger disclosure, and the routine mistakes that expose it are the same ones that expose +nothing much today: a debug flag left on in a staging environment that shares a database with production, a +`.env` copied to a box that was supposed to be internal, a developer laptop tunnelled for a demo. Each becomes a +customer-record disclosure the moment a browser is wired to `app.debug`. + +So the gate is **separate, explicit, and off**. `app.debug` cannot switch it on, and neither can +`firefly.admin.enabled`. Both of those must already be true **and** this key must be set: + +```php +'firefly' => [ + 'admin' => [ + 'enabled' => true, // the dashboard itself + 'data' => [ + 'enabled' => true, // ...and, separately, the data browser + ], + ], +], +``` + +Disabled means **empty, everywhere**. The resource registry returns nothing when the key is off, even though +every operation is gated again downstream. The redundancy is deliberate: the registry is public API a view could +hold directly, and a discovery list that leaked the names of an application's entities while the browser was +switched off would already be a disclosure. + +### Writes need `firefly.admin.data.writable` **on top of that** + +Also `false`, also its own key, and **ineffective on its own** — a write requires both. + +Reading the wrong row is a disclosure; deleting it is data loss with no undo, from a form, over a session that +may be nothing more than "debug was on". Turning on the browser is a decision about **visibility**; turning on +writes is a decision about **custody**. Collapsing the two into one key means the operator who wanted to look at +a table also armed the delete button. + +A write attempted with only one gate set is **refused with a stated reason**, not silently ignored. + +## Create is deliberately absent + +There is no `create()`, and that is not an omission to be filled in later. + +A generic create form over an arbitrary entity is a promise the browser cannot keep. **An aggregate's +constructor is where its invariants live** — an `Order` that must have at least one line, a `Wallet` whose +balance starts at zero in the currency it was opened in, a value object that rejects a malformed IBAN — and a +form built from a column list knows none of them. + +There are only two ways to build the row, and both are wrong: + +1. **Call the constructor** — which needs arguments the form cannot supply in the right types or the right + order, and which fails on the first entity with a non-trivial signature. +2. **Write the columns straight to the table** — which produces a row the domain model considers impossible, + and which every later read then has to cope with. + +The second is what a "just insert the columns" implementation actually does, and it is *worse than having no +button*, because it looks like it worked. Creation belongs to the application's own code, where the constructor +is. + +The two writes that do exist pass that test. `update()` operates on a row that **already satisfies its +invariants** and changes named columns on it; `delete()` needs no invariant at all. + +## Reads: four paths, and one of them is a foot-gun + +| # | Path | How | +|---|---|---| +| 1 | **Paged, unsearched** | `findPaged(Pageable)` — the repository does the offset, limit, `ORDER BY` and `COUNT`. The only path whose cost is independent of table size. | +| 2 | **Paged, searched, Eloquent** | `findBySpecificationPaged(Specification, Pageable)` — filter, page and count all happen in SQL | +| 3 | **Unpaged** | `findAll()`, then sort and slice **in PHP** | +| 4 | **Unpaged, searched** | Path 3 plus an in-PHP substring filter; no SQL is involved in the matching at all | + +Path 2 goes through `EloquentRepository`'s public **specification seam**, which applies the predicate to the +repository's *own* `query()` builder. Going around it with `Model::query()` would have been shorter and would +have silently dropped any constraint a repository added by overriding `query()` — which on a repository that +scopes to a tenant is a cross-tenant disclosure. + +!!! warning "Path 3 is a foot-gun, and it is load-bearing to say so" + `findAll()` on a plain `CrudRepository` issues `SELECT *` with no `LIMIT`, hydrates every row of the table + into PHP objects, and only then does the browser throw away all but 25 of them. On a table of ten thousand + rows that is a slow page; on a table of ten million it is an out-of-memory that kills the worker — and it + happens on the **first click**, not gradually. There is no way to do better through the `CrudRepository` + interface: it has no limit, no offset and no count-with-predicate. The honest options were "refuse to browse + repositories that cannot page" or "browse them and say what it costs"; this is the second. **A repository + that will be browsed against a large table should implement `PagingAndSortingRepository`**, at which point + it takes path 1. + +**Every listing is ordered, even when nobody asked.** With no `ORDER BY`, a paged query's row order is whatever +the storage engine finds convenient, and it is allowed to differ between the query for page 1 and the query for +page 2 — so a row can appear on both pages while another appears on neither, and the operator sees a table +missing records that are actually there. With no requested sort, the identifier is used: stable, and always +indexed. + +### Search is bound, never interpolated + +On the SQL paths the term is passed as a **binding** to `where(column, 'like', ?)`. It is never concatenated +into a fragment, never handed to `whereRaw`, and therefore cannot become SQL no matter what it contains. + +The **column names are not caller data at all**: they come from the derived schema, built from the driver's own +column list or from a class's declared properties, and a caller-supplied sort column is checked for membership +in that list before use — an unknown one is dropped, not quoted. + +`%` and `_` inside the term are deliberately left as **wildcards** rather than escaped. `LIKE` has no portable +escape character (sqlite has none by default, MySQL uses backslash, ANSI needs an explicit `ESCAPE` clause), so +escaping "portably" means breaking search on some driver — and an operator who types `%` into an admin search +box wants a wildcard. + +Search covers the **first twelve searchable columns** in schema order: OR-ing a `LIKE` across every text column +of a wide table produces a query no index can help with, and past a dozen columns the page is slow enough that +an operator will assume it hung. + +## Columns are a property of the resource, never of a row + +The tempting implementation is `$model->getAttributes()` on the first row, using its keys as the columns. It is +wrong in three ways that all bite in production: an **empty table** yields no columns at all (so the page +renders as broken rather than as empty), a row hydrated with a `select` of two columns yields two columns for +the whole resource, and an accessor-heavy model yields whatever `$appends` decided rather than what the table +holds. + +So columns are derived **once**, from a source that describes the resource: + +| `source` | Derivation | Meaning | +|---|---|---| +| `schema` | The live database via the schema builder | Authoritative — every column, real nullability | +| `entity` | The entity class's public and promoted properties | Whatever the class chose to expose | +| `none` | Nothing could be derived | No connection, no model, no typed entity | + +The source is recorded on the schema and shown, because *"why is this column missing"* is a question the page +has to be able to answer. + +**The schema is authoritative; the casts refine it.** `Schema::getColumns()` reports what the driver knows, and +the driver frequently does not know what the application meant — sqlite stores a `json()` column as `text` and a +`boolean()` as `tinyint`, so a type map built from `type_name` alone shows a JSON blob as a string and a flag as +a number. The model's own `$casts` carry the semantic type the schema cannot express, and where the two disagree +**the cast wins**, because the cast is what the application will hand the view. + +### The display type vocabulary is closed, and deliberately small + +`string`, `int`, `bool`, `datetime`, `json`. It is a **rendering hint, not a schema echo**: the view has to +decide "right-align this", "draw a checkbox", "format this as a timestamp", "pretty-print this blob", and there +are only those four decisions plus a default. Anything outside the vocabulary degrades to `string` rather than +reaching the view. + +!!! note "Why `decimal` and `float` map to `string`" + A `decimal(10,2)` column arrives from PDO as the string `"10.10"`, and that is not an accident of the + driver — it is how the value survives a round trip without binary floating point eating the last cent. + Typing it `int`/`float` invites the view to format it as a number, and the first thing a number formatter + does to `"10.10"` is render it as `10.1`. **A browser that silently rewrites a money column is worse than + one that shows the raw text**, so the raw text is what the type promises. `int` is reserved for genuinely + integral columns — keys, counters, foreign keys — where right-aligning is correct and no precision can be + lost. + +### The identifier is derived, and allowed to be null + +Everything past the listing keys on it: the detail view addresses a row by it, delete addresses a row by it, and +update addresses a row by it *while refusing to write it*. A browser that guessed wrong would render a link to a +row it cannot fetch — or, far worse, issue a delete whose `WHERE` clause matched more than one row. + +So it is derived explicitly: Eloquent's own `getKeyName()` (which respects a model that renamed it), or a +conventional identifier property on a plain entity. When it cannot be determined the resource is browsable as a +**list and nothing else**, and every `find`/`delete`/`update` is refused with a reason rather than improvised. + +Records are projected against the schema's column list and inherit **its order**, with a column the row did not +supply present as `null` rather than missing. `getAttributes()` returns keys in whatever order the driver +returned them, which differs between drivers and can differ between two rows of the same table after a migration +adds a column — and a detail page whose fields move between rows is unreadable. + +## Secrets + +Sensitivity is decided **by name, in one place**: the actuator's own `SensitiveValueMasker`, the same rule that +masks `/env` and `/configprops`, reused rather than mirrored — a second copy of a masking list is how a masking +list rots. + +A model's own **`$hidden` is treated as a second sensitivity source.** The name rule catches `password`, +`api_token` and their relatives but cannot know that this application considers `recovery_phrase` a secret. A +model that already hid a field from its JSON representation has stated that intent in the only place it could, +so the browser honours it rather than publishing in HTML what the model refuses to publish in JSON. + +A sensitive column is masked in the listing, masked in the detail view, **excluded from search**, and **refused +as an update target**: + +- Excluded from search because a box that answers *"yes, some row's `api_token` starts with `sk_live_9`"* is an + oracle, and an operator can walk it one character at a time. +- Refused as an update target because its *displayed* value is `******` — round-tripping a rendered form would + write the mask over the real credential, which is a data-loss bug the masking itself created. + +The **identifier** is refused as an update target too, for a different reason: re-keying a row from a generic +form is not an edit, it is a different row. Foreign keys pointing at the old value do not follow, and the browser +has no way to know which ones exist. + +Both refusals are enforced twice — a predicate the view uses to render the field read-only, and again in the +write path, so a hand-crafted POST cannot reach what the form would not offer. + +## Writes + +`update()` and `delete()`, both returning a typed `DataWriteResult` with **four distinguishable outcomes** +rather than a bool: + +| Outcome | Means | What the operator should do | +|---|---|---| +| `Done` | It happened | See the new state | +| `Refused` | A gate, or something the browser will never do | Change configuration — or stop asking | +| `NotFound` | The row or the resource is gone | Navigate away; a retry will not help | +| `Failed` | The database said no | Look at the log | + +A bare `false` collapses four situations a person needs to tell apart, and rendering "delete failed" for all four +sends an operator to debug a database that is working perfectly because a config key is off. + +**An update only writes what actually changed.** A submitted form round-trips every field; the ones whose value +did not change — plus the identifier and any masked secret — are dropped, and the result lists the columns +actually written. A submitted field that is not a column of the resource refuses the whole update rather than +being ignored. + +**A delete is verified after the fact** with `existsById()` rather than trusted, because +`CrudRepository::deleteById()` returns `void`: a repository whose delete was a no-op — a soft-delete scope that +excluded the row, an override that swallowed it — would otherwise report success, and the operator would watch +the row reappear on the next page load. + +**A non-Eloquent resource is refused for writes**, with a reason. There is no table to address and no +`setAttribute` to call. + +## Nothing throws at the caller, and no error text is an exception message + +Reads answer with a listing that carries a reason, or a null record; writes answer with one of the four outcomes. +A view rendering an admin page must not have to be exception-safe to stay on its feet — and, more sharply, an +exception that escaped would be rendered by the framework's error page. + +That matters because of what a database exception *contains*. Laravel's `QueryException` stringifies the failing +SQL **and its bindings** into `getMessage()`. Echoing that to the browser would publish the schema and, far +worse, the values that were bound — which on a search over a users table is the operator's own query, and on a +detail lookup is a primary key. + +So every reason a page can render is a **fixed sentence composed in this layer**, plus at most the exception's +class name. The message stays in the exception, where a log can have it. + +## Configuration (`firefly.admin.data.*`) + +| Key | Default | Meaning | +|---|---|---| +| `firefly.admin.data.enabled` | **`false`** | Enable the browser at all — today that means the API, since no page is routed yet. Does **not** follow `app.debug` or `firefly.admin.enabled` — see [The two gates](#the-two-gates). | +| `firefly.admin.data.writable` | **`false`** | Allow `update` and `delete`. Requires `enabled` as well; ineffective alone. | +| `firefly.admin.data.page-size` | `25` | Default rows per page. Clamped into `[1, max-page-size]`. | +| `firefly.admin.data.max-page-size` | `200` | Ceiling applied to any caller-supplied page size. Itself capped at **1000**, because `?perPage=1000000` on a resource that cannot page is a request to materialise the table into PHP memory. | +| `firefly.admin.data.exclude` | `''` | CSV of resource slugs to refuse. A **hard refusal, not a menu preference**: the resource is hidden *and* every operation on it is refused. Hiding `user` because the table holds PII achieves nothing if the row URL still answers. | + +The page-size cap is applied to whatever the caller asks for, so the query layer never sees a size it did not +agree to. + +## Reflection is confined to one class + +Discovery reads the compiled catalogue; schema derivation reads Laravel's schema builder; queries read the +container. Exactly one class reflects, and only for two facts no manifest carries: + +1. **Which model a repository manages.** `EloquentRepository` declares `protected string $model` and the + concrete repository sets it as a property *default*. It is protected, there is no accessor, and the value + never reaches a descriptor — `ComponentScanner` records a class's dependencies and interfaces, not its + property initialisers. It is read via `getDefaultProperties()`, which does **not** construct the repository: + discovery must stay cheap and must not be able to fail because a repository constructor wanted a live + connection. +2. **What shape a non-Eloquent entity has.** A plain `CrudRepository` over value objects has no table to ask, so + the only honest column list is the entity's declared fields — public properties and promoted constructor + parameters. Promoted parameters are why accessibility has to be bypassed: `Firefly\Domain\Entity` promotes + `protected int|string|null $id`, so a public-only scan would miss the identifier of every entity built on the + framework's own DDD base class. + +Confining both to one class is what keeps the rest honest — the registry, the schema factory, the query engine +and the browser contain no `Reflection*` reference at all, so the cost and the risk are auditable by grep. Every +entry point is guarded and memoised: reflection on a class the autoloader cannot complete throws, and a +resource list that dies because one repository is broken is useless, so a failure degrades **that one resource** +instead. + +## Known-latent + +- **The browser is the model layer; the dashboard page that renders it is not wired yet.** Discovery, schema, + reads and both writes are complete and tested, and `DataBrowser::forContainer()` makes them usable from an + application's own code today. What has not landed is the Blade page and the route that would put them in + the dashboard's menu — so at present the gates below govern a library, not a URL. +- **No create**, permanently — see [above](#create-is-deliberately-absent). +- **Writes are Eloquent-only.** A plain `CrudRepository` over value objects is browsable and read-only. +- **No relationship navigation.** A foreign key renders as its value, not as a link to the row it points at: + the browser knows a column's type, not its target, and Eloquent relationships are methods rather than + metadata. +- **`firefly/admin` still ships no authentication of its own.** The data browser inherits the dashboard's + access model exactly, which means the [route-level protection](admin.md#access-the-whole-security-boundary) + is your responsibility — and matters more here than anywhere else in the dashboard. + +--- + +See also: [Admin Dashboard](admin.md) for the access model this page sits inside, [Data & Repositories](data.md) +for `CrudRepository`/`PagingAndSortingRepository` and the specification seam, and +[Relational Data](data-relational.md) for `EloquentRepository`. diff --git a/docs/modules/data.md b/docs/modules/data.md index 21ff1d3..865de5d 100644 --- a/docs/modules/data.md +++ b/docs/modules/data.md @@ -252,3 +252,15 @@ enum Direction: string `Direction`'s backing value **is** the Eloquent `orderBy()` direction string, so the mapping at the Eloquent edge is a bare `->orderBy($order->property, $order->direction->value)` with no translation table. + +--- + +## Browsing what a repository holds + +`firefly/admin` ships a [data browser](data-browser.md) that discovers its resources from exactly these ports: +any bean whose scan-time interface list contains `CrudRepository` is browsable, and a repository that also +implements `PagingAndSortingRepository` is paged **in the database** rather than in PHP — which on a large table +is the difference between one page of rows and an out-of-memory. + +It is **disabled by default** and does not follow `app.debug` or `firefly.admin.enabled`; writes need a second +key on top of that, and there is deliberately no create. See [Data Browser](data-browser.md) for the reasoning. diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md index b6944dd..477e049 100644 --- a/docs/modules/openapi.md +++ b/docs/modules/openapi.md @@ -67,33 +67,9 @@ appears. **Request bodies** come from the `#[RequestBody]` DTO, as a `$ref` into `components/schemas` — one component per DTO, reused everywhere, with nested `#[Valid]` DTOs given their own component rather than being inlined, so a -self-referential DTO terminates as a `$ref` cycle instead of recursing forever. - -**Property schemas** merge the DTO's declared constructor types with its compiled constraints, because neither -alone is enough: types-only documents `#[NotBlank] string $name` as an unbounded string, constraints-only documents -`int $quantity` as a string. Backed enums, `DateTimeInterface` and nullability come from the type; the keywords come -from the manifest. - -| Constraint | JSON Schema | -|---|---| -| `#[NotNull]`, `#[NotBlank]`, `#[NotEmpty]` | member added to the parent's `required` | -| `#[NotBlank]` | `type: string` + `pattern: \S` | -| `#[Size(min, max)]` | `minLength`/`maxLength`, or `minItems`/`maxItems` on an array | -| `#[Min]` / `#[Max]` | `minimum` / `maximum` | -| `#[Positive]`, `#[Negative]`, `…OrZero` | `exclusiveMinimum` / `minimum` / … | -| `#[Email]` | `format: email` | -| `#[Pattern]` | `pattern` (PCRE delimiters and no-op flags stripped) | -| `#[UuidValue]`, `#[Phone]`, `#[Iban]`, `#[Bic]`, `#[Isin]`, … | `format` + a `pattern` where the rule matches the raw value | -| `#[Percentage]` | `type: number`, `minimum: 0`, `maximum: 100` | -| `#[DecimalScale(n)]`, `#[Money]` | `multipleOf` | -| `#[AssertTrue]` / `#[AssertFalse]` | `type: boolean` + `const` | - -A nullable member is spelled the 3.1 way — a `type` union including `"null"`, not 3.0's `nullable` keyword. - -**Nothing is dropped silently.** Constraints JSON Schema cannot express (`#[Future]`'s "after now", a Luhn -checksum, a third-party `ValidationRule`) and ones it can only approximate (a PCRE pattern carrying flags ECMA-262 -has no syntax for) are recorded under the `x-firefly-constraints` specification extension. Conforming tools ignore -an `x-` member; a human or a custom generator can read it. +self-referential DTO terminates as a `$ref` cycle instead of recursing forever. The whole derivation — +declared types, compiled constraints, docblock prose — is +[its own section below](#how-a-request-dto-becomes-a-schema). **Responses.** The success entry is keyed by the `#[Mapping]`'s declared status, and its body schema comes from the controller method's declared **return type** — the only place the shape of a successful response is stated anywhere @@ -116,6 +92,327 @@ with no edit in this package. operations, and describing one as `application/json` hands a generator a typed client for a response that is a web page. `firefly.openapi.include-html` documents them anyway, as `text/html`. +## How a request DTO becomes a schema + +A `#[RequestBody]` DTO is turned into a `components/schemas` entry by `DtoSchemaFactory`, from **three sources that +each know a different part of it** — and no two of which can be derived from the other: + +| Source | Knows | Does not know | +|---|---|---| +| `ConstraintManifest` — the compiled rules | which members are required, what shapes they must have | types: a rule list is untyped by construction | +| The **constructor signature**, by reflection | `?int`, a backed enum, a nested DTO, a default value | constraints: they live in attributes the manifest has already digested | +| The **docblock**, plus `#[ApiProperty]` | what the member *means*, an example, a more precise `format` | everything above | + +Neither of the first two alone produces a usable schema. Types-only documents `#[NotBlank] string $name` as an +unbounded string; constraints-only documents `int $quantity` as a string. + +### Why the manifest, and not the `#[Constraint]` attributes + +Reading the attributes back off the DTO is the obvious route to `#[Email]` → `format: email`, and it would document +a validator that does not exist. `ConstraintManifest::rulesFor()` returns the exact +`list<string|ValidationRule>` the `BeanValidator` is handed at request time, and by the time it does, the scanner +has already: + +* applied the **Jakarta null contract** — a `nullable` flag prepended to every property whose declared type admits + null and which carries no `NullAware` rule; +* expanded `#[Size]` into a first-party rule **object** rather than Laravel's polymorphic `min:`/`max:` strings; +* flattened one `#[Valid]` level into dotted keys (`beneficiary.postcode`). + +Generating from the attributes would re-derive all of that by hand and drift from it the first time +`packages/validation` changed a `toRules()` body. Generating from the manifest cannot drift, because the manifest +**is** the contract. + +### The property list, and why there is no `additionalProperties: false` + +The members documented are the **constructor's parameters, in declaration order** — exactly what `ArgumentResolver` +hydrates from. It picks the compiled binding's property list out of the decoded body and splats those keys as named +arguments; keys outside the list are **silently ignored, not rejected**. So no `additionalProperties: false` is +emitted: the server genuinely accepts extra members, and a spec claiming otherwise would make conforming clients +fail requests the server would have served. + +A member the constructor does *not* take is still documented when the manifest carries rules for it, because +`BeanValidator` validates the raw decoded array — such a member is enforced on input even though nothing hydrates +it. + +An empty `required` array is **omitted** rather than emitted: `required: []` is invalid under the OpenAPI 3.1 +meta-schema (`minItems: 1`), and strict validators do enforce it. + +### The type half + +`TypeSchema` handles everything derivable from a type *name* alone. Three shapes get first-class treatment because +a JSON client has to decode them differently and all three are invisible to the constraint list: + +| Declared type | Fragment | +|---|---| +| `string` / `int` / `float` / `bool` | `type: string` / `integer` / `number` / `boolean` | +| `array`, `iterable` | `type: array` | +| A **backed enum** | `enum: [...]` over the backing values, plus `type: integer` when every case backs an int, else `type: string` | +| `DateTimeInterface` (or any implementor) | `type: string`, `format: date-time` | +| `mixed`, `object`, `null`, untyped, or a class this process cannot autoload | `{}` — the "any JSON value" schema, **never** a guessed `type: string` | +| Any other class | *no fragment* — the caller mints a `$ref` instead | + +The backed-enum row is the single highest-value thing the reflection buys: `Currency $currency` documents the exact +accepted set, where the constraint list — usually empty on an enum-typed property, because the type already +constrains it — would have documented an unbounded string. + +### Requiredness is wider than the constraints say + +`MemberType::required()` is deliberately broader than the constraint-derived answer: + +```php +public function required(): bool +{ + return ! $this->hasDefault && ! $this->nullable && $this->type !== null; +} +``` + +A constructor parameter with no default whose type does not admit null **cannot be omitted**: `ArgumentResolver` +splats only the keys the body actually carried, so a missing one raises `ArgumentCountError` inside `new $dto(...)` +— a 500, *after* validation has already passed. Documenting it as optional would hand every generated client a +legal-looking request the server cannot serve. So the PHP signature is treated as the requirement it genuinely is, +alongside whatever `#[NotNull]`/`#[NotBlank]` say. + +JSON Schema states requiredness on the **parent** object, never on the member, which is why the mapper returns a +`PropertySchema` — a schema *plus* that one boolean — rather than a schema alone. + +### Constraints to keywords + +`ConstraintSchemaMapper` walks the compiled rule list and layers keywords onto whatever the declared type already +produced. **First writer wins**, everywhere: the type fragment is seeded before any rule is seen, so +`#[Min(1)] int $quantity` keeps `type: integer` instead of being widened to `number` by the `numeric` rule string +`#[Min]` emits — which would wrongly document `1.5` as acceptable. The same ordering then applies among the rules +themselves, matching declaration order, which is the order the validator applies them in. + +Each attribute below is shown with the compiled rule it actually produces, because that rule — not the attribute — +is what the mapper sees: + +| Constraint | Compiles to | JSON Schema | +|---|---|---| +| `#[NotNull]` | `present` + `NotNull` rule | `required` **and** clears nullability — the one rule that answers both questions | +| `#[NotEmpty]` | `required` | member added to the parent's `required` | +| `#[NotBlank]` | `required`, `string`, `regex:/\S/` | `required` + `type: string` + `pattern: \S` | +| `#[Size(min, max)]` | `Size` rule object | `minLength`/`maxLength`, or `minItems`/`maxItems` when the type is `array` | +| `#[Min(n)]` / `#[Max(n)]` | `numeric` + `gte:n` / `lte:n` | `minimum` / `maximum` | +| `#[Positive]` / `#[PositiveOrZero]` | `numeric` + `gt:0` / `gte:0` | `exclusiveMinimum: 0` / `minimum: 0` | +| `#[Negative]` / `#[NegativeOrZero]` | `numeric` + `lt:0` / `lte:0` | `exclusiveMaximum: 0` / `maximum: 0` | +| `#[Digits(i, f)]` | `numeric` + a `regex:` bounding both parts | `type: number` + `pattern` | +| `#[Pattern(re)]` | `regex:re` | `pattern`, PCRE delimiters stripped | +| `#[Email]` | `email` | `type: string`, `format: email` | +| `#[UuidValue]` | `Uuid` rule | `type: string`, `format: uuid`, `pattern` | +| `#[Phone]` | `E164` rule | `type: string`, `format: phone`, `pattern: ^\+[1-9]\d{1,14}$` | +| `#[CurrencyCode]` | `Currency` rule | `type: string`, `format: currency`, `pattern: ^[A-Z]{3}$` | +| `#[CountryCode]` | `CountryCode` rule | `type: string`, `format: country-code`, `pattern: ^[A-Z]{2}$` | +| `#[LanguageTag]` | `LanguageTag` rule | `type: string`, `format: bcp47`, `pattern` | +| `#[PostalCode]` | `PostalCode` rule | `type: string`, `format: postal-code`, `pattern` | +| `#[Iban]` | `Iban` rule | `type: string`, `format: iban` — **no pattern**, plus `iban:checksum` in the extension | +| `#[Swift]` / `#[Bic]` | `Swift` / `Bic` rule | `type: string`, `format: swift` / `bic` — no pattern | +| `#[Cusip]` / `#[Isin]` | `Cusip` / `Isin` rule | `type: string`, `format: cusip` / `isin`, plus `…:check-digit` in the extension | +| `#[RoutingNumber]` | `RoutingNumber` rule | `type: string`, `format: aba-routing-number`, plus the check digit in the extension | +| `#[Luhn]` | `Luhn` rule | `format: luhn` **only** — no `type`, since Luhn says nothing about it — plus the check digit in the extension | +| `#[Percentage]` | `Percentage` rule | `type: number`, `minimum: 0`, `maximum: 100` | +| `#[Money]` | `PositiveMoney` rule | `type: number`, `exclusiveMinimum: 0`, `multipleOf: 0.01` | +| `#[DecimalScale(n)]` | `DecimalScale` rule | `multipleOf` — `0.01` for scale 2, `1` for scale 0 | +| `#[AssertTrue]` / `#[AssertFalse]` | `accepted` / `declined` | `type: boolean` + `const: true` / `false` | +| `#[Future]` / `#[Past]` | `date` + `after:now` / `before:now` | `type: string`, `format: date-time`; the temporal half lands in the extension | + +`multipleOf` is computed as a division rather than `10 ** -$scale` so the value round-trips through `json_encode` +as `0.01` instead of `1.0E-2` — both are legal JSON numbers, but only the first reads as money in a rendered spec. + +Raw Laravel strings reach the same table through the `#[Rules]` escape hatch, and a few only exist there: + +| Rule string | JSON Schema | +|---|---| +| `nullable` | sets the nullable flag (see below) | +| `required`, `present`, `filled` | member added to the parent's `required` | +| `string`, `numeric`, `integer`/`int`, `boolean`, `array` | the corresponding `type` | +| `url`, `active_url` | `type: string`, `format: uri` | +| `ip` | `type: string`, `format: ipv4` | +| `date`, `date_format` | `type: string`, `format: date-time` | +| `gte:` / `lte:` / `gt:` / `lt:` | `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` | +| `min:` / `max:` / `between:a,b` / `size:` | **polymorphic** — see below | +| `in:a,b,c` | `enum` | +| `accepted` / `declined` | `type: boolean` + `const` | + +Laravel's `min:`/`max:`/`between:`/`size:` are deliberately polymorphic — `Validator::getSize()` reads the *value* +for a numeric attribute and the *length/count* otherwise — so what they translate to depends on the type already +resolved for the property: `minimum`/`maximum` for a numeric one, `minLength`/`maxLength` for a string, +`minItems`/`maxItems` for an array. Firefly's own `#[Size]` no longer emits these (it compiles to a rule object +precisely because the polymorphism was a defect), but `#[Rules('min:3')]` passes the raw string straight through, so +the ambiguity is still reachable and is resolved here exactly as the validator resolves it. + +An argument that is not numeric is not a bound at all — `gte:other_field` is a field reference JSON Schema cannot +express — so it is recorded in the extension rather than coerced to `0`. + +### Nullability, patterns, and the extension + +**Nullability** is spelled the 3.1 way. OpenAPI 3.1 *is* JSON Schema 2020-12, which dropped 3.0's `nullable: true` +in favour of a type union: `type: [string, "null"]`. A schema with no `type` at all already admits null and is left +alone; an `enum` additionally gains a `null` member, because widening `type` alone would leave `null` failing the +enumeration. + +**Patterns** are translated from PCRE (delimiters plus flags, the form every `regex:` rule carries) to the bare +ECMA-262 body the `pattern` keyword expects. `D` and `u` are dropped as genuine no-ops — ECMA `$` without `m` +already anchors at end-of-input, and JSON Schema patterns are already Unicode. Any *other* flag, `i` above all, +cannot be carried across, so the pattern is still emitted (it is the closest true statement available) **and** the +original rule is recorded in the extension, so a reader can see the published pattern is stricter than the server's. +An unparseable pattern is recorded and otherwise ignored — a malformed `pattern` keyword breaks every consumer of +the document, which is far worse than an absent one. + +JSON Schema has exactly **one** `pattern` slot per schema object, and `#[NotBlank]` + `#[Pattern]` on the same +property genuinely produces two. One pattern becomes `pattern`; several become an `allOf` of single-pattern +subschemas. Collapsing them by keeping the last would silently drop the non-blank guarantee. + +**Nothing is dropped silently.** Constraints JSON Schema cannot express (`after:now` — it cannot say "in the +future"; a bare Luhn checksum; a third-party `ValidationRule`, recorded by class name because that is the only +thing knowable about it without executing it) and ones it can only approximate are recorded under the +`x-firefly-constraints` specification extension. Extensions are explicitly permitted by OpenAPI 3.1 and ignored by +every conforming tool, so the document stays valid while the full truth survives for a human or a custom generator +to read. + +!!! note "Where a `format` is invented, and where a `pattern` is withheld" + `format` in JSON Schema 2020-12 is an **open vocabulary** — unknown values are annotations, not errors. IBAN, + BIC, ISIN, CUSIP and E.164 have no registered format name, so self-describing ones are emitted (`iban`, `bic`, + …). The `pattern` is emitted only where the rule matches its PCRE against the **raw** value. Where the rule + normalises first — `Iban` strips spaces and upper-cases; `Bic`/`Swift`/`Cusip`/`Isin` upper-case; + `Luhn`/`RoutingNumber` strip separators — the pattern is deliberately withheld, because publishing the + post-normalisation pattern would reject payloads the server accepts. Under-specifying is the lesser error. + +### Nested DTOs are `$ref` components, never inlined + +A member whose declared type is a class that `TypeSchema` does not resolve becomes its own component and a `$ref`. +`SchemaRegistry` exists for the two problems an inlining generator has: + +**Duplication.** A DTO used by six operations would be emitted six times, and every generated client would mint six +structurally identical anonymous types with six different names. Registering once and referring by `$ref` is what +makes `openapi-generator`/`orval`/`kiota` produce **one named type per DTO**, which is the whole point of +generating the document. + +**Recursion.** `SelfReferential { #[Valid] ?SelfReferential $parent; }` cannot be inlined at all — the expansion +does not terminate. So `ref()` **reserves the component name before invoking the builder**, and a nested call for +the same class finds the name taken and returns the reference immediately, closing the cycle. + +Component **names** are the class's short name, because that is what a human reads in a viewer and what a generator +turns into a type name. Two DTOs sharing a short name across namespaces (`Order\Dto\Address` and +`Billing\Dto\Address`) would collide, so the **second** claimant falls back to its dotted fully-qualified name — +ugly, unambiguous, and rare. First claimant wins, so adding a second `Address` elsewhere never renames the one +already published. + +A **nullable** nested DTO is spelled as the union it actually is: + +```json +{ "anyOf": [ { "$ref": "#/components/schemas/Address" }, { "type": "null" } ] } +``` + +not as a `$ref` with a sibling `type`. In 2020-12 a *validation* keyword beside a reference is applied **with** it, +so `type: "null"` would have to pass as well as the reference and could never hold. Annotations are the opposite +case — a `description` beside a `$ref` is legal — which is why the prose below is applied to either shape. + +Where the nested class has its own manifest entry (the normal case: the compiler compiles every class under the +app's scan roots, not just body DTOs) its own rules are used. Where it does not, the parent's dotted +`#[Valid]`-cascaded keys are **unflattened back into it**, so a nested schema is still constrained rather than a +bare `type: object`. + +### `list<X>` element types come from the constructor docblock + +PHP's `array` says nothing about what is in it, so `#[Valid] public readonly array $lines = []` documented itself +as a bare `type: array` with no `items` — which a client generator faithfully turns into `Array<any>`, a typed +client with an untyped hole in exactly the member that most needed a type. + +The element class is not missing information, though. It is written in the **constructor docblock**, and +`packages/web` already reads it: `RouteScanner::dtoShapes()` resolves it at `firefly:cache` time and compiles it +into the body binding's `dtos` table so `ArgumentResolver` can hydrate the nested payload without reflecting. +That table is a `class => member => {class, list}` map covering **every class reachable from the body DTO, at any +depth**, and it is the first thing the generator consults — because it is not a copy of the answer, it *is* the +answer the hydrator uses. A document generated from it cannot describe a shape the server would refuse to build. + +Three spellings are recognised, and they all mean the same payload: + +```php +/** + * @param list<OrderLineRequest> $lines The lines to order, at least one. + * @param OrderLineRequest[] $legacy The same thing, the older way. + * @param array<int, Fulfilment> $channels A keyed array works too; the key type is ignored. + */ +public function __construct( + #[Valid] public readonly array $lines = [], + public readonly array $legacy = [], + public readonly array $channels = [], +) {} +``` + +A short name is resolved the way PHP would resolve it: an already-qualified name as-is, then the declaring class's +own namespace, then the file's `use` imports. A name that does not resolve to a real class is **dropped entirely** +rather than emitted as a dangling `$ref` — the same choice `RouteScanner` makes when it leaves such a member out +of the hydration table. + +Only a parameter **declared `array`** may take an element type from a comment. A class-typed member is a nested +DTO already resolved from its declared type, and `iterable` is excluded because the scanner excludes it: giving +`items` to a member the hydrator does not bind as a list would describe a request the server cannot accept. + +What the element becomes depends on what it is: + +| Element | `items` | +|---|---| +| A DTO | `{"$ref": "#/components/schemas/OrderLineRequest"}` — its own component, like any nested DTO | +| A backed enum, a `DateTimeInterface`, a scalar | **inlined** — an enum is not a reusable component, and minting one per enum would hand every generated client a named type where an inline union is what the payload is | +| Something `TypeSchema` cannot resolve | no `items` at all, rather than an empty `{}` — both say "any element", and the absent one avoids a later `[]`-vs-`{}` decision | + +A list of DTOs recurses safely for the same reason a plain nested DTO does: `SchemaRegistry` reserves the +component name *before* the builder runs, so `CategoryNode { list<CategoryNode> $children }` closes its own cycle +on the component being built instead of expanding forever. + +`items` is seeded into the **base** fragment rather than layered on afterwards, so the constraint mapper's +first-writer-wins ordering sees a complete declared-type fragment — and so a `#[Size]` on the member still +resolves against the `type: array` sitting beside it and becomes `minItems`/`maxItems` rather than +`minLength`/`maxLength`. + +!!! note "Rules for a list element come from the element's own manifest entry" + Never from the parent's dotted `#[Valid]` keys. `ConstraintScanner` cascades a `#[Valid]` only through a + **class-typed** member, so a parent's dotted keys can never describe a list element in the first place — and + unflattening a Laravel-style `lines.*.sku` into an element schema would invent a member literally named + `*.sku`. The element is constrained because the compiler compiled *its* class too, not because its parent + mentioned it. + +!!! warning "There is a second, reflective path — and it is only ever a fallback" + Three reachable shapes carry no compiled table: a DTO named by `#[ApiResponse(type:)]` (a response has no + binding plan at all), a DTO handed straight to `DtoSchemaFactory::ref()` by something other than a request + body, and a route manifest compiled before the scanner emitted the `dtos` key — a supported state, since that + key is written only when a body DTO actually nests. In all three the element type is still sitting in the + docblock, and the choice is between reading it and shipping `Array<any>` again. The scanner's resolution is + private to `packages/web` and reachable only through a compiled binding, so it is **mirrored** rule for rule. + Two implementations of one rule is a real cost; the alternative was a generator whose output silently + depended on whether a route happened to reach the class. The mirror is deliberately *not* consulted when the + table has a row for the class: a row is complete, so a member missing from it is a member the hydrator will + not treat as a list, and second-guessing that with reflection is how the two paths would drift. + +### The prose half + +The schema's `description` is the DTO's class docblock. A member's is resolved in this precedence: + +1. `#[ApiProperty(description:)]` — the author said it explicitly; +2. the member's **own** docblock; +3. the constructor's `@param` line for it. + +That order is the one people expect from reading a file top to bottom — the closer a statement sits to the member, +the more specific it is. The `@param` fallback matters more than it looks: a promoted constructor property is where +most LaraFly DTOs put everything, and `@param` is the only place PHPDoc lets you describe one without inventing a +property docblock for a parameter. + +**Nothing is invented.** A member with no description in any of the three sources gets **no `description` key**, +rather than a humanised restatement of its own name — `"quantity": {"description": "Quantity"}` is noise that costs +a reader a second to dismiss and costs the file a line per property forever. The schema-level fallback is the one +exception: a DTO with no class docblock gets `Request payload bound from App\Dto\X.`, which is a *locator* telling +you which PHP file to open, not documentation — which is exactly why any real docblock beats it. + +`#[ApiProperty]`'s `format` **overwrites** a constraint-derived one, on the grounds that an author naming a format +is making the more precise statement. Examples are emitted as the **plural array** form, `examples: [...]`: 3.1 +aligned the Schema Object with JSON Schema 2020-12, whose keyword is `examples`, and explicitly deprecated the +singular `example` inherited from 3.0. + +A constructor **default** is copied into `default` only when it is a JSON value — a scalar, `null`, or a list of +scalars. An object or enum default (a promoted `new Money(0)`, say) has no JSON spelling a client could send back, +and emitting a serialised approximation would be a `default` the server never applies. + ## Determinism, and the `{}`-vs-`[]` trap Paths are sorted, verbs within a Path Item are sorted into the canonical OpenAPI order, and `SchemaRegistry` sorts @@ -127,9 +424,30 @@ committing the generated file, which is what makes it go stale. the one that must produce any file or HTTP body. PHP cannot tell an empty map from an empty list, so `json_encode([])` is `[]` — and `"paths": []` or an unconstrained property serialised as `[]` are both type errors against the 3.1 meta-schema that make a strict validator reject an otherwise perfect document. `toJson()` -re-encodes every empty array as `{}`, which is unconditionally safe here because nothing in this document ever -emits an empty *list*: `required`, `tags`, `parameters`, `servers`, `allOf` and the constraint extension are each -omitted entirely rather than emitted empty. +therefore re-encodes empty arrays as `{}`. + +That rewrite used to be **unconditional**, justified by a claim that quietly stopped being true — "nothing in this +document ever emits an empty *list*". A constructor default does. `array $lines = []` is documented as +`default: []`, the rewrite turned it into `"default": {}`, and the document then told every client that omitting +`lines` yields an empty **object** for a member the same schema declares `type: array` two lines above. A generated +client either fails to compile against its own type or ships a wrong default. + +The fix draws the line the rewrite always meant to draw, between **structure** and **data**. `default`, `const` and +`example` hold one instance value; `enum` and `examples` hold a list of them. Those are values the schema +*describes*, not part of the document's own shape, so an empty one is typed by the sibling `type`: `type: array` +(or the 3.1 nullable spelling `type: [array, "null"]`) makes it a JSON array, and anything else falls back to the +structural `{}`. + +Requiring the schema to have *said* `array`, rather than trusting the PHP value, is what keeps the exception +narrow. Those keywords are also perfectly legal DTO member names, so `properties: {"default": {}}` is a reachable +node, and a rule of "an empty array under one of these keys is always a list" would turn that member's own empty +schema into an invalid `[]`. The cost is one genuinely ambiguous case — a `mixed` member with an array default, +which declares no type for anything to decide from. Nothing recurses into a *non-empty* instance either: +`json_encode`'s own list-vs-map rule is already right for it, and rewriting a caller's example payload would +corrupt their empty arrays. + +Everything structural still holds: `required`, `tags`, `parameters`, `servers`, `allOf` and the constraint +extension are each omitted entirely rather than emitted empty. ## `php artisan firefly:openapi` @@ -236,10 +554,24 @@ otherwise get a link that 404s from every page but the root. | `firefly.openapi.title` | `'API'` | Info Object `title`. | | `firefly.openapi.version` | `'0.0.0'` | Info Object `version`. | | `firefly.openapi.description` | `''` | Info Object `description`; omitted from the document when empty. | +| `firefly.openapi.summary` | `''` | Info Object `summary` — the 3.1 short-form line beside `description`. Trimmed; an empty value is "not configured" and is never emitted as an empty member. | +| `firefly.openapi.terms-of-service` | `''` | Info Object `termsOfService`. Same trim-and-omit rule. | +| `firefly.openapi.contact.name` \| `.url` \| `.email` | `''` | Info Object `contact` members. The object is emitted only if at least one is set, carrying only the ones that are. | +| `firefly.openapi.license.name` | `''` | Info Object `license`. **`name` is the gate** — with it empty, no `license` is emitted at all, because the 3.1 License Object requires it. | +| `firefly.openapi.license.identifier` \| `.url` | `''` | The other two License members. They are mutually exclusive in 3.1, so `identifier` wins where both are set and `url` is dropped rather than emitting an invalid object. | | `firefly.openapi.servers` | `[]` | Bare URL strings and/or OpenAPI Server Objects. An entry that is neither — or an object with no `url` — is **dropped**, because it would be invalid under the 3.1 schema and would poison an otherwise-good document. Omitted from the document when empty. | | `firefly.openapi.exclude` | `''` | CSV of path **prefixes** left out of the document. Removes them from the spec only; it does not unroute them. | | `firefly.openapi.include-html` | `false` | Document `#[Controller]` HTML routes as `text/html` operations. | +The optional Info Object members live on `DocumentInfo` rather than on `OpenApiProperties`, and its constructor +argument is last and nullable, so every existing three-argument `OpenApiGenerator` construction — the auto- +configuration's `#[Bean]`, an application's own override bean, the fixtures — keeps producing exactly the document +it produced before. `applyTo()` then **rebuilds** the Info Object's key order rather than appending, into the order +the specification itself lists: `title, summary, description, termsOfService, contact, license, version`. Nothing +consumes that order semantically; a human diffing a committed `openapi.json` does, and `title, version, +description, summary` reads as an afterthought where the spec's own order reads as a table. Any non-spec member an +override bean put into `info` — a `x-` specification extension, say — survives, after the spec ones. + `OpenApiProperties` is read **once**, at `BootPhase::FlushDefinitions`, into an immutable value object — the same lifetime `ExposureModel` has in `firefly/actuator`, and for the same reason: the registrar mounts routes from `specPath`/`viewerPath` at `WiringPasses`, so a post-boot `config()->set()` on those keys could not move an diff --git a/packages/admin/composer.json b/packages/admin/composer.json index a34ec23..c5bc295 100644 --- a/packages/admin/composer.json +++ b/packages/admin/composer.json @@ -19,9 +19,11 @@ "firefly/config": "*@dev", "firefly/container": "*@dev", "firefly/context": "*@dev", + "firefly/data": "*@dev", "firefly/kernel": "*@dev", "firefly/web": "*@dev", "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", "illuminate/http": "^13.0", "illuminate/routing": "^13.0", "illuminate/support": "^13.0" diff --git a/packages/admin/resources/views/data-disabled.blade.php b/packages/admin/resources/views/data-disabled.blade.php new file mode 100644 index 0000000..ecc0911 --- /dev/null +++ b/packages/admin/resources/views/data-disabled.blade.php @@ -0,0 +1,11 @@ +@extends('firefly-admin::layout') +@section('title', 'Browse data') +@section('body') + <div class="head"><h1>Browse data</h1></div> + <div class="panel"> + @include('firefly-admin::_empty', [ + 'title' => 'The data browser is off', + 'body' => 'It reads the records behind your repositories, which is a far bigger disclosure than beans or configuration — so it is off even when the rest of the dashboard is on. Set <code>firefly.admin.data.enabled</code> to true to switch it on, and <code>firefly.admin.data.writable</code> on top of that to allow edits and deletes.', + ]) + </div> +@endsection diff --git a/packages/admin/resources/views/data-index.blade.php b/packages/admin/resources/views/data-index.blade.php new file mode 100644 index 0000000..2a7f7e1 --- /dev/null +++ b/packages/admin/resources/views/data-index.blade.php @@ -0,0 +1,50 @@ +@extends('firefly-admin::layout') +@section('title', 'Browse data') +@section('body') + @php use Firefly\Admin\Format; @endphp + + <div class="head"> + <h1>Browse data</h1> + <p>Every repository this application declared. The browser reads through the repositories themselves, + so what you see here is what your own data layer returns — not a raw table dump.</p> + </div> + + <div class="panel"> + @include('firefly-admin::_panel-head', [ + 'title' => 'Resources', 'count' => count($resources), + 'filter' => 'res-body', 'placeholder' => 'Filter resources…', + ]) + @if ($resources === []) + @include('firefly-admin::_empty', [ + 'title' => 'No repositories found', + 'body' => 'A resource is a bean implementing <code>Firefly\Data\Repository\CrudRepository</code>. Create one with <code>php artisan make:firefly-repository</code>, then re-run <code>firefly:cache</code> if this application boots compiled.', + ]) + @else + <div class="tw"> + <table> + <thead><tr><th>Resource</th><th>Entity</th><th>Table</th><th>Paging</th><th></th></tr></thead> + <tbody id="res-body"> + @foreach ($resources as $resource) + <tr> + <td class="cls"> + <span class="nm"><a href="{{ $settings->url('data') }}?resource={{ urlencode($resource->slug) }}">{{ $resource->label }}</a></span> + <span class="ns">{{ rtrim(Format::namespaceOf($resource->repositoryClass), '\\') }}</span> + </td> + <td class="mono dim">{{ $resource->entityClass !== null ? Format::shortClass($resource->entityClass) : '—' }}</td> + <td class="mono dim">{{ $resource->table ?: '—' }}</td> + <td class="tight"> + <span class="chip flat">{{ $resource->paged ? 'paged' : 'in-memory' }}</span> + </td> + <td class="tight"><a href="{{ $settings->url('data') }}?resource={{ urlencode($resource->slug) }}">Browse →</a></td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> + + <p class="note">A repository that does not implement <code>PagingAndSortingRepository</code> is listed as + <em>in-memory</em>: the browser has to call <code>findAll()</code> and slice the result in PHP, which is + fine for a lookup table and a foot-gun for a large one.</p> +@endsection diff --git a/packages/admin/resources/views/data-list.blade.php b/packages/admin/resources/views/data-list.blade.php new file mode 100644 index 0000000..1408c0c --- /dev/null +++ b/packages/admin/resources/views/data-list.blade.php @@ -0,0 +1,141 @@ +@extends('firefly-admin::layout') +@section('title', 'Browse data') +@section('body') + @php + use Firefly\Admin\Data\DataColumn; + use Firefly\Admin\Format; + + $resource = $listing->resource; + $schema = $listing->schema; + $columns = $listing->columns(); + $identifier = $schema?->identifierColumn(); + $base = $settings->url('data').'?resource='.urlencode($resource?->slug ?? ''); + + /** + * Values arrive RAW: an Eloquent-backed row holds the driver's value, so a bool column can be int 1 + * and a json column a string. The column TYPE is the rendering hint — never the value's PHP type. + */ + $render = static function (mixed $value, ?DataColumn $column): string { + if ($value === null) { return '—'; } + $type = $column?->type ?? DataColumn::TYPE_STRING; + + return match ($type) { + DataColumn::TYPE_BOOL => ((int) $value) === 1 ? 'true' : 'false', + DataColumn::TYPE_JSON => is_string($value) ? $value : (string) json_encode($value, JSON_UNESCAPED_SLASHES), + default => is_scalar($value) ? (string) $value : (string) json_encode($value, JSON_UNESCAPED_SLASHES), + }; + }; + @endphp + + <div class="head"> + <h1>{{ $resource?->label ?? 'Records' }}</h1> + <p> + @if ($resource?->entityClass !== null)<code>{{ Format::shortClass($resource->entityClass) }}</code>@endif + @if ($resource?->table)· table <code>{{ $resource->table }}</code>@endif + · <a href="{{ $settings->url('data') }}">all resources</a> + </p> + </div> + + @if ($listing->failed()) + <div class="panel"> + @include('firefly-admin::_empty', ['title' => 'The listing failed', 'body' => e($listing->error)]) + </div> + @elseif ($schema === null || $schema->isEmpty()) + <div class="panel"> + @include('firefly-admin::_empty', [ + 'title' => 'No columns to show', + 'body' => 'The browser could not derive a column list for this resource — it has no Eloquent model with a readable table, and its entity exposes no public properties.', + ]) + </div> + @else + <div class="panel"> + <header> + <h2>Records</h2> + <span class="spacer"></span> + @if ($schema->searchable() !== []) + <form method="get" action="{{ $settings->url('data') }}" style="display:flex;gap:6px"> + <input type="hidden" name="resource" value="{{ $resource?->slug }}"> + @if ($listing->sort)<input type="hidden" name="sort" value="{{ $listing->sort }}">@endif + <input type="hidden" name="dir" value="{{ $listing->direction }}"> + <input class="filter" type="search" name="q" value="{{ $listing->search }}" placeholder="Search…" aria-label="Search records"> + </form> + @endif + <span class="meta">{{ number_format($listing->total) }} total</span> + </header> + + @if ($listing->isEmpty()) + @include('firefly-admin::_empty', [ + 'title' => $listing->search !== null ? 'Nothing matches that search' : 'No records yet', + 'body' => $listing->search !== null ? 'Try a shorter term, or clear the search.' : 'This resource has no rows.', + ]) + @else + <div class="tw"> + <table> + <thead> + <tr> + @foreach ($columns as $column) + @php + // searchable()/sortable() return column NAMES, not DataColumn objects. + $sortable = in_array($column->name, $schema->sortable(), true); + $isSorted = $listing->sort === $column->name; + $next = $isSorted && $listing->direction === 'asc' ? 'desc' : 'asc'; + @endphp + <th> + @if ($sortable) + <a href="{{ $base }}&sort={{ urlencode($column->name) }}&dir={{ $next }}{{ $listing->search !== null ? '&q='.urlencode($listing->search) : '' }}"> + {{ $column->label() }}@if ($isSorted) {{ $listing->direction === 'asc' ? '↑' : '↓' }}@endif + </a> + @else + {{ $column->label() }} + @endif + </th> + @endforeach + @if ($identifier !== null)<th></th>@endif + </tr> + </thead> + <tbody> + @foreach ($listing->rows as $row) + <tr> + @foreach ($columns as $column) + <td class="mono {{ $column->sensitive ? 'dim' : '' }} wrap text"> + {{ $render($row[$column->name] ?? null, $column) }} + </td> + @endforeach + @if ($identifier !== null) + <td class="tight"> + @php $id = $row[$identifier->name] ?? null; @endphp + @if ($id !== null) + <a href="{{ $base }}&id={{ urlencode((string) $id) }}">Open →</a> + @endif + </td> + @endif + </tr> + @endforeach + </tbody> + </table> + </div> + + @if ($listing->totalPages() > 1) + <div class="pager"> + <span>Page {{ $listing->page }} of {{ $listing->totalPages() }}</span> + <span class="spacer"></span> + @php + $keep = ($listing->sort !== null ? '&sort='.urlencode($listing->sort).'&dir='.$listing->direction : '') + .($listing->search !== null ? '&q='.urlencode($listing->search) : ''); + @endphp + @if ($listing->hasPrevious()) + <a class="act" href="{{ $base }}&page={{ $listing->page - 1 }}{{ $keep }}">Previous</a> + @endif + @if ($listing->hasNext()) + <a class="act" href="{{ $base }}&page={{ $listing->page + 1 }}{{ $keep }}">Next</a> + @endif + </div> + @endif + @endif + </div> + @endif + + @unless ($writable) + <p class="note">Read-only. Set <code>firefly.admin.data.writable</code> to allow edits and deletes.</p> + @endunless +@endsection diff --git a/packages/admin/resources/views/data-missing.blade.php b/packages/admin/resources/views/data-missing.blade.php new file mode 100644 index 0000000..8ae7091 --- /dev/null +++ b/packages/admin/resources/views/data-missing.blade.php @@ -0,0 +1,12 @@ +@extends('firefly-admin::layout') +@section('title', 'Browse data') +@section('body') + <div class="head"><h1>Not found</h1></div> + <div class="panel"> + @include('firefly-admin::_empty', [ + 'title' => 'No such record', + 'body' => 'It may have been deleted, or the resource <code>'.e($slug).'</code> has no identifier column to address one by.', + ]) + </div> + <p class="note"><a href="{{ $settings->url('data') }}">Back to the resource list</a></p> +@endsection diff --git a/packages/admin/resources/views/data-record.blade.php b/packages/admin/resources/views/data-record.blade.php new file mode 100644 index 0000000..46e93f3 --- /dev/null +++ b/packages/admin/resources/views/data-record.blade.php @@ -0,0 +1,88 @@ +@extends('firefly-admin::layout') +@section('title', 'Record') +@section('body') + @php + use Firefly\Admin\Data\DataColumn; + use Firefly\Admin\Format; + + $resource = $record->resource; + $base = $settings->url('data').'?resource='.urlencode($resource->slug); + @endphp + + <div class="head"> + <h1>{{ $resource->label }} <span class="dim mono" style="font-size:15px">#{{ $record->id }}</span></h1> + <p> + @if ($resource->entityClass !== null)<code>{{ Format::shortClass($resource->entityClass) }}</code> · @endif + <a href="{{ $base }}">back to {{ strtolower($resource->label) }}</a> + </p> + </div> + + @if (session('data-message')) + <p class="tip">{{ session('data-message') }}</p> + @endif + + <div class="panel"> + @include('firefly-admin::_panel-head', ['title' => 'Fields', 'count' => count($record->fields)]) + <div class="tw"> + <table> + <thead><tr><th>Field</th><th>Value</th><th>Type</th></tr></thead> + <tbody> + @foreach ($record->fields as $name => $value) + @php $column = $record->schema->column((string) $name); @endphp + <tr> + <td class="mono tight">{{ $name }}@if ($column?->identifier)<span class="dim"> · id</span>@endif</td> + <td class="mono wrap text"> + @if ($value === null) + <span class="dim">null</span> + @elseif ($column?->type === DataColumn::TYPE_BOOL) + {{ ((int) $value) === 1 ? 'true' : 'false' }} + @else + {{ is_scalar($value) ? (string) $value : (string) json_encode($value, JSON_UNESCAPED_SLASHES) }} + @endif + </td> + <td class="mono dim tight">{{ $column?->type ?? '—' }}{{ $column?->nullable ? '?' : '' }}</td> + </tr> + @endforeach + </tbody> + </table> + </div> + </div> + + @if ($writable) + <div class="panel"> + @include('firefly-admin::_panel-head', ['title' => 'Edit', 'count' => count($record->schema->columns) - 1]) + <form method="post" action="{{ $settings->url('data') }}" class="editor"> + @csrf + <input type="hidden" name="resource" value="{{ $resource->slug }}"> + <input type="hidden" name="id" value="{{ $record->id }}"> + <input type="hidden" name="op" value="update"> + + @foreach ($record->schema->columns as $column) + @continue (! $column->isEditable()) + <label> + <span>{{ $column->label() }} <em>{{ $column->type }}{{ $column->nullable ? '?' : '' }}</em></span> + <input name="f[{{ $column->name }}]" value="{{ is_scalar($record->fields[$column->name] ?? null) ? (string) $record->fields[$column->name] : '' }}" + @if ($column->sensitive) placeholder="masked — leave blank to keep" @endif> + </label> + @endforeach + + <div class="actions"> + <button class="go" type="submit">Save changes</button> + </div> + </form> + </div> + + <form method="post" action="{{ $settings->url('data') }}" + onsubmit="return confirm('Delete this record permanently?')" style="margin-top:16px"> + @csrf + <input type="hidden" name="resource" value="{{ $resource->slug }}"> + <input type="hidden" name="id" value="{{ $record->id }}"> + <input type="hidden" name="op" value="delete"> + <button class="act danger" type="submit">Delete this record</button> + </form> + @else + <p class="note">Read-only. Set <code>firefly.admin.data.writable</code> to allow edits and deletes. + Creating records is deliberately not offered: a generic form cannot honour an entity's constructor + invariants, and one that silently bypassed them would be worse than not having it.</p> + @endif +@endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index e811a30..caa89cf 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -352,6 +352,28 @@ .nodes .node.off{display:none} .nodes .node:focus-visible rect{stroke:var(--accent);stroke-width:2} + /* ── data browser ────────────────────────────────────────────────── */ + .pager{display:flex;align-items:center;gap:10px;padding:10px 14px;border-top:1px solid var(--line); + font-size:12.5px;color:var(--ink-2)} + .pager .spacer{flex:1} + .pager .act{text-decoration:none;display:inline-flex;align-items:center} + .editor{padding:14px;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px} + .editor label{display:flex;flex-direction:column;gap:4px;min-width:0} + .editor label span{font-size:12px;color:var(--ink-2)} + .editor label em{font-style:normal;font-family:var(--mono);font-size:10.5px;color:var(--ink-3)} + .editor input{ + font:12.5px var(--mono);height:30px;padding:0 9px;border-radius:7px; + border:1px solid var(--line-2);background:var(--bg);color:var(--ink);min-width:0; + } + .editor .actions{grid-column:1/-1;display:flex;gap:8px} + .go{height:30px;padding:0 14px;border-radius:7px;border:1px solid var(--accent);background:var(--accent); + color:#fff;cursor:pointer;font-size:13px;font-weight:600} + .go:hover{filter:brightness(1.08)} + .act.danger{color:var(--down);border-color:var(--down)} + .act.danger:hover{background:var(--down-bg)} + .tip{margin:0 0 16px;border-radius:8px;background:var(--accent-soft);color:var(--ink); + padding:11px 14px;font-size:13.5px} + [hidden]{display:none!important} </style> </head> diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php index d8fc50f..6ea4029 100644 --- a/packages/admin/src/Boot/AdminRouteRegistrar.php +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -5,10 +5,17 @@ namespace Firefly\Admin\Boot; use Firefly\Actuator\Endpoint\ActuatorRegistry; +use Firefly\Actuator\Introspection\BeansCatalog; use Firefly\Actuator\Server\ManagementPortGuard; use Firefly\Actuator\Server\ManagementServerSettings; use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; +use Firefly\Admin\Data\DataBrowser; +use Firefly\Admin\Data\DataBrowserSettings; +use Firefly\Admin\Data\DataQueryEngine; +use Firefly\Admin\Data\DataResourceRegistry; +use Firefly\Admin\Data\DataSchemaFactory; +use Firefly\Admin\Data\RepositoryIntrospector; use Firefly\Admin\Web\AdminAction; use Firefly\Context\Boot\BootContext; use Firefly\Context\Boot\BootPass; @@ -63,6 +70,29 @@ public function run(BootContext $context): void $context->config, $container, )); + // The data browser is assembled here rather than declared as beans because it must exist even when + // it is switched OFF: the dashboard asks it whether it is enabled, and a page that cannot ask has to + // guess. Its own settings answer false by default, so building it costs a few objects and grants + // nothing. + $container->singleton(DataBrowser::class, static function () use ($container, $context): DataBrowser { + $settings = DataBrowserSettings::fromConfig($context->config); + $introspector = new RepositoryIntrospector; + + return new DataBrowser( + $settings, + new DataResourceRegistry( + // Null when the actuator has not populated a catalogue — the registry treats that as + // "nothing discoverable" rather than failing the page. + $container->bound(BeansCatalog::class) ? $container->make(BeansCatalog::class) : null, + $introspector, + $settings, + ), + new DataSchemaFactory($introspector), + new DataQueryEngine($introspector), + $container, + ); + }); + $container->singleton(AdminAction::class, static fn (): AdminAction => new AdminAction( $container->make(AdminSettings::class), $container->make(AdminEndpointReader::class), @@ -71,6 +101,7 @@ public function run(BootContext $context): void // Resolved here rather than injected as a bean so the dashboard works whether or not the // actuator's own wiring has bound one: the settings come from the same config keys either way. new ManagementPortGuard(ManagementServerSettings::fromConfig($context->config)), + $container->make(DataBrowser::class), )); /** @var Router $router */ diff --git a/packages/admin/src/Data/DataBrowser.php b/packages/admin/src/Data/DataBrowser.php new file mode 100644 index 0000000..ee102b7 --- /dev/null +++ b/packages/admin/src/Data/DataBrowser.php @@ -0,0 +1,492 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Data; + +use Firefly\Actuator\Introspection\BeansCatalog; +use Firefly\Config\Config; +use Firefly\Data\Repository\CrudRepository; +use Illuminate\Contracts\Config\Repository as ConfigRepository; +use Illuminate\Contracts\Container\Container; +use Illuminate\Database\Eloquent\Model; +use Throwable; + +/** + * The database browser's single entry point: discovery, schema, reads and the two writes, behind the gates. + * + * WHY THERE IS NO `create()`, AND WHY THAT IS NOT AN OMISSION TO BE FILLED IN LATER. A generic create form + * over an arbitrary entity is a promise the browser cannot keep. An aggregate's constructor is where its + * invariants live — an Order that must have at least one line, a Wallet whose balance starts at zero in the + * currency it was opened in, a value object that rejects a malformed IBAN — and a form built from a column + * list knows none of them. There are only two ways to build the row: call the constructor, which needs + * arguments the form cannot supply in the right types or the right order and will fail on the first entity + * with a non-trivial signature; or write the columns straight to the table, which produces a row the domain + * model considers impossible and which every later read then has to cope with. The second is what a "just + * insert the columns" implementation actually does, and it is worse than having no button, because it looks + * like it worked. Creation belongs to the application's own code, where the constructor is. `update()` is + * offered because it operates on a row that ALREADY satisfies its invariants and changes named columns on it; + * `delete()` because removal needs no invariant at all. + * + * EVERY OPERATION IS GATED TWICE — once by `firefly.admin.data.enabled` and, for writes, again by + * `firefly.admin.data.writable`, both default false. See DataBrowserSettings for the argument about why this + * page does not inherit the dashboard's `app.debug` default: beans and config are facts about the + * application, and these are facts about its users. + * + * NOTHING HERE THROWS AT THE CALLER. Reads answer with a DataListing that carries a reason, or a null record; + * writes answer with a DataWriteResult carrying one of four outcomes. A view rendering an admin page must not + * have to be exception-safe to stay on its feet, and — more sharply — an exception that escaped would be + * rendered by the framework's error page, which on a QueryException means the SQL and its bindings on screen. + */ +final class DataBrowser +{ + private const string DISABLED = 'The database browser is disabled. Set firefly.admin.data.enabled to switch it on.'; + + private const string UNRESOLVABLE = 'The repository bean for this resource could not be resolved from the container.'; + + private const string NO_IDENTIFIER = 'This resource has no identifier column, so a single record cannot be addressed.'; + + public function __construct( + private readonly DataBrowserSettings $settings, + private readonly DataResourceRegistry $registry, + private readonly DataSchemaFactory $schemas, + private readonly DataQueryEngine $engine, + private readonly Container $container, + ) {} + + /** + * Assemble a browser from the application container — the one-liner a route or a view can call. + * + * BeansCatalog is optional because it is bound by ActuatorRouteRegistrar only when + * `firefly.management.enabled` is on. Without it there is no discovery source and the browser reports no + * resources, which is the correct degradation: the dashboard that hosts this page already requires the + * actuator, so in every deployment that can reach this code the catalogue is there. + */ + public static function forContainer(Container $container, ?Config $config = null): self + { + $config ??= new Config(self::configRepository($container)); + $settings = DataBrowserSettings::fromConfig($config); + $introspector = new RepositoryIntrospector; + + $catalog = null; + if ($container->bound(BeansCatalog::class)) { + try { + $catalog = $container->make(BeansCatalog::class); + } catch (Throwable) { + $catalog = null; + } + } + + return new self( + $settings, + new DataResourceRegistry($catalog, $introspector, $settings), + new DataSchemaFactory($introspector), + new DataQueryEngine($introspector), + $container, + ); + } + + public function settings(): DataBrowserSettings + { + return $this->settings; + } + + public function isEnabled(): bool + { + return $this->settings->enabled; + } + + /** True only when BOTH gates are open — the browser is on and writes are permitted. */ + public function isWritable(): bool + { + return $this->settings->canWrite(); + } + + /** + * Every browsable resource, or an empty list when the browser is switched off. + * + * @return list<DataResource> + */ + public function resources(): array + { + return $this->settings->enabled ? $this->registry->all() : []; + } + + public function resource(string $slug): ?DataResource + { + return $this->settings->enabled ? $this->registry->get($slug) : null; + } + + public function schema(string $slug): ?DataSchema + { + $resource = $this->resource($slug); + + return $resource === null ? null : $this->schemas->for($resource); + } + + /** + * One page of a resource. + * + * `$perPage` is null to mean "the configured default" and is clamped to `firefly.admin.data.max-page-size` + * in every case, so a caller-supplied page size can never ask the fallback path to materialise a table. + * `$page` is 1-based and floored at 1. + */ + public function list( + string $slug, + int $page = 1, + ?int $perPage = null, + ?string $sort = null, + string $direction = 'asc', + ?string $search = null, + ): DataListing { + $perPage = $this->settings->clampPageSize($perPage); + $page = max(1, $page); + + if (! $this->settings->enabled) { + return DataListing::failure(self::DISABLED, null, null, $page, $perPage); + } + + $resource = $this->registry->get($slug); + if ($resource === null) { + return DataListing::failure('No such resource.', null, null, $page, $perPage); + } + + $schema = $this->schemas->for($resource); + $repository = $this->repositoryFor($resource); + if ($repository === null) { + return DataListing::failure(self::UNRESOLVABLE, $resource, $schema, $page, $perPage); + } + + return $this->engine->list($repository, $resource, $schema, $page, $perPage, $sort, $direction, $search); + } + + /** + * One record, or null when it cannot be shown — see DataQueryEngine::find() for why the null is + * deliberately ambiguous. + */ + public function find(string $slug, int|string $id): ?DataRecord + { + if (! $this->settings->enabled) { + return null; + } + + $resource = $this->registry->get($slug); + if ($resource === null) { + return null; + } + + $repository = $this->repositoryFor($resource); + + return $repository === null + ? null + : $this->engine->find($repository, $resource, $this->schemas->for($resource), $id); + } + + /** + * Remove one row, addressed by the schema's identifier. + * + * The removal is verified after the fact with `existsById()` rather than trusted, because + * `CrudRepository::deleteById()` returns void: a repository whose delete was a no-op (a soft-delete scope + * that excluded the row, an override that swallowed it) would otherwise report success and the operator + * would watch the row reappear on the next page load. + */ + public function delete(string $slug, int|string $id): DataWriteResult + { + $refusal = $this->refuseWrite($slug, $id); + if ($refusal !== null) { + return $refusal; + } + + $resource = $this->registry->get($slug); + if ($resource === null) { + return DataWriteResult::notFound('No such resource.', $slug, $id); + } + + $schema = $this->schemas->for($resource); + if ($schema->identifier === null) { + return DataWriteResult::refused(self::NO_IDENTIFIER, $slug, $id); + } + + $repository = $this->repositoryFor($resource); + if ($repository === null) { + return DataWriteResult::failed(self::UNRESOLVABLE, $slug, $id); + } + + try { + if (! $repository->existsById($id)) { + return DataWriteResult::notFound('No such record.', $slug, $id); + } + + $repository->deleteById($id); + + if ($repository->existsById($id)) { + return DataWriteResult::failed('The repository accepted the delete but the record is still present.', $slug, $id); + } + } catch (Throwable $e) { + return DataWriteResult::failed($this->engine->safeReason('The delete failed', $e), $slug, $id); + } + + return DataWriteResult::done('Deleted.', $slug, $id); + } + + /** + * Write named columns onto one existing row, through the repository's own `save()`. + * + * WHY `save()` AND NOT AN UPDATE QUERY. Going straight to the builder would be one line and would bypass + * everything the application attached to persistence: auditing (`created_by`/`updated_by`), optimistic + * locking, the aggregate tracker that dispatches domain events after commit. An admin edit that silently + * skips the audit trail is precisely the edit you most want audited. + * + * WHY ONLY ELOQUENT-BACKED RESOURCES. Mutating a plain entity means either calling setters the browser + * cannot know about or reflecting values into promoted `readonly` properties, which is exactly the + * invariant-bypassing that `create()` is refused for (see the class docblock) — with the additional + * problem that on a readonly property it is not even possible. A resource whose entities are value + * objects is browsable and deletable, and its edit is refused with a reason. + * + * THE IDENTIFIER AND MASKED COLUMNS ARE DROPPED, NOT REJECTED. A detail form legitimately round-trips + * every field it rendered, including the key it addressed the row by and any column shown as `******`. + * Rejecting the whole submission for containing them would make the obvious form implementation fail + * every time; writing them would re-key the row, or overwrite a real credential with the mask. So they + * are dropped, and `DataWriteResult::$changed` reports exactly which columns were written — silence with + * a receipt, not silence. + * + * An UNKNOWN column, by contrast, is a hard refusal: it cannot come from a form this schema produced, so + * it is either tampering or a bug, and quietly ignoring it would hide both. + * + * `$fields` is keyed by `array-key`, not by `string`, because that is what request input actually is: + * PHP normalises a numeric form field name to an INTEGER key, so a POST containing `0=x` produces an int + * key no matter how the form was meant to be built. Declaring the honest type is what lets the unknown- + * column check reject it as data instead of raising a TypeError on the way in. + * + * @param array<array-key, mixed> $fields column name => submitted value + */ + public function update(string $slug, int|string $id, array $fields): DataWriteResult + { + $refusal = $this->refuseWrite($slug, $id); + if ($refusal !== null) { + return $refusal; + } + + $resource = $this->registry->get($slug); + if ($resource === null) { + return DataWriteResult::notFound('No such resource.', $slug, $id); + } + + $schema = $this->schemas->for($resource); + if ($schema->identifier === null) { + return DataWriteResult::refused(self::NO_IDENTIFIER, $slug, $id); + } + + // The key type is int|string, not string: PHP turns a numeric form field name into an integer key, + // so `<input name="0">` in a crafted POST would hand a `string` closure an int and raise a TypeError + // under strict_types — a 500 from the one input this method exists to distrust. + $unknown = array_values(array_filter( + array_keys($fields), + static fn (int|string $name): bool => ! $schema->has((string) $name), + )); + if ($unknown !== []) { + return DataWriteResult::refused( + sprintf('%d submitted field(s) are not columns of this resource.', count($unknown)), + $slug, + $id, + ); + } + + $repository = $this->repositoryFor($resource); + if ($repository === null) { + return DataWriteResult::failed(self::UNRESOLVABLE, $slug, $id); + } + + try { + $entity = $repository->findById($id); + } catch (Throwable $e) { + return DataWriteResult::failed($this->engine->safeReason('The lookup failed', $e), $slug, $id); + } + + if ($entity === null) { + return DataWriteResult::notFound('No such record.', $slug, $id); + } + + if (! $entity instanceof Model) { + return DataWriteResult::refused( + 'This resource is not backed by an Eloquent model, so the browser will not mutate it — see DataBrowser::update().', + $slug, + $id, + ); + } + + return $this->applyUpdate($repository, $entity, $schema, $slug, $id, $fields); + } + + /** + * @param CrudRepository<object, mixed> $repository + * @param array<array-key, mixed> $fields + */ + private function applyUpdate( + CrudRepository $repository, + Model $entity, + DataSchema $schema, + string $slug, + int|string $id, + array $fields, + ): DataWriteResult { + foreach ($fields as $name => $value) { + $column = $schema->column((string) $name); + if ($column === null || ! $column->isEditable()) { + continue; + } + + $coerced = $this->coerce($entity, $column, $value); + if ($coerced === false) { + return DataWriteResult::refused( + sprintf('The value submitted for "%s" is not a valid %s.', $column->name, $column->type), + $slug, + $id, + ); + } + + $entity->setAttribute($column->name, $coerced[0]); + } + + $changed = array_keys($entity->getDirty()); + if ($changed === []) { + return DataWriteResult::done('Nothing changed.', $slug, $id); + } + + try { + $repository->save($entity); + } catch (Throwable $e) { + return DataWriteResult::failed($this->engine->safeReason('The update failed', $e), $slug, $id); + } + + return DataWriteResult::done(sprintf('Updated %d field(s).', count($changed)), $slug, $id, $changed); + } + + /** + * Coerce one submitted value to the column's type, or report that it cannot be. + * + * Returns `false` for "invalid" and a ONE-ELEMENT ARRAY for "valid, here it is" — because the valid value + * may itself legitimately be `null` or `false`, and a bare `?mixed` return cannot tell those apart from + * failure. + * + * A form submits strings for everything, so `""` has to mean something. On a nullable non-string column it + * means null (an emptied number field is not the integer zero); on a string column it means the empty + * string, which is a real value that is not the same as null and must survive a round trip. + * + * @return array{0: mixed}|false + */ + private function coerce(Model $entity, DataColumn $column, mixed $value): array|false + { + if ($value === null) { + return $column->nullable ? [null] : false; + } + + if (is_array($value)) { + return $column->type === DataColumn::TYPE_JSON ? [$value] : false; + } + + if (! is_scalar($value)) { + return false; + } + + $string = trim((string) $value); + + if ($string === '' && $column->type !== DataColumn::TYPE_STRING) { + return $column->nullable ? [null] : false; + } + + return match ($column->type) { + DataColumn::TYPE_INT => preg_match('/^-?\d+$/', $string) === 1 ? [(int) $string] : false, + DataColumn::TYPE_BOOL => $this->coerceBool($string), + DataColumn::TYPE_DATETIME => strtotime($string) === false ? false : [$string], + DataColumn::TYPE_JSON => $this->coerceJson($entity, $column, $string), + default => [is_string($value) ? $value : $string], + }; + } + + /** @return array{0: bool}|false */ + private function coerceBool(string $value): array|false + { + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + return $parsed === null ? false : [$parsed]; + } + + /** + * JSON is validated before it is written — an admin form is the one place a malformed blob gets in by + * hand, and a column that fails to decode on every subsequent read is a corruption that outlives the + * session that caused it. + * + * WHAT gets written depends on the model, not on the column: with an `array`/`json`/`object`/`collection` + * cast Eloquent will encode whatever it is given, so it must be handed the DECODED value or the row ends + * up double-encoded (`"{\"a\":1}"`); with no cast the column is plain text and the submitted string is + * exactly right. + * + * @return array{0: mixed}|false + */ + private function coerceJson(Model $entity, DataColumn $column, string $value): array|false + { + $decoded = json_decode($value, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return false; + } + + return [$entity->hasCast($column->name, ['array', 'json', 'object', 'collection']) ? $decoded : $value]; + } + + /** + * The gate check shared by both writes. Returns the refusal, or null when the caller may proceed. + * + * The two keys are reported separately rather than as one "not permitted": an operator who turned the + * browser on and forgot the second key needs to be told which key, and an operator who never turned the + * browser on at all should not be told that a write key exists. + */ + private function refuseWrite(string $slug, int|string $id): ?DataWriteResult + { + if (! $this->settings->enabled) { + return DataWriteResult::refused(self::DISABLED, $slug, $id); + } + + if (! $this->settings->writable) { + return DataWriteResult::refused( + 'The database browser is read-only. Set firefly.admin.data.writable to permit writes.', + $slug, + $id, + ); + } + + return null; + } + + /** + * Resolve the repository bean once, for both the read and the write path. + * + * A resource came from the catalogue, so the BINDING exists — but resolving it runs a constructor, and a + * constructor can fail for reasons that have nothing to do with this page (a connection this deployment + * did not configure, a collaborator bean a condition backed off from). A null here becomes a stated + * reason, never a 500. + * + * @return CrudRepository<object, mixed>|null + */ + public function repositoryFor(DataResource $resource): ?CrudRepository + { + try { + $bean = $this->container->make($resource->repositoryClass); + } catch (Throwable) { + return null; + } + + return $bean instanceof CrudRepository ? $bean : null; + } + + /** + * The Illuminate config repository Firefly's typed Config wraps. `Firefly\Config\Config` is not itself a + * container binding anywhere in the framework — every call site builds one over the repository — so this + * mirrors what AdminRouteRegistrar does with `$context->config` rather than inventing a new binding the + * boot pipeline does not create. + */ + private static function configRepository(Container $container): ConfigRepository + { + return $container->make('config'); + } +} diff --git a/packages/admin/src/Data/DataListing.php b/packages/admin/src/Data/DataListing.php new file mode 100644 index 0000000..dd98b5a --- /dev/null +++ b/packages/admin/src/Data/DataListing.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Data; + +/** + * One page of a resource: the rows, the grand total, the query that produced them, and — when it did not + * work — a reason safe to put on a page. + * + * WHY A FAILED LISTING IS A LISTING AND NOT AN EXCEPTION. The three ways this can go wrong (the browser is + * switched off, the resource does not exist, the query threw) all have to render as a page, and a view that + * has to wrap every call in try/catch to stay alive will eventually forget one. So there is exactly one + * return type: `$error === null` means the rows are real, and otherwise `$error` is a sentence the view can + * print and `$rows` is empty. `$resource` and `$schema` are nullable for the same reason — an unknown slug + * has neither, and the caller still needs something to render. + * + * WHY `$error` NEVER CONTAINS THE EXCEPTION MESSAGE. Laravel's QueryException stringifies the failing SQL + * *and its bindings* into `getMessage()`. Echoing that to the browser would publish the schema and, far + * worse, the values that were bound — which on a search over a users table is the operator's own query and on + * a detail lookup is a primary key. DataBrowser builds this field from a fixed sentence plus, at most, the + * exception's class name; the message stays in the exception, where a log can have it. + */ +final readonly class DataListing +{ + /** + * @param list<array<string, mixed>> $rows each row keyed by column name, in schema column order + */ + public function __construct( + public ?DataResource $resource, + public ?DataSchema $schema, + public array $rows, + public int $total, + public int $page = 1, + public int $perPage = 25, + public ?string $sort = null, + public string $direction = 'asc', + public ?string $search = null, + public ?string $error = null, + ) {} + + /** + * The empty-with-a-reason constructor every refusal and every caught failure goes through. + */ + public static function failure( + string $error, + ?DataResource $resource = null, + ?DataSchema $schema = null, + int $page = 1, + int $perPage = 25, + ): self { + return new self($resource, $schema, [], 0, $page, $perPage, null, 'asc', null, $error); + } + + public function failed(): bool + { + return $this->error !== null; + } + + public function isEmpty(): bool + { + return $this->rows === []; + } + + public function totalPages(): int + { + return $this->perPage > 0 ? max(1, (int) ceil($this->total / $this->perPage)) : 1; + } + + public function hasNext(): bool + { + return $this->page < $this->totalPages(); + } + + public function hasPrevious(): bool + { + return $this->page > 1; + } + + /** + * The columns the view should draw, in order — empty when the schema could not be derived, which the + * view must render as "no columns" rather than as "no rows". + * + * @return list<DataColumn> + */ + public function columns(): array + { + return $this->schema === null ? [] : $this->schema->columns; + } +} diff --git a/packages/admin/src/Data/DataQueryEngine.php b/packages/admin/src/Data/DataQueryEngine.php new file mode 100644 index 0000000..75bdaa2 --- /dev/null +++ b/packages/admin/src/Data/DataQueryEngine.php @@ -0,0 +1,456 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Data; + +use BackedEnum; +use DateTimeInterface; +use Firefly\Actuator\Introspection\SensitiveValueMasker; +use Firefly\Data\Repository\CrudRepository; +use Firefly\Data\Repository\EloquentRepository; +use Firefly\Data\Repository\Page; +use Firefly\Data\Repository\Pageable; +use Firefly\Data\Repository\PagingAndSortingRepository; +use Firefly\Data\Repository\Sort; +use Firefly\Data\Repository\Specification\Specification; +use Firefly\Data\Repository\Specification\Specifications; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Stringable; +use Throwable; + +/** + * The READ half of the browser: turn (resource, page, sort, search) into rows, and (resource, id) into one + * record. Every value that leaves here has been through the masker and the normaliser. + * + * FOUR PATHS, AND WHY EACH EXISTS. + * + * 1. PAGED, UNSEARCHED — `findPaged(Pageable)`. The repository does the offset, the limit, the ORDER BY and + * the COUNT, so the database returns one page and the total. This is the path every well-declared + * repository takes and the only one whose cost is independent of table size. + * + * 2. PAGED, SEARCHED, ELOQUENT — `findBySpecificationPaged(Specification, Pageable)`. `findPaged` cannot + * carry a predicate, and the naive fix (fetch everything, filter in PHP) is worst exactly where search + * matters, on the big table. EloquentRepository already exposes a public specification seam that applies + * the predicate to the repository's OWN `query()` builder, so the filter, the page and the count all + * happen in SQL and any constraint a repository added by overriding `query()` still applies. Going around + * it with `Model::query()` would have been shorter and would have silently dropped that constraint — + * which on a repository that scopes to a tenant is a cross-tenant disclosure. + * + * 3. UNPAGED — `findAll()`, then sort and slice IN PHP. THIS IS A FOOT-GUN AND IT IS LOAD-BEARING TO SAY SO: + * `findAll()` on a plain CrudRepository issues `SELECT *` with no LIMIT, hydrates every row of the table + * into PHP objects, and only then does the browser throw away all but 25 of them. On a table of ten + * thousand rows that is a slow page; on a table of ten million it is an out-of-memory that kills the + * worker, and it will happen on the FIRST click, not gradually. There is no way to do better through the + * CrudRepository interface — it has no limit, no offset and no count-with-predicate — so the honest + * options were "refuse to browse repositories that cannot page" or "browse them and say what it costs". + * This is the second. A repository that will be browsed against a large table should implement + * PagingAndSortingRepository, at which point it takes path 1. + * + * 4. UNPAGED, SEARCHED — path 3 with an additional in-PHP substring filter. No SQL is involved in the + * matching at all, so the term cannot reach a query planner, let alone a parser. + * + * SEARCH IS BOUND, NEVER INTERPOLATED. On the SQL paths the term is passed as a BINDING to + * `where(column, 'like', ?)` — it is never concatenated into a fragment, never handed to `whereRaw`, and + * therefore cannot become SQL no matter what it contains. The COLUMN names are not caller data at all: they + * come from DataSchema, which built them from the driver's own column list or from a class's declared + * properties, and a caller-supplied sort column is checked for membership in that list before it is used — + * an unknown one is dropped, not quoted. `%` and `_` inside the term are deliberately left as wildcards + * rather than escaped: LIKE has no portable escape character (sqlite has none by default, MySQL uses + * backslash, ANSI needs an explicit ESCAPE clause), so escaping "portably" means breaking search on some + * driver, and an operator who types `%` into an admin search box wants a wildcard. + * + * EVERY LISTING IS ORDERED, EVEN WHEN NOBODY ASKED. With no ORDER BY, a paged query's row order is whatever + * the storage engine finds convenient, and it is allowed to differ between the query for page 1 and the query + * for page 2 — so a row can appear on both pages while another appears on neither, and the operator sees a + * table that is missing records that are actually there. When no sort is requested the identifier is used, + * which is stable and always indexed. + */ +final class DataQueryEngine +{ + /** + * OR-ing a LIKE across every text column of a wide table produces a query no index can help with; past a + * dozen columns the page is slow enough that an operator will assume it hung. Search covers the first + * twelve searchable columns in schema order. + */ + public const int MAX_SEARCH_COLUMNS = 12; + + /** + * Cell values are truncated in a LISTING (and never in a detail view). A `text` column holding a 2 MB + * document is legal, and twenty-five of them is a fifty-megabyte HTML response that helps nobody: a table + * cell can show a couple of hundred characters. The truncation is marked with a horizontal ellipsis so + * the view is not claiming the value ended there, and the detail page shows the whole thing. + */ + public const int LIST_VALUE_LIMIT = 200; + + private const string ELLIPSIS = "\u{2026}"; + + public function __construct(private readonly RepositoryIntrospector $introspector) {} + + /** + * One page of a resource. Never throws: a failure comes back as a DataListing carrying a safe reason. + * + * The repository is passed IN rather than resolved here so that container resolution — which runs a + * constructor and can therefore fail for reasons that have nothing to do with querying — has exactly one + * home, in DataBrowser, shared with the write path. + * + * @param CrudRepository<object, mixed> $repository + */ + public function list( + CrudRepository $repository, + DataResource $resource, + DataSchema $schema, + int $page, + int $perPage, + ?string $sort, + string $direction, + ?string $search, + ): DataListing { + $sort = $this->sortColumn($schema, $sort); + $direction = strtolower($direction) === 'desc' ? 'desc' : 'asc'; + $term = $this->term($search); + + // The PROJECTION is inside the try as well as the fetch. It reads attributes off hydrated entities + // and stringifies whatever it finds, which is not obviously fallible until a model's accessor or a + // value object's __toString throws — and a half-rendered page is exactly as broken as a failed query. + try { + [$entities, $total] = $this->fetch($repository, $schema, $page, $perPage, $sort, $direction, $term); + + $rows = []; + foreach ($entities as $entity) { + $rows[] = $this->project($entity, $schema, self::LIST_VALUE_LIMIT); + } + } catch (Throwable $e) { + return DataListing::failure($this->safeReason('The listing query failed', $e), $resource, $schema, $page, $perPage); + } + + return new DataListing($resource, $schema, $rows, $total, $page, $perPage, $sort, $direction, $term); + } + + /** + * One record, or null when it cannot be shown. + * + * NULL IS DELIBERATELY AMBIGUOUS HERE, unlike in `list()`. It covers "no such row", "the identifier could + * not be derived" and "the lookup threw", and it does so on purpose: a detail view that distinguished + * "this row does not exist" from "this row exists but the query failed" is an existence oracle for + * anything the caller can name, and the correct rendering for all three is the same 404 page anyway. + * + * @param CrudRepository<object, mixed> $repository + */ + public function find(CrudRepository $repository, DataResource $resource, DataSchema $schema, int|string $id): ?DataRecord + { + if ($schema->identifier === null) { + return null; + } + + try { + $entity = $repository->findById($id); + + return $entity === null + ? null + : new DataRecord($resource, $schema, $id, $this->project($entity, $schema, null)); + } catch (Throwable) { + return null; + } + } + + /** + * Pick the page of entities and the grand total, by whichever of the four paths this repository supports. + * + * @param CrudRepository<object, mixed> $repository + * @return array{0: list<object>, 1: int} + */ + private function fetch( + CrudRepository $repository, + DataSchema $schema, + int $page, + int $perPage, + ?string $sort, + string $direction, + ?string $term, + ): array { + $pageable = new Pageable($page, $perPage, $this->sort($sort, $direction)); + + if ($term !== null && $repository instanceof EloquentRepository) { + $columns = $this->searchColumns($schema); + if ($columns === []) { + return [[], 0]; + } + + /** @var Page<object> $result */ + $result = $repository->findBySpecificationPaged($this->searchSpecification($columns, $term), $pageable); + + return [$result->items, $result->total]; + } + + if ($term === null && $repository instanceof PagingAndSortingRepository) { + /** @var Page<object> $result */ + $result = $repository->findPaged($pageable); + + return [$result->items, $result->total]; + } + + return $this->fetchInPhp($repository, $schema, $page, $perPage, $sort, $direction, $term); + } + + /** + * The fallback: materialise everything, then filter, sort and slice in PHP. See the class docblock for + * what this costs — it is the price of browsing a repository that cannot page, and it is charged in full + * on the first page. + * + * @param CrudRepository<object, mixed> $repository + * @return array{0: list<object>, 1: int} + */ + private function fetchInPhp( + CrudRepository $repository, + DataSchema $schema, + int $page, + int $perPage, + ?string $sort, + string $direction, + ?string $term, + ): array { + $needle = $term === null ? null : mb_strtolower($term); + $columns = $this->searchColumns($schema); + + $matched = []; + foreach ($repository->findAll() as $entity) { + $values = $this->rawValues($entity, $schema); + + if ($needle !== null && ! $this->matches($values, $columns, $needle)) { + continue; + } + + $matched[] = ['entity' => $entity, 'values' => $values]; + } + + if ($sort !== null) { + usort($matched, function (array $a, array $b) use ($sort, $direction): int { + $comparison = $this->compare($a['values'][$sort] ?? null, $b['values'][$sort] ?? null); + + return $direction === 'desc' ? -$comparison : $comparison; + }); + } + + $total = count($matched); + $slice = array_slice($matched, ($page - 1) * $perPage, $perPage); + + return [array_map(static fn (array $row): object => $row['entity'], $slice), $total]; + } + + /** + * A grouped `(col LIKE ? OR col LIKE ? ...)` predicate over the repository's own builder. + * + * The OR group is NESTED rather than chained onto the outer builder: `->orWhere()` at the top level would + * escape any constraint the repository's `query()` seam had already applied, turning `tenant = 7 AND + * (name LIKE …)` into `tenant = 7 OR name LIKE …` — every tenant's rows, from a search box. + * + * @param non-empty-list<string> $columns + * @return Specification<Model> + */ + private function searchSpecification(array $columns, string $term): Specification + { + $pattern = '%'.$term.'%'; + + return Specifications::where(static function (Builder $query) use ($columns, $pattern): void { + $query->where(static function (Builder $group) use ($columns, $pattern): void { + foreach ($columns as $column) { + $group->orWhere($column, 'like', $pattern); + } + }); + }); + } + + /** + * @param array<string, mixed> $values + * @param list<string> $columns + */ + private function matches(array $values, array $columns, string $needle): bool + { + foreach ($columns as $column) { + $value = $values[$column] ?? null; + if (is_scalar($value) && str_contains(mb_strtolower((string) $value), $needle)) { + return true; + } + } + + return false; + } + + /** @return list<string> */ + private function searchColumns(DataSchema $schema): array + { + return array_slice($schema->searchable(), 0, self::MAX_SEARCH_COLUMNS); + } + + /** + * A requested sort survives only if the schema knows the column; otherwise the identifier is used, and + * only when there is neither does a listing go out unordered — see the class docblock on why that is the + * last resort and not the default. + */ + private function sortColumn(DataSchema $schema, ?string $requested): ?string + { + $sortable = $schema->sortable(); + + if ($requested !== null && in_array($requested, $sortable, true)) { + return $requested; + } + + return $schema->identifier !== null && in_array($schema->identifier, $sortable, true) + ? $schema->identifier + : null; + } + + private function sort(?string $column, string $direction): ?Sort + { + if ($column === null) { + return null; + } + + $sort = Sort::by($column); + + return $direction === 'desc' ? $sort->descending() : $sort; + } + + private function term(?string $search): ?string + { + $term = trim($search ?? ''); + + return $term === '' ? null : $term; + } + + /** + * Read an entity's fields in schema order, WITHOUT masking or formatting — the shape sorting and + * filtering compare against. + * + * Eloquent is read through `getAttributes()` (the raw column values) rather than `getAttribute()` (the + * cast values) on purpose: this page's job is to show what is in the table, and a cast turns a timestamp + * into a Carbon object and a JSON column into an array, neither of which is what the row holds. The + * casts still shaped the COLUMN TYPES (see DataSchemaFactory), which is where they belong — deciding how + * to render, not deciding what the value is. + * + * @return array<string, mixed> + */ + private function rawValues(object $entity, DataSchema $schema): array + { + $attributes = $entity instanceof Model ? $entity->getAttributes() : null; + + $values = []; + foreach ($schema->columns as $column) { + $values[$column->name] = $attributes !== null + ? ($attributes[$column->name] ?? null) + : $this->introspector->read($entity, $column->name); + } + + return $values; + } + + /** + * Raw values, masked and normalised for display. `$limit` truncates long strings in a listing and is null + * on a detail view. + * + * A NULL IN A SENSITIVE COLUMN STAYS NULL. Replacing it with `******` would tell the reader that a secret + * is set when none is — which reads as "this account has an API token" and is exactly the kind of quiet + * falsehood an operator would act on. + * + * @return array<string, mixed> + */ + private function project(object $entity, DataSchema $schema, ?int $limit): array + { + $values = []; + foreach ($this->rawValues($entity, $schema) as $name => $value) { + $column = $schema->column($name); + + $values[$name] = $column !== null && $column->sensitive && $value !== null + ? SensitiveValueMasker::MASK + : $this->normalize($value, $limit); + } + + return $values; + } + + /** + * Reduce a value to something a template can print without calling a method on it. Objects are the + * interesting case: a Carbon, a backed enum and a value object all reach here from a cast or a plain + * entity, and a view that has to type-check each one will get it wrong. Anything with no printable form + * degrades to its class name in brackets, which is information rather than "Object of class X could not + * be converted to string". + */ + private function normalize(mixed $value, ?int $limit): mixed + { + if ($value === null || is_bool($value) || is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value)) { + return $this->truncate($value, $limit); + } + + if (is_array($value)) { + return $this->truncate((string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), $limit); + } + + if ($value instanceof DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + if ($value instanceof BackedEnum) { + return $value->value; + } + + if ($value instanceof Stringable || (is_object($value) && method_exists($value, '__toString'))) { + return $this->truncate((string) $value, $limit); + } + + return is_object($value) ? '['.$value::class.']' : '[unrenderable]'; + } + + private function truncate(string $value, ?int $limit): string + { + if ($limit === null || mb_strlen($value) <= $limit) { + return $value; + } + + return mb_substr($value, 0, $limit).self::ELLIPSIS; + } + + /** Null-last ordering, so a nullable column does not sort its empties into the middle of the values. */ + private function compare(mixed $a, mixed $b): int + { + if ($a === null && $b === null) { + return 0; + } + if ($a === null) { + return 1; + } + if ($b === null) { + return -1; + } + + if (is_scalar($a) && is_scalar($b)) { + return is_numeric($a) && is_numeric($b) ? ($a + 0) <=> ($b + 0) : strnatcasecmp((string) $a, (string) $b); + } + + return 0; + } + + /** + * A failure sentence that names the exception's CLASS and withholds its message. Public because the write + * path in DataBrowser needs exactly the same guarantee, and two formatters is how one of them ends up + * calling getMessage(). + * + * `Illuminate\Database\QueryException::getMessage()` embeds the failing SQL and the bound parameters. On + * this surface those bindings are a searched term, a primary key, or — on an update — the submitted field + * values, so echoing the message into HTML publishes the schema and the data in one line. The class name + * is enough for an operator to know what kind of failure it was and to find it in the log. + */ + public function safeReason(string $what, Throwable $e): string + { + return sprintf( + '%s (%s). The exception message is withheld because it can contain SQL and bound values; see the application log.', + $what, + $e::class, + ); + } +} diff --git a/packages/admin/src/Data/DataRecord.php b/packages/admin/src/Data/DataRecord.php new file mode 100644 index 0000000..a37141d --- /dev/null +++ b/packages/admin/src/Data/DataRecord.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Data; + +/** + * One record, as an ORDERED field map — schema column order, not hash order. + * + * Order matters more than it looks. `getAttributes()` on an Eloquent model returns keys in whatever order the + * driver returned them, which differs between drivers and can differ between two rows of the same table after + * a migration adds a column. A detail page whose fields move between rows is unreadable, so the record is + * projected against the schema's column list and inherits its order, and a column the row did not supply is + * present with a null rather than missing. + * + * `rows()` is the shape the detail view actually wants: value plus the metadata needed to decide how to draw + * it and whether to offer an edit. It is computed here rather than in the view so that the editability rule + * (never the identifier, never a secret — see DataColumn::isEditable()) has exactly one definition. + */ +final readonly class DataRecord +{ + /** + * @param array<string, mixed> $fields masked, in schema column order + */ + public function __construct( + public DataResource $resource, + public DataSchema $schema, + public int|string $id, + public array $fields, + ) {} + + /** + * @return list<array{name: string, label: string, type: string, nullable: bool, identifier: bool, sensitive: bool, editable: bool, value: mixed}> + */ + public function rows(): array + { + $rows = []; + foreach ($this->schema->columns as $column) { + $rows[] = [ + 'name' => $column->name, + 'label' => $column->label(), + 'type' => $column->type, + 'nullable' => $column->nullable, + 'identifier' => $column->identifier, + 'sensitive' => $column->sensitive, + 'editable' => $column->isEditable(), + 'value' => $this->fields[$column->name] ?? null, + ]; + } + + return $rows; + } +} diff --git a/packages/admin/src/Data/DataResource.php b/packages/admin/src/Data/DataResource.php index 24811da..c82206d 100644 --- a/packages/admin/src/Data/DataResource.php +++ b/packages/admin/src/Data/DataResource.php @@ -36,8 +36,6 @@ public function __construct( /** * Whether this resource has a live Eloquent model behind it — the precondition for schema-derived * columns, SQL-side search/sort, and any write at all. - * - * @phpstan-assert-if-true non-empty-string $this->entityClass */ public function isEloquentBacked(): bool { diff --git a/packages/admin/src/Data/DataResourceRegistry.php b/packages/admin/src/Data/DataResourceRegistry.php index 0aa90fd..d114c34 100644 --- a/packages/admin/src/Data/DataResourceRegistry.php +++ b/packages/admin/src/Data/DataResourceRegistry.php @@ -111,16 +111,20 @@ public function get(string $slug): ?DataResource private function describe(string $class, bool $paged): array { $model = $this->introspector->modelOf($class); - $eloquent = $model !== null - && is_a($class, EloquentRepository::class, true) - && is_a($model, Model::class, true); + + // Eloquent-backed means all three: the repository extends the Eloquent base, it declared a $model, + // and that $model really is a Model. A repository that declares a `$model` pointing at something else + // is not an error — it is just not schema-browsable, and it keeps the declared class as its entity. + if ($model !== null && is_a($class, EloquentRepository::class, true) && is_a($model, Model::class, true)) { + return ['class' => $class, 'entity' => $model, 'table' => $this->tableOf($model), 'paged' => $paged, 'eloquent' => true]; + } return [ 'class' => $class, 'entity' => $model ?? $this->introspector->entityOf($class), - 'table' => $eloquent && is_a($model, Model::class, true) ? $this->tableOf($model) : null, + 'table' => null, 'paged' => $paged, - 'eloquent' => $eloquent, + 'eloquent' => false, ]; } diff --git a/packages/admin/src/Data/DataSchemaFactory.php b/packages/admin/src/Data/DataSchemaFactory.php new file mode 100644 index 0000000..488e591 --- /dev/null +++ b/packages/admin/src/Data/DataSchemaFactory.php @@ -0,0 +1,211 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Data; + +use Firefly\Actuator\Introspection\SensitiveValueMasker; +use Illuminate\Database\Eloquent\Model; +use Throwable; + +/** + * Derives the displayable field list of a resource, honestly, from whichever source can actually answer. + * + * NEVER "dump every attribute". The tempting implementation is `$model->getAttributes()` on the first row and + * use its keys as the columns, and it is wrong in three ways that all bite in production: an empty table + * yields no columns at all (so the page renders as broken rather than as empty), a row that was hydrated with + * a `select` of two columns yields two columns for the whole resource, and an accessor-heavy model yields + * whatever `$appends` decided rather than what the table holds. Columns are a property of the RESOURCE, so + * they are derived once from a source that describes the resource: the live schema for a model-backed one, + * the entity's own declared fields for everything else. + * + * THE SCHEMA IS AUTHORITATIVE, THE CASTS REFINE IT. `Schema::getColumns()` reports what the driver knows, + * and the driver frequently does not know what the application meant: sqlite stores a `json()` column as + * `text` and a `boolean()` column as `tinyint`, so a type map built from `type_name` alone shows a JSON blob + * as a string and a flag as a number. The model's own `$casts` carry the semantic type the schema cannot + * express, so they are applied on top — `meta => array` makes `meta` render as JSON on every driver, not just + * the ones whose type names happen to be self-describing. Where the two disagree the cast wins, because the + * cast is what the application will hand the view. + * + * THE IDENTIFIER IS DERIVED, NOT ASSUMED. Eloquent knows its own key (`getKeyName()`, which respects a model + * that renamed it); a plain entity is searched for a conventional identifier in a fixed order. It is allowed + * to come back null, and everything downstream refuses rather than guessing — see DataSchema. + * + * A MODEL'S OWN `$hidden` IS TREATED AS SENSITIVE. `SensitiveValueMasker` decides by column NAME, which + * catches `password`, `api_token` and their relatives but cannot know that this application considers + * `recovery_phrase` a secret. A model that already hid a field from its JSON representation has stated that + * intent in the only place it could, so the browser honours it as a second sensitivity source rather than + * publishing in HTML what the model refuses to publish in JSON. + */ +final class DataSchemaFactory +{ + /** @var array<string, DataSchema> */ + private array $cache = []; + + public function __construct(private readonly RepositoryIntrospector $introspector) {} + + public function for(DataResource $resource): DataSchema + { + return $this->cache[$resource->slug] ??= $this->derive($resource); + } + + private function derive(DataResource $resource): DataSchema + { + $entity = $resource->entityClass; + if ($entity === null) { + return DataSchema::empty(); + } + + return $resource->isEloquentBacked() && is_a($entity, Model::class, true) + ? $this->fromModel($entity) + : $this->fromEntity($entity); + } + + /** + * @param class-string<Model> $modelClass + */ + private function fromModel(string $modelClass): DataSchema + { + try { + $model = new $modelClass; + $key = $model->getKeyName(); + } catch (Throwable) { + return DataSchema::empty(); + } + + try { + $rows = $model->getConnection()->getSchemaBuilder()->getColumns($model->getTable()); + } catch (Throwable) { + // No usable connection (or no such table). The key name is still known and is the one column + // every other operation needs, so the resource degrades to a key-only listing instead of + // vanishing — and `source` says none, so the view can explain why the row is so bare. + return new DataSchema([DataColumn::of($key, DataColumn::TYPE_STRING, false, true)], $key, DataSchema::SOURCE_NONE); + } + + $casts = $model->getCasts(); + $hidden = $model->getHidden(); + + $columns = []; + foreach ($rows as $row) { + $name = $row['name']; + + $columns[] = new DataColumn( + name: $name, + type: $this->castType($casts[$name] ?? null) ?? $this->columnType($row['type_name'], $row['type']), + nullable: $row['nullable'], + identifier: $name === $key, + sensitive: SensitiveValueMasker::isSensitive($name) || in_array($name, $hidden, true), + ); + } + + if ($columns === []) { + return new DataSchema([DataColumn::of($key, DataColumn::TYPE_STRING, false, true)], $key, DataSchema::SOURCE_NONE); + } + + $identifier = null; + foreach ($columns as $column) { + if ($column->identifier) { + $identifier = $column->name; + } + } + + return new DataSchema($columns, $identifier, DataSchema::SOURCE_SCHEMA); + } + + /** + * @param class-string $entityClass + */ + private function fromEntity(string $entityClass): DataSchema + { + $fields = $this->introspector->fieldsOf($entityClass); + if ($fields === []) { + return DataSchema::empty(); + } + + $identifier = $this->identifierOf($entityClass, array_column($fields, 'name')); + + $columns = []; + foreach ($fields as $field) { + $columns[] = DataColumn::of($field['name'], $field['type'], $field['nullable'], $field['name'] === $identifier); + } + + return new DataSchema($columns, $identifier, DataSchema::SOURCE_ENTITY); + } + + /** + * The conventional identifier of a plain entity, in a FIXED preference order so the answer never depends + * on declaration order: `id` (what Firefly's own Domain\Entity promotes), then `uuid`, then the + * type-qualified forms an application writes when it avoids a bare `id` (`walletId`, `wallet_id`). + * Nothing else is guessed — an entity that names its key something else gets a null identifier and a + * list-only resource, which is a correct refusal rather than a delete aimed at the wrong column. + * + * @param class-string $entityClass + * @param list<string> $names + */ + private function identifierOf(string $entityClass, array $names): ?string + { + $position = strrpos($entityClass, '\\'); + $short = $position === false ? $entityClass : substr($entityClass, $position + 1); + $snake = strtolower((string) preg_replace('/([a-z\d])([A-Z])/', '$1_$2', $short)); + + foreach (['id', 'uuid', lcfirst($short).'Id', $snake.'_id'] as $candidate) { + if (in_array($candidate, $names, true)) { + return $candidate; + } + } + + return null; + } + + /** + * The semantic type a model's `$casts` entry declares, or null when the cast says nothing about display. + * + * Numeric casts (`float`, `double`, `decimal:2`) deliberately resolve to `string` rather than to a + * numeric display type — see DataColumn for the money-rounding argument. + */ + private function castType(mixed $cast): ?string + { + if (! is_string($cast)) { + return null; + } + + $base = strtolower(explode(':', $cast, 2)[0]); + + return match ($base) { + 'array', 'json', 'object', 'collection', 'encrypted' => DataColumn::TYPE_JSON, + 'bool', 'boolean' => DataColumn::TYPE_BOOL, + 'int', 'integer' => DataColumn::TYPE_INT, + 'date', 'datetime', 'immutable_date', 'immutable_datetime', 'custom_datetime', + 'immutable_custom_datetime', 'timestamp' => DataColumn::TYPE_DATETIME, + 'real', 'float', 'double', 'decimal', 'string' => DataColumn::TYPE_STRING, + default => null, + }; + } + + /** + * Map a driver type name onto the display vocabulary. + * + * `tinyint` is checked against the FULL type rather than the type name because `tinyint(1)` is how both + * MySQL and sqlite spell a boolean while a bare `tinyint` is a small integer, and the distinction is only + * in the width. + */ + private function columnType(string $typeName, string $fullType): string + { + $name = strtolower($typeName); + $full = strtolower($fullType); + + if ($name === 'tinyint' || $name === 'bit') { + return str_contains($full, '(1)') ? DataColumn::TYPE_BOOL : DataColumn::TYPE_INT; + } + + return match ($name) { + 'bool', 'boolean' => DataColumn::TYPE_BOOL, + 'int', 'integer', 'bigint', 'smallint', 'mediumint', 'int2', 'int4', 'int8', + 'serial', 'bigserial', 'smallserial' => DataColumn::TYPE_INT, + 'json', 'jsonb' => DataColumn::TYPE_JSON, + 'date', 'datetime', 'datetime2', 'smalldatetime', 'datetimeoffset', + 'timestamp', 'timestamptz', 'datetimetz' => DataColumn::TYPE_DATETIME, + default => DataColumn::TYPE_STRING, + }; + } +} diff --git a/packages/admin/src/Data/DataWriteOutcome.php b/packages/admin/src/Data/DataWriteOutcome.php new file mode 100644 index 0000000..dc158a7 --- /dev/null +++ b/packages/admin/src/Data/DataWriteOutcome.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Data; + +/** + * What happened to a write, as four distinguishable outcomes rather than a bool. + * + * A bare `false` collapses four situations a person needs to tell apart, and the view's copy is different for + * every one: `Refused` means the operator has to change configuration (or is asking for something the browser + * will never do, like re-keying a row); `NotFound` means the row or the resource is gone and the page should + * navigate away rather than offer a retry; `Failed` means the database said no and the operator should look + * at the log; `Done` means show the new state. Rendering "delete failed" for all four sends an operator to + * debug a database that is working perfectly because a config key is off. + */ +enum DataWriteOutcome: string +{ + case Done = 'done'; + case Refused = 'refused'; + case NotFound = 'not_found'; + case Failed = 'failed'; +} diff --git a/packages/admin/src/Data/DataWriteResult.php b/packages/admin/src/Data/DataWriteResult.php new file mode 100644 index 0000000..072204e --- /dev/null +++ b/packages/admin/src/Data/DataWriteResult.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Data; + +/** + * The typed result of a delete or an update: the outcome, a sentence explaining it, and what was touched. + * + * `$reason` is always populated, including on success ("Deleted."), so the view has one field to render in + * every branch instead of a match over the enum. Every reason produced by DataBrowser is a fixed sentence + * composed in this layer — never an exception message, for the reason DataListing's docblock sets out at + * length: a QueryException's message carries the SQL and its bindings. + * + * `$changed` lists the columns an update actually wrote, which is not the same as the columns it was given: a + * submitted form round-trips every field, and the ones whose value did not change, plus the identifier and + * any masked secret, are dropped. The view uses it to say what happened; a test uses it to prove the + * identifier and the secrets were dropped. + */ +final readonly class DataWriteResult +{ + /** + * @param list<string> $changed + */ + public function __construct( + public DataWriteOutcome $outcome, + public string $reason, + public ?string $resource = null, + public int|string|null $id = null, + public array $changed = [], + ) {} + + /** + * @param list<string> $changed + */ + public static function done(string $reason, ?string $resource = null, int|string|null $id = null, array $changed = []): self + { + return new self(DataWriteOutcome::Done, $reason, $resource, $id, $changed); + } + + public static function refused(string $reason, ?string $resource = null, int|string|null $id = null): self + { + return new self(DataWriteOutcome::Refused, $reason, $resource, $id); + } + + public static function notFound(string $reason, ?string $resource = null, int|string|null $id = null): self + { + return new self(DataWriteOutcome::NotFound, $reason, $resource, $id); + } + + public static function failed(string $reason, ?string $resource = null, int|string|null $id = null): self + { + return new self(DataWriteOutcome::Failed, $reason, $resource, $id); + } + + public function isDone(): bool + { + return $this->outcome === DataWriteOutcome::Done; + } + + public function isRefused(): bool + { + return $this->outcome === DataWriteOutcome::Refused; + } + + public function isNotFound(): bool + { + return $this->outcome === DataWriteOutcome::NotFound; + } + + public function isFailed(): bool + { + return $this->outcome === DataWriteOutcome::Failed; + } +} diff --git a/packages/admin/src/Data/RepositoryIntrospector.php b/packages/admin/src/Data/RepositoryIntrospector.php index 9e3ce86..b8086f4 100644 --- a/packages/admin/src/Data/RepositoryIntrospector.php +++ b/packages/admin/src/Data/RepositoryIntrospector.php @@ -125,11 +125,7 @@ public function entityOf(string $repositoryClass): ?string */ private function readEntity(string $repositoryClass): ?string { - try { - $reflection = new ReflectionClass($repositoryClass); - } catch (Throwable) { - return null; - } + $reflection = new ReflectionClass($repositoryClass); foreach (['findById', 'save'] as $method) { if (! $reflection->hasMethod($method)) { @@ -179,11 +175,7 @@ public function fieldsOf(string $entityClass): array return $this->fields[$entityClass]; } - try { - $reflection = new ReflectionClass($entityClass); - } catch (Throwable) { - return $this->fields[$entityClass] = []; - } + $reflection = new ReflectionClass($entityClass); /** @var array<string, array{name: string, type: string, nullable: bool}> $fields */ $fields = []; diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index dd109db..6909a16 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -8,6 +8,7 @@ use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; use Firefly\Admin\BeanGraph; +use Firefly\Admin\Data\DataBrowser; use Firefly\Admin\Format; use Firefly\Context\Scan\AppScan; use Illuminate\Contracts\Container\Container; @@ -15,6 +16,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Session\Store; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; /** @@ -32,6 +34,7 @@ public function __construct( private ViewFactory $views, private Container $container, private ManagementPortGuard $guard, + private DataBrowser $data, ) {} public function __invoke(Request $request, string $page = ''): SymfonyResponse @@ -70,9 +73,121 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse return $this->setLoggerLevel($request); } + if ($slug === 'data') { + return $request->isMethod('POST') ? $this->dataWrite($request) : $this->dataPage($request); + } + return $this->html($this->render($slug === '' ? 'overview' : $slug, $this->data($slug), $current), 200); } + /** + * An edit or a delete from the record page. + * + * Both go through DataBrowser, which refuses anything the two switches do not permit — this method never + * decides that itself. The outcome is carried back in the session so a refusal reads as a sentence on + * the page the operator was already looking at, rather than as a status code they have to interpret. + */ + private function dataWrite(Request $request): SymfonyResponse + { + // A disabled browser answers the same way for every shape. DataBrowser refuses the write regardless + // — verified: the row is untouched — but redirecting to a page that then 404s tells the caller the + // request was understood and merely declined, which is a different fact from "this does not exist". + if (! $this->data->isEnabled()) { + return $this->html($this->render('data-disabled', []), 404); + } + + $slug = $request->input('resource'); + $id = $request->input('id'); + + if (! is_string($slug) || $slug === '' || ! is_string($id) || $id === '') { + return $this->html($this->render('data-missing', ['slug' => is_string($slug) ? $slug : '']), 400); + } + + $back = $this->settings->url('data').'?resource='.urlencode($slug); + + if ($request->input('op') === 'delete') { + $result = $this->data->delete($slug, $id); + + // A successful delete has nowhere to go back TO, so it lands on the listing. + return $this->redirect($result->isDone() ? $back : $back.'&id='.urlencode($id), $result->reason); + } + + /** @var array<string, mixed> $fields */ + $fields = is_array($request->input('f')) ? $request->input('f') : []; + + return $this->redirect($back.'&id='.urlencode($id), $this->data->update($slug, $id, $fields)->reason); + } + + /** + * Redirect back with the outcome sentence flashed. + * + * Guarded on the session actually being started: the dashboard mounts on the plain router and an + * application can serve it without session middleware, where with() would throw — and a write that + * SUCCEEDED failing on its way to reporting success is the worst possible outcome for this control. + */ + private function redirect(string $to, string $message): RedirectResponse + { + $response = new RedirectResponse($to); + + $session = $this->container->bound('session') ? $this->container->get('session') : null; + + if ($session instanceof Store && $session->isStarted()) { + $response->with('data-message', $message); + } + + return $response; + } + + /** + * The data browser: a resource index, one resource's records, or a single record. + * + * All three live behind one slug rather than three routes because the browser's own switch decides + * whether ANY of it exists, and a disabled browser must answer the same way for every shape rather than + * 404ing some paths and rendering others. + */ + private function dataPage(Request $request): SymfonyResponse + { + if (! $this->data->isEnabled()) { + return $this->html($this->render('data-disabled', []), 404); + } + + $slug = $request->query('resource'); + $slug = is_string($slug) && $slug !== '' ? $slug : null; + + if ($slug === null) { + return $this->html($this->render('data-index', ['resources' => $this->data->resources()]), 200); + } + + $id = $request->query('id'); + if (is_string($id) && $id !== '') { + $record = $this->data->find($slug, $id); + + return $record === null + ? $this->html($this->render('data-missing', ['slug' => $slug]), 404) + : $this->html($this->render('data-record', [ + 'record' => $record, + 'writable' => $this->data->isWritable(), + ]), 200); + } + + $page = (int) ($request->query('page') ?? 1); + $sort = $request->query('sort'); + $direction = $request->query('dir') === 'desc' ? 'desc' : 'asc'; + $search = $request->query('q'); + + return $this->html($this->render('data-list', [ + 'listing' => $this->data->list( + $slug, + max(1, $page), + null, + is_string($sort) && $sort !== '' ? $sort : null, + $direction, + is_string($search) && $search !== '' ? $search : null, + ), + 'writable' => $this->data->isWritable(), + ]), 200); + } + /** @return array<string,mixed> */ private function data(string $slug): array { @@ -349,6 +464,8 @@ private function nav(): array return array_values(array_filter( AdminPage::all(), fn (AdminPage $page): bool => $this->settings->allows($page->slug) + // The data browser has no actuator endpoint; its own switch decides whether it is offered. + && ($page->slug !== 'data' || $this->data->isEnabled()) && ($page->requires === null || $this->reader->has($page->requires)), )); } diff --git a/packages/admin/src/Web/AdminPage.php b/packages/admin/src/Web/AdminPage.php index 507e0d8..e8281b0 100644 --- a/packages/admin/src/Web/AdminPage.php +++ b/packages/admin/src/Web/AdminPage.php @@ -26,6 +26,8 @@ public const GROUP_CONFIG = 'Configuration'; + public const GROUP_DATA = 'Data'; + public function __construct( public string $slug, public string $label, @@ -66,6 +68,11 @@ public static function all(): array 'The cache stores this application has configured.'), new self('loggers', 'Loggers', 'loggers', self::GROUP_CONFIG, 'Log channels and their levels.'), + + // `requires` is null: the data browser reads repositories through the container, not an actuator + // endpoint. Its own switch decides whether the page appears — see AdminAction::nav(). + new self('data', 'Browse data', null, self::GROUP_DATA, + 'Every repository this application declared, and the records behind it.'), ]; } @@ -76,6 +83,6 @@ public static function all(): array */ public static function groups(): array { - return [self::GROUP_RUNTIME, self::GROUP_WIRING, self::GROUP_CONFIG]; + return [self::GROUP_RUNTIME, self::GROUP_WIRING, self::GROUP_DATA, self::GROUP_CONFIG]; } } diff --git a/packages/admin/tests/Data/DataBrowserEdgeTest.php b/packages/admin/tests/Data/DataBrowserEdgeTest.php new file mode 100644 index 0000000..ed0ce07 --- /dev/null +++ b/packages/admin/tests/Data/DataBrowserEdgeTest.php @@ -0,0 +1,234 @@ +<?php + +declare(strict_types=1); + +use Firefly\Admin\Data\DataColumn; +use Firefly\Admin\Data\DataSchema; +use Firefly\Admin\Data\DataWriteOutcome; +use Firefly\Admin\Tests\Data\Fixtures\Alt\AdminRecordRepository as AltAdminRecordRepository; +use Firefly\Admin\Tests\Data\Fixtures\BrokenRepository; +use Firefly\Admin\Tests\Data\Fixtures\GhostRecordRepository; +use Firefly\Admin\Tests\Data\Fixtures\NotARepository; +use Firefly\Admin\Tests\Data\Fixtures\OrphanRecordRepository; +use Firefly\Admin\Tests\Data\Fixtures\PairRepository; +use Firefly\Admin\Tests\Data\Fixtures\ScopedNoteRepository; +use Firefly\Admin\Tests\Data\Fixtures\WidgetRepository; +use Firefly\Admin\Tests\Data\Support\DataBrowserTestCase; +use Firefly\Data\Repository\CrudRepository; +use Illuminate\Support\Facades\DB; + +uses(DataBrowserTestCase::class); + +/** + * The edges the happy-path files do not reach: the projector's non-scalar branches, the resources that + * degrade (no identifier, no table, no constructible bean), and the coercions that must be refused. + */ + +// `deleteById()` returns void, so a repository that removed nothing is indistinguishable from one that +// removed the row unless the browser goes back and looks. +it('reports a delete the repository silently declined instead of claiming success', function () { + /** @var DataBrowserTestCase $this */ + $this->seedNotes(); + $browser = $this->browserOver([ScopedNoteRepository::class], ['enabled' => true, 'writable' => true]); + + // Note 2 is not pinned, so this repository's scoped delete matches nothing at all. + $declined = $browser->delete('plain-note', 2); + + expect($declined->outcome)->toBe(DataWriteOutcome::Failed) + ->and($declined->reason)->toBe('The repository accepted the delete but the record is still present.') + ->and(DB::table('admin_notes')->where('id', 2)->exists())->toBeTrue(); + + // Note 1 is pinned, so the same code path succeeds and says so. + expect($browser->delete('plain-note', 1)->isDone())->toBeTrue() + ->and(DB::table('admin_notes')->where('id', 1)->exists())->toBeFalse(); +}); + +it('reduces a datetime, an enum, an array, a value object and an opaque object to printable values', function () { + /** @var DataBrowserTestCase $this */ + $this->seedWidgets(); + $browser = $this->browserOver([WidgetRepository::class]); + + $row = $browser->list('widget')->rows[0]; + + expect($row['occurredAt'])->toBe('2026-02-01 09:30:00') + ->and($row['status'])->toBe('live') + ->and($row['tags'])->toBe('["a","b"]') + ->and($row['price'])->toBe('10.10 EUR') + // Nothing printable, so the class name rather than an "Object of class X" fatal. + ->and($row['opaque'])->toBe('[stdClass]'); + + // The detail view reduces them identically. + expect($this->recordOf($browser, 'widget', 'w-1')->fields['status'])->toBe('live'); +}); + +it('falls through the identifier preference order to uuid when there is no id', function () { + /** @var DataBrowserTestCase $this */ + $this->seedWidgets(); + $browser = $this->browserOver([WidgetRepository::class]); + $schema = $this->schemaOf($browser, 'widget'); + + expect($schema->source)->toBe(DataSchema::SOURCE_ENTITY) + ->and($schema->identifier)->toBe('uuid') + ->and($schema->identifierColumn()?->identifier)->toBeTrue() + ->and($this->columnOf($browser, 'widget', 'occurredAt')->type)->toBe(DataColumn::TYPE_DATETIME) + ->and($this->columnOf($browser, 'widget', 'tags')->type)->toBe(DataColumn::TYPE_JSON) + // The listing is ordered by that identifier, not left to the repository's whim. + ->and($browser->list('widget')->sort)->toBe('uuid'); +}); + +// A resource whose key cannot be derived is browsable as a list and nothing else. Guessing a column here +// would mean a DELETE whose WHERE clause matched rows nobody asked about. +it('refuses to address a single row of a resource with no identifier', function () { + /** @var DataBrowserTestCase $this */ + $this->seedPairs(); + $browser = $this->browserOver([PairRepository::class], ['enabled' => true, 'writable' => true]); + + $schema = $this->schemaOf($browser, 'pair'); + $listing = $browser->list('pair'); + + expect($schema->identifier)->toBeNull() + ->and($schema->identifierColumn())->toBeNull() + ->and($listing->failed())->toBeFalse() + ->and($listing->total)->toBe(2) + // Nothing sortable was derivable either, so the listing goes out unordered rather than on a guess. + ->and($listing->sort)->toBeNull() + ->and($browser->find('pair', 'alpha'))->toBeNull() + ->and($browser->delete('pair', 'alpha')->reason)->toBe('This resource has no identifier column, so a single record cannot be addressed.') + ->and($browser->delete('pair', 'alpha')->outcome)->toBe(DataWriteOutcome::Refused) + // The reason matters as much as the outcome: without the identifier check this would still be + // refused, but for the wrong reason ("not an Eloquent model"), and delete would have run. + ->and($browser->update('pair', 'alpha', ['right' => 'x'])->reason)->toBe('This resource has no identifier column, so a single record cannot be addressed.') + ->and(DB::table('pairs')->count())->toBe(2); +}); + +// The binding is real — it came from the catalogue — but running the constructor is what fails. +it('states a reason when the repository bean cannot be constructed', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browserOver([BrokenRepository::class]); + + // No narrowed return type anywhere, so no entity is inferred and the resource is named after the + // repository with the conventional suffix stripped. + $resource = $this->resourceOf($browser, 'broken'); + $listing = $browser->list('broken'); + + expect($resource->entityClass)->toBeNull() + ->and($resource->shortName())->toBe('BrokenRepository') + ->and($listing->failed())->toBeTrue() + ->and($listing->error)->toBe('The repository bean for this resource could not be resolved from the container.') + ->and($listing->columns())->toBe([]) + ->and($browser->find('broken', 1))->toBeNull(); +}); + +it('exposes the schema columns of a listing, in order, for the view to draw', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $listing = $this->browser()->list('admin-record'); + + expect(array_map(fn (DataColumn $c) => $c->name, $listing->columns())) + ->toBe(['id', 'email', 'api_token', 'recovery_phrase', 'amount', 'active', 'meta', 'created_at']) + ->and($listing->columns())->toEqual($this->schemaOf($this->browser(), 'admin-record')->columns); +}); + +it('degrades a model whose table is missing to a key-only listing that says so', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browserOver([GhostRecordRepository::class]); + $schema = $this->schemaOf($browser, 'ghost-record'); + + expect($schema->source)->toBe(DataSchema::SOURCE_NONE) + ->and($schema->isEmpty())->toBeFalse() + ->and($schema->names())->toBe(['id']) + ->and($schema->identifier)->toBe('id') + // The resource stays in the menu instead of vanishing with no explanation. + ->and(array_map(fn ($r) => $r->slug, $browser->resources()))->toBe(['ghost-record']); +}); + +// `active` is the case the casts are usually credited with: sqlite spells `boolean()` as `tinyint(1)`, which +// the driver type map already reads correctly. This model declares no casts at all, so nothing else can. +it('types a column from the driver alone when the model declares no casts', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browserOver([AltAdminRecordRepository::class]); + $schema = $this->schemaOf($browser, 'admin-record'); + + $types = []; + foreach ($schema->columns as $column) { + $types[$column->name] = $column->type; + } + + expect($schema->source)->toBe(DataSchema::SOURCE_SCHEMA) + ->and($types)->toBe([ + 'id' => DataColumn::TYPE_INT, + 'label' => DataColumn::TYPE_STRING, + 'archived' => DataColumn::TYPE_BOOL, + 'rank' => DataColumn::TYPE_INT, + ]); +}); + +it('refuses a value whose PHP type the column cannot hold', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(['enabled' => true, 'writable' => true]); + + // An array posted at a column that is not JSON is not a value, it is a malformed submission. + expect($browser->update('admin-record', 1, ['email' => ['a', 'b']])->outcome)->toBe(DataWriteOutcome::Refused) + // An explicit null at a NOT NULL column would be a constraint violation dressed up as an edit. + ->and($browser->update('admin-record', 1, ['amount' => null])->outcome)->toBe(DataWriteOutcome::Refused) + ->and($browser->update('admin-record', 1, ['amount' => null])->reason)->toContain('amount') + // A nullable one takes it. + ->and($browser->update('admin-record', 1, ['api_token' => null])->isDone())->toBeTrue() + ->and(DB::table('admin_records')->where('id', 1)->value('email'))->toBe('ada@example.test') + ->and(DB::table('admin_records')->where('id', 1)->value('amount'))->toBe(50); +}); + +it('treats a blank search as no search at all', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $listing = $this->browser()->list('admin-record', search: " \t "); + + expect($listing->search)->toBeNull() + ->and($listing->total)->toBe(5); +}); + +// The in-PHP fallback has to order nulls somewhere, and "wherever the comparison happens to put them" leaves +// empties scattered through the values. +it('sorts nulls last in the in-PHP fallback', function () { + /** @var DataBrowserTestCase $this */ + $this->seedNotes(); + + // Gamma is the row with no body. + expect(array_column($this->browser()->list('plain-note', sort: 'body')->rows, 'title'))->toBe(['Alpha', 'Beta', 'Gamma']); +}); + +it('degrades a type outside the closed vocabulary to string rather than passing it to the view', function () { + expect(DataColumn::of('whatever', 'numeric')->type)->toBe(DataColumn::TYPE_STRING) + ->and(DataColumn::of('whatever', DataColumn::TYPE_JSON)->type)->toBe(DataColumn::TYPE_JSON) + ->and(DataColumn::of('api_token')->sensitive)->toBeTrue(); +}); + +it('degrades a model whose connection is not configured to a key-only listing', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browserOver([OrphanRecordRepository::class]); + $schema = $this->schemaOf($browser, 'orphan-record'); + + // Asking the schema builder for columns throws here; the key name is known without a connection, and it + // is the one column every other operation needs. + expect($schema->source)->toBe(DataSchema::SOURCE_NONE) + ->and($schema->names())->toBe(['id']) + ->and($schema->identifier)->toBe('id') + ->and($this->resourceOf($browser, 'orphan-record')->isEloquentBacked())->toBeTrue(); +}); + +// BeansCatalog is a COMPILED snapshot, so it can be stale: a row may still claim an interface the class +// stopped implementing. Trusting the row and handing the caller whatever the container returned would put a +// non-repository through the query engine. +it('refuses a bean the stale catalogue calls a repository but the container does not', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browserOverStaleCatalog([NotARepository::class => [CrudRepository::class]]); + + $listing = $browser->list('not-a'); + + expect(array_map(fn ($r) => $r->slug, $browser->resources()))->toBe(['not-a']) + ->and($listing->failed())->toBeTrue() + ->and($listing->error)->toBe('The repository bean for this resource could not be resolved from the container.') + ->and($browser->find('not-a', 1))->toBeNull(); +}); diff --git a/packages/admin/tests/Data/DataBrowserReadTest.php b/packages/admin/tests/Data/DataBrowserReadTest.php new file mode 100644 index 0000000..65d566f --- /dev/null +++ b/packages/admin/tests/Data/DataBrowserReadTest.php @@ -0,0 +1,226 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Introspection\SensitiveValueMasker; +use Firefly\Admin\Data\DataQueryEngine; +use Firefly\Admin\Tests\Data\Support\DataBrowserTestCase; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +uses(DataBrowserTestCase::class); + +it('pages a PagingAndSortingRepository through findPaged and reports the grand total', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $listing = $this->browser()->list('admin-record', page: 2, perPage: 2); + + expect($listing->failed())->toBeFalse() + ->and($listing->total)->toBe(5) + ->and($listing->page)->toBe(2) + ->and($listing->perPage)->toBe(2) + ->and($listing->totalPages())->toBe(3) + ->and($listing->hasNext())->toBeTrue() + ->and($listing->hasPrevious())->toBeTrue() + ->and(array_column($listing->rows, 'id'))->toBe([3, 4]); +}); + +it('orders by the identifier when no sort is asked for, so pages cannot overlap', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $listing = $this->browser()->list('admin-record'); + + expect($listing->sort)->toBe('id') + ->and($listing->direction)->toBe('asc') + ->and(array_column($listing->rows, 'id'))->toBe([1, 2, 3, 4, 5]); +}); + +it('honours a sort on a known column and drops one the schema does not know', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $descending = $this->browser()->list('admin-record', sort: 'amount', direction: 'desc'); + expect($descending->sort)->toBe('amount') + ->and(array_column($descending->rows, 'amount'))->toBe([450, 350, 250, 150, 50]); + + // A crafted column name is not quoted, escaped or passed through — it simply fails the membership test + // and the listing falls back to the identifier. + $crafted = $this->browser()->list('admin-record', sort: 'amount) ; drop table admin_records; --'); + expect($crafted->failed())->toBeFalse() + ->and($crafted->sort)->toBe('id') + ->and(Schema::hasTable('admin_records'))->toBeTrue(); +}); + +it('masks a secret column in a listing but leaves a null one null', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $rows = $this->browser()->list('admin-record')->rows; + + expect($rows[0]['api_token'])->toBe(SensitiveValueMasker::MASK) + ->and($rows[0]['recovery_phrase'])->toBe(SensitiveValueMasker::MASK) + // Row 2 has no token; masking a null would claim a secret exists where none does. + ->and($rows[1]['api_token'])->toBeNull() + ->and($rows[0]['email'])->toBe('ada@example.test'); + + expect(json_encode($rows))->not->toContain('sk_live_ada_secret'); +}); + +it('searches through the repository with a BOUND term, not an interpolated one', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + // An apostrophe is the classic break-out character. It finds its row because it was bound. + $found = $this->browser()->list('admin-record', search: "o'brien"); + expect($found->failed())->toBeFalse() + ->and($found->total)->toBe(1) + ->and($found->rows[0]['email'])->toBe("o'brien@example.test"); + + // And an outright injection attempt is just a string that matches nothing. + $attack = $this->browser()->list('admin-record', search: "' OR 1=1; DROP TABLE admin_records; --"); + expect($attack->failed())->toBeFalse() + ->and($attack->total)->toBe(0) + ->and($attack->rows)->toBe([]) + ->and(Schema::hasTable('admin_records'))->toBeTrue() + ->and(DB::table('admin_records')->count())->toBe(5); +}); + +it('pages a searched listing in SQL and counts only the matches', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $listing = $this->browser()->list('admin-record', page: 1, perPage: 2, search: 'example.test'); + + expect($listing->total)->toBe(5) + ->and($listing->rows)->toHaveCount(2) + ->and($listing->search)->toBe('example.test'); +}); + +it('never searches a masked column, so the search box is not an oracle', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + expect($this->browser()->list('admin-record', search: 'sk_live')->total)->toBe(0); +}); + +it('falls back to findAll with an in-PHP slice for a plain CrudRepository', function () { + /** @var DataBrowserTestCase $this */ + $this->seedNotes(); + + $listing = $this->browser()->list('plain-note', page: 2, perPage: 2); + + expect($listing->failed())->toBeFalse() + ->and($listing->total)->toBe(3) + ->and($listing->rows)->toHaveCount(1) + ->and($listing->rows[0]['title'])->toBe('Gamma') + ->and($listing->rows[0]['id'])->toBe(3) + // Read off a promoted PROTECTED property and a public one alike. + ->and($listing->rows[0]['pinned'])->toBeFalse(); +}); + +it('sorts and filters the in-PHP fallback without touching SQL', function () { + /** @var DataBrowserTestCase $this */ + $this->seedNotes(); + + $sorted = $this->browser()->list('plain-note', sort: 'title', direction: 'desc'); + expect(array_column($sorted->rows, 'title'))->toBe(['Gamma', 'Beta', 'Alpha']); + + $searched = $this->browser()->list('plain-note', search: 'note'); + expect($searched->total)->toBe(2) + ->and(array_column($searched->rows, 'title'))->toBe(['Alpha', 'Beta']); + + $injected = $this->browser()->list('plain-note', search: "'; DROP TABLE admin_notes; --"); + expect($injected->total)->toBe(0) + ->and(Schema::hasTable('admin_notes'))->toBeTrue(); +}); + +it('clamps the page size to the configured ceiling', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + expect($this->browser()->list('admin-record', perPage: 10_000)->perPage)->toBe(200) + ->and($this->browser(['enabled' => true, 'max-page-size' => 2])->list('admin-record', perPage: 500)->perPage)->toBe(2) + ->and($this->browser()->list('admin-record', perPage: 0)->perPage)->toBe(1) + ->and($this->browser()->list('admin-record', page: -5)->page)->toBe(1); +}); + +it('truncates a long value in a listing and shows it whole in the record', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $long = '{"note":"'.str_repeat('x', 400).'"}'; + DB::table('admin_records')->where('id', 1)->update(['meta' => $long]); + + $listed = $this->stringCell($this->browser()->list('admin-record')->rows[0], 'meta'); + expect(mb_strlen($listed))->toBe(DataQueryEngine::LIST_VALUE_LIMIT + 1) + ->and($listed)->toEndWith("\u{2026}") + ->and($this->recordOf($this->browser(), 'admin-record', 1)->fields['meta'])->toBe($long); +}); + +it('returns one record as an ordered field map', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $record = $this->recordOf($this->browser(), 'admin-record', 3); + + expect($record->id)->toBe(3) + // Schema order, not driver order — a detail page whose fields move between rows is unreadable. + ->and(array_keys($record->fields))->toBe(['id', 'email', 'api_token', 'recovery_phrase', 'amount', 'active', 'meta', 'created_at']) + ->and($record->fields['email'])->toBe('grace@example.test') + ->and($record->fields['api_token'])->toBe(SensitiveValueMasker::MASK) + ->and($record->fields['created_at'])->toBe('2026-01-03 10:00:00'); + + $rows = $record->rows(); + expect($rows[0]['name'])->toBe('id') + ->and($rows[0]['identifier'])->toBeTrue() + ->and($rows[0]['editable'])->toBeFalse() + ->and($rows[1]['label'])->toBe('Email') + ->and($rows[1]['editable'])->toBeTrue() + ->and($rows[2]['sensitive'])->toBeTrue(); +}); + +it('returns null for a record that is not there', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $this->seedNotes(); + + expect($this->browser()->find('admin-record', 999))->toBeNull() + ->and($this->browser()->find('nope', 1))->toBeNull() + ->and($this->recordOf($this->browser(), 'plain-note', 2)->fields['title'])->toBe('Beta'); +}); + +it('reports a query failure without leaking the SQL or the bindings', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(); + Schema::drop('admin_records'); + + $listing = $browser->list('admin-record', search: 'ada@example.test'); + + expect($listing->failed())->toBeTrue() + ->and($listing->rows)->toBe([]) + ->and($listing->total)->toBe(0) + ->and($listing->error)->toContain('The listing query failed') + ->and($listing->error)->toContain('QueryException') + // The exception message would have carried `select * from "admin_records" ...` and the bound term. + ->and(strtolower((string) $listing->error))->not->toContain('select') + ->and($listing->error)->not->toContain('ada@example.test') + // A detail read of a broken resource is a 404, never a stack trace. + ->and($browser->find('admin-record', 1))->toBeNull(); +}); + +it('shows nothing at all while the browser is switched off', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser([]); + + expect($browser->isEnabled())->toBeFalse() + ->and($browser->isWritable())->toBeFalse() + ->and($browser->resources())->toBe([]) + ->and($browser->resource('admin-record'))->toBeNull() + ->and($browser->schema('admin-record'))->toBeNull() + ->and($browser->find('admin-record', 1))->toBeNull() + ->and($browser->list('admin-record')->failed())->toBeTrue() + ->and($browser->list('admin-record')->error)->toContain('firefly.admin.data.enabled'); +}); diff --git a/packages/admin/tests/Data/DataBrowserSettingsTest.php b/packages/admin/tests/Data/DataBrowserSettingsTest.php new file mode 100644 index 0000000..a074f12 --- /dev/null +++ b/packages/admin/tests/Data/DataBrowserSettingsTest.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +use Firefly\Admin\Data\DataBrowserSettings; +use Firefly\Config\Config; +use Illuminate\Config\Repository; + +/** @param array<string, mixed> $data the `firefly.admin.data.*` subtree */ +function dataSettings(array $data = [], bool $appDebug = true): DataBrowserSettings +{ + return DataBrowserSettings::fromConfig(new Config(new Repository([ + 'app' => ['debug' => $appDebug], + 'firefly' => ['admin' => ['enabled' => true, 'data' => $data]], + ]))); +} + +// The whole point of the separate gate: the dashboard follows app.debug, this does not follow anything. +it('is off by default even with app.debug on and the dashboard enabled', function () { + $settings = dataSettings(); + + expect($settings->enabled)->toBeFalse() + ->and($settings->writable)->toBeFalse() + ->and($settings->canWrite())->toBeFalse(); +}); + +it('needs both keys before a write is permitted', function () { + expect(dataSettings(['enabled' => true])->canWrite())->toBeFalse() + // Arming writes without switching the browser on does nothing at all. + ->and(dataSettings(['writable' => true])->canWrite())->toBeFalse() + ->and(dataSettings(['writable' => true])->enabled)->toBeFalse() + ->and(dataSettings(['enabled' => true, 'writable' => true])->canWrite())->toBeTrue(); +}); + +it('defaults the page sizes and clamps a caller-supplied one', function () { + $settings = dataSettings(['enabled' => true]); + + expect($settings->pageSize)->toBe(25) + ->and($settings->maxPageSize)->toBe(200) + ->and($settings->clampPageSize(null))->toBe(25) + ->and($settings->clampPageSize(50))->toBe(50) + ->and($settings->clampPageSize(1_000_000))->toBe(200) + ->and($settings->clampPageSize(0))->toBe(1) + ->and($settings->clampPageSize(-9))->toBe(1); +}); + +it('caps a configured maximum at the hard ceiling, and the default page size at the maximum', function () { + // An application cannot configure its way to an OOM: one request must never be able to ask for a + // million rows just because a config key said so. + expect(dataSettings(['enabled' => true, 'max-page-size' => 50_000])->maxPageSize)->toBe(DataBrowserSettings::PAGE_SIZE_CEILING) + ->and(dataSettings(['enabled' => true, 'max-page-size' => 0])->maxPageSize)->toBe(1) + // A default larger than the maximum is incoherent; the maximum wins. + ->and(dataSettings(['enabled' => true, 'page-size' => 900, 'max-page-size' => 100])->pageSize)->toBe(100); +}); + +it('parses the exclusion list as case-insensitive csv and refuses those slugs', function () { + $settings = dataSettings(['enabled' => true, 'exclude' => ' User , AUDIT-LOG ,, ']); + + expect($settings->excluded)->toBe(['user', 'audit-log']) + ->and($settings->allows('user'))->toBeFalse() + ->and($settings->allows('audit-log'))->toBeFalse() + ->and($settings->allows('wallet'))->toBeTrue() + ->and(dataSettings(['enabled' => true])->allows('anything'))->toBeTrue(); +}); diff --git a/packages/admin/tests/Data/DataBrowserWriteTest.php b/packages/admin/tests/Data/DataBrowserWriteTest.php new file mode 100644 index 0000000..a57c3e3 --- /dev/null +++ b/packages/admin/tests/Data/DataBrowserWriteTest.php @@ -0,0 +1,221 @@ +<?php + +declare(strict_types=1); + +use Firefly\Admin\Data\DataBrowser; +use Firefly\Admin\Data\DataWriteOutcome; +use Firefly\Admin\Tests\Data\Support\DataBrowserTestCase; +use Illuminate\Support\Facades\DB; + +uses(DataBrowserTestCase::class); + +/** + * Both gates open. + * + * @return array<string, mixed> + */ +function writableData(): array +{ + return ['enabled' => true, 'writable' => true]; +} + +it('refuses every write while the browser is switched off', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser([]); + + $deleted = $browser->delete('admin-record', 1); + $updated = $browser->update('admin-record', 1, ['email' => 'x@y.test']); + + expect($deleted->outcome)->toBe(DataWriteOutcome::Refused) + ->and($deleted->reason)->toContain('firefly.admin.data.enabled') + // An operator who never switched the browser on is not told that a write key exists. + ->and($deleted->reason)->not->toContain('writable') + ->and($updated->isRefused())->toBeTrue() + ->and(DB::table('admin_records')->count())->toBe(5); +}); + +it('refuses every write while the browser is read-only, naming the key that would open it', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(['enabled' => true]); + + $deleted = $browser->delete('admin-record', 1); + $updated = $browser->update('admin-record', 1, ['email' => 'x@y.test']); + + expect($browser->isEnabled())->toBeTrue() + ->and($browser->isWritable())->toBeFalse() + ->and($deleted->outcome)->toBe(DataWriteOutcome::Refused) + ->and($deleted->reason)->toContain('firefly.admin.data.writable') + ->and($updated->outcome)->toBe(DataWriteOutcome::Refused) + ->and(DB::table('admin_records')->where('id', 1)->value('email'))->toBe('ada@example.test') + ->and(DB::table('admin_records')->count())->toBe(5); +}); + +it('deletes a row through the repository when both gates are open', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $result = $this->browser(writableData())->delete('admin-record', 2); + + expect($result->outcome)->toBe(DataWriteOutcome::Done) + ->and($result->reason)->toBe('Deleted.') + ->and($result->id)->toBe(2) + ->and($result->resource)->toBe('admin-record') + ->and(DB::table('admin_records')->count())->toBe(4) + ->and(DB::table('admin_records')->where('id', 2)->exists())->toBeFalse(); +}); + +it('deletes through a plain CrudRepository too', function () { + /** @var DataBrowserTestCase $this */ + $this->seedNotes(); + + expect($this->browser(writableData())->delete('plain-note', 3)->isDone())->toBeTrue() + ->and(DB::table('admin_notes')->count())->toBe(2); +}); + +it('reports a missing row and a missing resource as NOT FOUND, never as a failure', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(writableData()); + + expect($browser->delete('admin-record', 999)->outcome)->toBe(DataWriteOutcome::NotFound) + ->and($browser->delete('admin-record', 999)->reason)->toBe('No such record.') + ->and($browser->delete('nope', 1)->outcome)->toBe(DataWriteOutcome::NotFound) + ->and($browser->update('admin-record', 999, ['email' => 'x@y.test'])->outcome)->toBe(DataWriteOutcome::NotFound); +}); + +it('updates named columns and reports exactly which ones it wrote', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $result = $this->browser(writableData())->update('admin-record', 1, [ + 'email' => 'ada.lovelace@example.test', + 'amount' => '999', + 'active' => '0', + ]); + + expect($result->outcome)->toBe(DataWriteOutcome::Done) + ->and($result->changed)->toBe(['email', 'amount', 'active']) + ->and($result->reason)->toBe('Updated 3 field(s).'); + + $row = DB::table('admin_records')->where('id', 1); + expect($row->value('email'))->toBe('ada.lovelace@example.test') + // Submitted as strings by a form, stored as the column's own type. + ->and($row->value('amount'))->toBe(999) + ->and($row->value('active'))->toBe(0); +}); + +it('drops the identifier and every masked column instead of writing the mask over the secret', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + // Exactly what a detail form that round-trips every field it rendered would post back. + $result = $this->browser(writableData())->update('admin-record', 1, [ + 'id' => '42', + 'api_token' => '******', + 'recovery_phrase' => '******', + 'email' => 'changed@example.test', + ]); + + expect($result->isDone())->toBeTrue() + ->and($result->changed)->toBe(['email']); + + $row = DB::table('admin_records')->where('email', 'changed@example.test'); + expect($row->value('id'))->toBe(1) + ->and($row->value('api_token'))->toBe('sk_live_ada_secret') + ->and($row->value('recovery_phrase'))->toBe('correct horse battery') + ->and(DB::table('admin_records')->where('id', 42)->exists())->toBeFalse(); +}); + +it('refuses a submission carrying a column this resource does not have', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $result = $this->browser(writableData())->update('admin-record', 1, ['email' => 'x@y.test', 'is_admin' => '1']); + + expect($result->outcome)->toBe(DataWriteOutcome::Refused) + ->and($result->reason)->toContain('are not columns of this resource') + // Nothing at all is written when part of the submission is rejected. + ->and(DB::table('admin_records')->where('id', 1)->value('email'))->toBe('ada@example.test'); +}); + +it('refuses a value that is not of the column type', function (string $column, string $value) { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $result = $this->browser(writableData())->update('admin-record', 1, [$column => $value]); + + expect($result->outcome)->toBe(DataWriteOutcome::Refused) + ->and($result->reason)->toContain($column); +})->with([ + 'a non-numeric integer' => ['amount', 'lots'], + 'an unparseable boolean' => ['active', 'maybe'], + 'malformed json' => ['meta', '{"tier": '], + 'a date nothing can read' => ['created_at', 'the day before yesterday'], + 'an empty value in a NOT NULL column' => ['amount', ''], +]); + +it('writes JSON decoded when the model casts the column, so the row is not double-encoded', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + expect($this->browser(writableData())->update('admin-record', 1, ['meta' => '{"tier":"platinum"}'])->isDone())->toBeTrue(); + + // Exactly the encoded object, with no escaped quotes: a double-encode would have stored + // "{\"tier\":\"platinum\"}" and every later read would have decoded it to a string. + expect(DB::table('admin_records')->where('id', 1)->value('meta'))->toBe('{"tier":"platinum"}'); +}); + +it('turns an emptied nullable field into null rather than into an empty string', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + expect($this->browser(writableData())->update('admin-record', 1, ['created_at' => ''])->isDone())->toBeTrue() + ->and(DB::table('admin_records')->where('id', 1)->value('created_at'))->toBeNull(); +}); + +it('reports an update that changed nothing as done, with an empty change list', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $result = $this->browser(writableData())->update('admin-record', 1, ['email' => 'ada@example.test']); + + expect($result->outcome)->toBe(DataWriteOutcome::Done) + ->and($result->reason)->toBe('Nothing changed.') + ->and($result->changed)->toBe([]); +}); + +it('refuses to mutate a resource whose entities are not Eloquent models', function () { + /** @var DataBrowserTestCase $this */ + $this->seedNotes(); + + $result = $this->browser(writableData())->update('plain-note', 1, ['title' => 'Renamed']); + + expect($result->outcome)->toBe(DataWriteOutcome::Refused) + ->and($result->reason)->toContain('not backed by an Eloquent model') + ->and(DB::table('admin_notes')->where('id', 1)->value('title'))->toBe('Alpha'); +}); + +// A numeric form field name arrives as an INTEGER array key. Before the key type was widened, the +// unknown-column filter took a `string` parameter and blew up under strict_types on exactly this input. +it('rejects a crafted numeric field name as data, not as a TypeError', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + + $result = $this->browser(writableData())->update('admin-record', 1, [0 => 'injected', 'email' => 'x@y.test']); + + expect($result->outcome)->toBe(DataWriteOutcome::Refused) + ->and($result->reason)->toContain('are not columns of this resource') + ->and(DB::table('admin_records')->where('id', 1)->value('email'))->toBe('ada@example.test'); +}); + +// Not a formality: `create()` is refused on principle (a generic form cannot honour a constructor's +// invariants — see DataBrowser's class docblock), and this is the guard that keeps a future edit from +// quietly adding one back. +it('offers no create operation at all', function () { + expect(get_class_methods(DataBrowser::class)) + ->not->toContain('create') + ->not->toContain('insert') + ->not->toContain('store'); +}); diff --git a/packages/admin/tests/Data/DataDiscoveryTest.php b/packages/admin/tests/Data/DataDiscoveryTest.php new file mode 100644 index 0000000..f358ddb --- /dev/null +++ b/packages/admin/tests/Data/DataDiscoveryTest.php @@ -0,0 +1,163 @@ +<?php + +declare(strict_types=1); + +use Firefly\Admin\Data\DataColumn; +use Firefly\Admin\Data\DataSchema; +use Firefly\Admin\Tests\Data\Fixtures\AdminRecord; +use Firefly\Admin\Tests\Data\Fixtures\AdminRecordRepository; +use Firefly\Admin\Tests\Data\Fixtures\Alt\AdminRecordRepository as AltAdminRecordRepository; +use Firefly\Admin\Tests\Data\Fixtures\PlainNote; +use Firefly\Admin\Tests\Data\Fixtures\PlainNoteRepository; +use Firefly\Admin\Tests\Data\Support\DataBrowserTestCase; + +uses(DataBrowserTestCase::class); + +it('finds every CrudRepository bean and nothing else', function () { + /** @var DataBrowserTestCase $this */ + $slugs = array_map(fn ($resource) => $resource->slug, $this->browser()->resources()); + + expect($slugs)->toBe(['admin-record', 'plain-note']); +}); + +it('reads each resource capability from the catalogue and the declared model', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browser(); + + $record = $this->resourceOf($browser, 'admin-record'); + expect($record->label)->toBe('Admin Record') + ->and($record->repositoryClass)->toBe(AdminRecordRepository::class) + ->and($record->entityClass)->toBe(AdminRecord::class) + ->and($record->table)->toBe('admin_records') + ->and($record->paged)->toBeTrue() + ->and($record->eloquent)->toBeTrue() + ->and($record->isEloquentBacked())->toBeTrue(); + + // No $model, so the entity comes from findById()'s narrowed return type; CrudRepository only, so no paging. + $note = $this->resourceOf($browser, 'plain-note'); + expect($note->repositoryClass)->toBe(PlainNoteRepository::class) + ->and($note->entityClass)->toBe(PlainNote::class) + ->and($note->table)->toBeNull() + ->and($note->paged)->toBeFalse() + ->and($note->eloquent)->toBeFalse(); +}); + +it('derives columns from the live schema, refined by the model casts', function () { + /** @var DataBrowserTestCase $this */ + $schema = $this->schemaOf($this->browser(), 'admin-record'); + + expect($schema->source)->toBe(DataSchema::SOURCE_SCHEMA) + ->and($schema->names())->toBe(['id', 'email', 'api_token', 'recovery_phrase', 'amount', 'active', 'meta', 'created_at']) + ->and($schema->identifier)->toBe('id'); + + $types = []; + foreach ($schema->columns as $column) { + $types[$column->name] = $column->type; + } + + // sqlite reports `text` for meta and `tinyint` for active; only the model's casts know what they mean. + expect($types)->toBe([ + 'id' => DataColumn::TYPE_INT, + 'email' => DataColumn::TYPE_STRING, + 'api_token' => DataColumn::TYPE_STRING, + 'recovery_phrase' => DataColumn::TYPE_STRING, + 'amount' => DataColumn::TYPE_INT, + 'active' => DataColumn::TYPE_BOOL, + 'meta' => DataColumn::TYPE_JSON, + 'created_at' => DataColumn::TYPE_DATETIME, + ]); +}); + +it('marks the identifier and reports nullability from the schema', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browser(); + + expect($this->columnOf($browser, 'admin-record', 'id')->identifier)->toBeTrue() + ->and($this->columnOf($browser, 'admin-record', 'id')->nullable)->toBeFalse() + ->and($this->columnOf($browser, 'admin-record', 'email')->identifier)->toBeFalse() + ->and($this->columnOf($browser, 'admin-record', 'email')->nullable)->toBeFalse() + ->and($this->columnOf($browser, 'admin-record', 'api_token')->nullable)->toBeTrue() + ->and($this->schemaOf($browser, 'admin-record')->identifierColumn()?->name)->toBe('id'); +}); + +it('flags a secret by name AND a column the model hides', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browser(); + + // `api_token` matches the actuator masker's regex; `recovery_phrase` matches nothing, and is only a + // secret because the model put it in $hidden. + expect($this->columnOf($browser, 'admin-record', 'api_token')->sensitive)->toBeTrue() + ->and($this->columnOf($browser, 'admin-record', 'api_token')->isEditable())->toBeFalse() + ->and($this->columnOf($browser, 'admin-record', 'recovery_phrase')->sensitive)->toBeTrue() + ->and($this->columnOf($browser, 'admin-record', 'email')->sensitive)->toBeFalse() + ->and($this->columnOf($browser, 'admin-record', 'email')->isEditable())->toBeTrue() + ->and($this->columnOf($browser, 'admin-record', 'id')->isEditable())->toBeFalse(); +}); + +it('derives columns of a plain entity from its promoted constructor parameters', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browser(); + $schema = $this->schemaOf($browser, 'plain-note'); + + expect($schema->source)->toBe(DataSchema::SOURCE_ENTITY) + // `id` is promoted PROTECTED — a public-properties-only scan would have lost the identifier. + ->and($schema->names())->toBe(['id', 'title', 'body', 'pinned']) + ->and($schema->identifier)->toBe('id') + ->and($this->columnOf($browser, 'plain-note', 'id')->type)->toBe(DataColumn::TYPE_INT) + ->and($this->columnOf($browser, 'plain-note', 'body')->nullable)->toBeTrue() + ->and($this->columnOf($browser, 'plain-note', 'title')->nullable)->toBeFalse() + ->and($this->columnOf($browser, 'plain-note', 'pinned')->type)->toBe(DataColumn::TYPE_BOOL); +}); + +it('excludes json from sortable columns and secrets from searchable ones', function () { + /** @var DataBrowserTestCase $this */ + $schema = $this->schemaOf($this->browser(), 'admin-record'); + + expect($schema->sortable())->not->toContain('meta') + ->and($schema->sortable())->toContain('id') + ->and($schema->searchable())->toBe(['email']) + ->and($schema->searchable())->not->toContain('api_token'); +}); + +it('hides an excluded resource from discovery and from every operation', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browser(['enabled' => true, 'writable' => true, 'exclude' => 'admin-record']); + + expect(array_map(fn ($r) => $r->slug, $browser->resources()))->toBe(['plain-note']) + ->and($browser->resource('admin-record'))->toBeNull() + ->and($browser->schema('admin-record'))->toBeNull() + ->and($browser->find('admin-record', 1))->toBeNull() + ->and($browser->list('admin-record')->error)->toBe('No such resource.') + ->and($browser->delete('admin-record', 1)->isNotFound())->toBeTrue(); +}); + +// Two bounded contexts each owning an `AdminRecord` is ordinary. A `-2` suffix would have made ONE of them +// depend on scan order, so a removal elsewhere could silently repoint a bookmarked URL at a different table. +it('qualifies BOTH sides of a slug collision instead of suffixing one', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browserOver([AdminRecordRepository::class, AltAdminRecordRepository::class]); + + $slugs = array_map(fn ($resource) => $resource->slug, $browser->resources()); + expect($slugs)->toBe([ + 'firefly-admin-tests-data-fixtures-admin-record', + 'firefly-admin-tests-data-fixtures-alt-admin-record', + ]) + ->and($slugs)->not->toContain('admin-record'); + + $labels = array_map(fn ($resource) => $resource->label, $browser->resources()); + expect($labels[0])->toBe('Admin Record (Firefly\\Admin\\Tests\\Data\\Fixtures)') + ->and($labels[1])->toBe('Admin Record (Firefly\\Admin\\Tests\\Data\\Fixtures\\Alt)') + ->and($this->resourceOf($browser, 'firefly-admin-tests-data-fixtures-alt-admin-record')->table)->toBe('alt_admin_records'); +}); + +// The catalogue is bound by the actuator's own registrar. Without it there is no discovery source, and the +// honest answer is "no resources" rather than a reflective scan that would find classes nothing wired. +it('reports no resources when no beans catalogue is bound', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browserWithoutCatalog(); + + expect($browser->isEnabled())->toBeTrue() + ->and($browser->resources())->toBe([]) + ->and($browser->resource('admin-record'))->toBeNull() + ->and($browser->list('admin-record')->error)->toBe('No such resource.'); +}); diff --git a/packages/admin/tests/Data/Fixtures/AdminRecord.php b/packages/admin/tests/Data/Fixtures/AdminRecord.php new file mode 100644 index 0000000..7962f39 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/AdminRecord.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Illuminate\Database\Eloquent\Model; + +/** + * A real Eloquent model over a real sqlite table, shaped to exercise every branch of column derivation at + * once: a name the masker catches (`api_token`), a name it does not that the MODEL hides + * (`recovery_phrase`), a `text` column that is only JSON because a cast says so, a `tinyint` that is only a + * boolean because a cast says so, and a genuine datetime. + * + * @property int $id + * @property string $email + * @property string|null $api_token + * @property string|null $recovery_phrase + * @property int $amount + * @property bool $active + * @property array<string, mixed>|null $meta + * @property string|null $created_at + */ +final class AdminRecord extends Model +{ + protected $table = 'admin_records'; + + public $timestamps = false; + + protected $guarded = []; + + /** @var list<string> */ + protected $hidden = ['recovery_phrase']; + + /** @return array<string, string> */ + protected function casts(): array + { + return ['meta' => 'array', 'active' => 'boolean']; + } +} diff --git a/packages/admin/tests/Data/Fixtures/AdminRecordRepository.php b/packages/admin/tests/Data/Fixtures/AdminRecordRepository.php new file mode 100644 index 0000000..b49d53d --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/AdminRecordRepository.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Firefly\Data\Repository\EloquentRepository; + +/** + * The ordinary shape an application declares: extend EloquentRepository, set `$model`, inherit everything. + * Nothing is overridden, so the browser is reading exactly the ports a real repository exposes. + * + * @extends EloquentRepository<AdminRecord> + */ +final class AdminRecordRepository extends EloquentRepository +{ + protected string $model = AdminRecord::class; +} diff --git a/packages/admin/tests/Data/Fixtures/Alt/AdminRecord.php b/packages/admin/tests/Data/Fixtures/Alt/AdminRecord.php new file mode 100644 index 0000000..3393c17 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/Alt/AdminRecord.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures\Alt; + +use Illuminate\Database\Eloquent\Model; + +/** + * A SECOND class called AdminRecord, in a different namespace, over a different table. Two bounded contexts + * each owning an `AdminRecord` is unremarkable in a real application, and it is the case that breaks a naive + * short-name slug. + * + * @property int $id + * @property string $label + */ +final class AdminRecord extends Model +{ + protected $table = 'alt_admin_records'; + + public $timestamps = false; + + protected $guarded = []; +} diff --git a/packages/admin/tests/Data/Fixtures/Alt/AdminRecordRepository.php b/packages/admin/tests/Data/Fixtures/Alt/AdminRecordRepository.php new file mode 100644 index 0000000..ad98f5f --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/Alt/AdminRecordRepository.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures\Alt; + +use Firefly\Data\Repository\EloquentRepository; + +/** + * @extends EloquentRepository<AdminRecord> + */ +final class AdminRecordRepository extends EloquentRepository +{ + protected string $model = AdminRecord::class; +} diff --git a/packages/admin/tests/Data/Fixtures/BrokenRepository.php b/packages/admin/tests/Data/Fixtures/BrokenRepository.php new file mode 100644 index 0000000..c002b78 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/BrokenRepository.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Firefly\Data\Repository\CrudRepository; +use RuntimeException; + +/** + * A repository the catalogue knows about and the container cannot build — a constructor that wanted a + * connection this deployment never configured. Discovery must still list it (the binding is real), and every + * operation on it must degrade to a stated reason rather than to a 500. + * + * It also declines to narrow its return types, so RepositoryIntrospector can infer no entity from it: `object` + * is what the base signature already says and carries no information. The resource is therefore named after + * the repository class with the conventional `Repository` suffix stripped. + * + * @implements CrudRepository<object, mixed> + */ +final class BrokenRepository implements CrudRepository +{ + public function __construct() + { + throw new RuntimeException('The [reporting] connection is not configured.'); + } + + public function save(object $entity): object + { + return $entity; + } + + /** + * @param iterable<object> $entities + * @return list<object> + */ + public function saveAll(iterable $entities): array + { + return array_values(is_array($entities) ? $entities : iterator_to_array($entities, false)); + } + + public function findById(mixed $id): ?object + { + return null; + } + + /** @return list<object> */ + public function findAll(): array + { + return []; + } + + /** + * @param iterable<mixed> $ids + * @return list<object> + */ + public function findAllById(iterable $ids): array + { + return []; + } + + public function existsById(mixed $id): bool + { + return false; + } + + public function count(): int + { + return 0; + } + + public function delete(object $entity): void {} + + public function deleteById(mixed $id): void {} + + public function deleteAll(): void {} +} diff --git a/packages/admin/tests/Data/Fixtures/GhostRecord.php b/packages/admin/tests/Data/Fixtures/GhostRecord.php new file mode 100644 index 0000000..69f3e10 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/GhostRecord.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Illuminate\Database\Eloquent\Model; + +/** + * A model whose table is not there — a migration that has not run yet, or a connection pointed at the wrong + * database. The browser still knows the key name, so the resource degrades to a key-only listing that says + * where its columns came from, instead of disappearing from the menu with no explanation. + * + * @property int $id + */ +final class GhostRecord extends Model +{ + protected $table = 'ghost_records'; + + public $timestamps = false; + + protected $guarded = []; +} diff --git a/packages/admin/tests/Data/Fixtures/GhostRecordRepository.php b/packages/admin/tests/Data/Fixtures/GhostRecordRepository.php new file mode 100644 index 0000000..d523d57 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/GhostRecordRepository.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Firefly\Data\Repository\EloquentRepository; + +/** + * @extends EloquentRepository<GhostRecord> + */ +final class GhostRecordRepository extends EloquentRepository +{ + protected string $model = GhostRecord::class; +} diff --git a/packages/admin/tests/Data/Fixtures/Money.php b/packages/admin/tests/Data/Fixtures/Money.php new file mode 100644 index 0000000..e016c53 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/Money.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Stringable; + +/** An ordinary value object: printable, but only through __toString. */ +final readonly class Money implements Stringable +{ + public function __construct(public string $amount, public string $currency) {} + + public function __toString(): string + { + return $this->amount.' '.$this->currency; + } +} diff --git a/packages/admin/tests/Data/Fixtures/NotARepository.php b/packages/admin/tests/Data/Fixtures/NotARepository.php new file mode 100644 index 0000000..a7d8775 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/NotARepository.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +/** An ordinary bean. It must never appear in the browsable-resource list. */ +final class NotARepository +{ + public function handle(): string + { + return 'nothing to browse here'; + } +} diff --git a/packages/admin/tests/Data/Fixtures/OrphanRecord.php b/packages/admin/tests/Data/Fixtures/OrphanRecord.php new file mode 100644 index 0000000..0e6447a --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/OrphanRecord.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Illuminate\Database\Eloquent\Model; + +/** + * A model pinned to a connection this deployment never configured — a read replica named in a shared config + * but absent from a developer's `.env`. Asking it for its columns throws; the browser still knows its key + * name, so the resource degrades rather than disappearing. + * + * @property int $id + */ +final class OrphanRecord extends Model +{ + protected $connection = 'no-such-connection'; + + protected $table = 'orphan_records'; + + public $timestamps = false; + + protected $guarded = []; +} diff --git a/packages/admin/tests/Data/Fixtures/OrphanRecordRepository.php b/packages/admin/tests/Data/Fixtures/OrphanRecordRepository.php new file mode 100644 index 0000000..cb95560 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/OrphanRecordRepository.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Firefly\Data\Repository\EloquentRepository; + +/** + * @extends EloquentRepository<OrphanRecord> + */ +final class OrphanRecordRepository extends EloquentRepository +{ + protected string $model = OrphanRecord::class; +} diff --git a/packages/admin/tests/Data/Fixtures/Pair.php b/packages/admin/tests/Data/Fixtures/Pair.php new file mode 100644 index 0000000..b54d098 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/Pair.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +/** + * An entity that names its key nothing the identifier preference order recognises, so the browser refuses to + * address a single row of it rather than improvising a WHERE clause. + */ +final class Pair +{ + public function __construct(public string $left, public string $right) {} +} diff --git a/packages/admin/tests/Data/Fixtures/PairRepository.php b/packages/admin/tests/Data/Fixtures/PairRepository.php new file mode 100644 index 0000000..c9ee9b4 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/PairRepository.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Firefly\Data\Repository\CrudRepository; +use Illuminate\Support\Facades\DB; + +/** + * A real repository over a real table whose entity has no derivable identifier. + * + * @implements CrudRepository<Pair, string> + */ +final class PairRepository implements CrudRepository +{ + public const string TABLE = 'pairs'; + + public function save(object $entity): Pair + { + DB::table(self::TABLE)->updateOrInsert(['left' => $entity->left], ['right' => $entity->right]); + + return $entity; + } + + /** + * @param iterable<Pair> $entities + * @return list<Pair> + */ + public function saveAll(iterable $entities): array + { + $saved = []; + foreach ($entities as $entity) { + $saved[] = $this->save($entity); + } + + return $saved; + } + + public function findById(mixed $id): ?Pair + { + $row = DB::table(self::TABLE)->where('left', '=', $id)->first(); + + return $row === null ? null : self::hydrate($row); + } + + /** @return list<Pair> */ + public function findAll(): array + { + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->orderBy('left')->get()->all())); + } + + /** + * @param iterable<string> $ids + * @return list<Pair> + */ + public function findAllById(iterable $ids): array + { + $list = is_array($ids) ? array_values($ids) : iterator_to_array($ids, false); + + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->whereIn('left', $list)->get()->all())); + } + + public function existsById(mixed $id): bool + { + return DB::table(self::TABLE)->where('left', '=', $id)->exists(); + } + + public function count(): int + { + return DB::table(self::TABLE)->count(); + } + + public function delete(object $entity): void + { + $this->deleteById($entity->left); + } + + public function deleteById(mixed $id): void + { + DB::table(self::TABLE)->where('left', '=', $id)->delete(); + } + + public function deleteAll(): void + { + DB::table(self::TABLE)->delete(); + } + + private static function hydrate(object $row): Pair + { + $data = (array) $row; + $left = $data['left'] ?? null; + $right = $data['right'] ?? null; + + return new Pair(is_string($left) ? $left : '', is_string($right) ? $right : ''); + } +} diff --git a/packages/admin/tests/Data/Fixtures/PlainNote.php b/packages/admin/tests/Data/Fixtures/PlainNote.php new file mode 100644 index 0000000..caec726 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/PlainNote.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +/** + * A plain entity — no Eloquent, no framework base class — whose fields exist ONLY as promoted constructor + * parameters, and whose identifier is promoted PROTECTED. That is the shape `Firefly\Domain\Entity` gives + * every DDD aggregate in the framework, and a column scan restricted to public properties would find `title`, + * `body` and `pinned` while silently losing the key the detail view and delete both address rows by. + */ +final class PlainNote +{ + public function __construct( + protected int $id, + public string $title, + public ?string $body = null, + public bool $pinned = false, + ) {} + + public function id(): int + { + return $this->id; + } +} diff --git a/packages/admin/tests/Data/Fixtures/PlainNoteRepository.php b/packages/admin/tests/Data/Fixtures/PlainNoteRepository.php new file mode 100644 index 0000000..416f807 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/PlainNoteRepository.php @@ -0,0 +1,119 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Firefly\Data\Repository\CrudRepository; +use Illuminate\Support\Facades\DB; + +/** + * A REAL repository against the REAL sqlite connection that implements CrudRepository and NOTHING else — no + * paging, no sorting, no specification seam. It is the shape that forces DataQueryEngine down its + * `findAll()`-then-slice-in-PHP fallback, and it is deliberately not a mock: the fallback's whole point is + * that it works over a genuine repository whose interface simply cannot express a limit, and a doubled + * `findAll()` returning three hand-built objects would prove nothing about that. + * + * `findById()` narrows its return type to PlainNote, which is also how RepositoryIntrospector discovers the + * entity class of a repository that has no `$model` — the same covariant-return trick the framework's own + * lumen sample uses. + * + * @implements CrudRepository<PlainNote, int> + */ +final class PlainNoteRepository implements CrudRepository +{ + public const string TABLE = 'admin_notes'; + + /** + * The parameter stays `object` because PHP's contravariance rule forbids narrowing an implementation's + * parameter below the interface's; `@implements CrudRepository<PlainNote, int>` is what binds it to + * PlainNote for the type checker, which is why no instanceof guard appears here. + */ + public function save(object $entity): PlainNote + { + DB::table(self::TABLE)->updateOrInsert( + ['id' => $entity->id()], + ['title' => $entity->title, 'body' => $entity->body, 'pinned' => $entity->pinned], + ); + + return $entity; + } + + /** + * @param iterable<PlainNote> $entities + * @return list<PlainNote> + */ + public function saveAll(iterable $entities): array + { + $saved = []; + foreach ($entities as $entity) { + $saved[] = $this->save($entity); + } + + return $saved; + } + + public function findById(mixed $id): ?PlainNote + { + $row = DB::table(self::TABLE)->where('id', '=', $id)->first(); + + return $row === null ? null : self::hydrate($row); + } + + /** @return list<PlainNote> */ + public function findAll(): array + { + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->orderBy('id')->get()->all())); + } + + /** + * @param iterable<int> $ids + * @return list<PlainNote> + */ + public function findAllById(iterable $ids): array + { + $list = is_array($ids) ? array_values($ids) : iterator_to_array($ids, false); + + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->whereIn('id', $list)->orderBy('id')->get()->all())); + } + + public function existsById(mixed $id): bool + { + return DB::table(self::TABLE)->where('id', '=', $id)->exists(); + } + + public function count(): int + { + return DB::table(self::TABLE)->count(); + } + + public function delete(object $entity): void + { + $this->deleteById($entity->id()); + } + + public function deleteById(mixed $id): void + { + DB::table(self::TABLE)->where('id', '=', $id)->delete(); + } + + public function deleteAll(): void + { + DB::table(self::TABLE)->delete(); + } + + private static function hydrate(object $row): PlainNote + { + $data = (array) $row; + $id = $data['id'] ?? null; + $title = $data['title'] ?? null; + $body = $data['body'] ?? null; + + return new PlainNote( + is_numeric($id) ? (int) $id : 0, + is_string($title) ? $title : '', + is_string($body) ? $body : null, + (bool) ($data['pinned'] ?? false), + ); + } +} diff --git a/packages/admin/tests/Data/Fixtures/ScopedNoteRepository.php b/packages/admin/tests/Data/Fixtures/ScopedNoteRepository.php new file mode 100644 index 0000000..731a72a --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/ScopedNoteRepository.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use Firefly\Data\Repository\CrudRepository; +use Illuminate\Support\Facades\DB; + +/** + * A real repository whose DELETE is scoped and whose reads are not — an archive policy that only removes + * pinned notes. It is the ordinary shape (a soft-delete scope, a tenant guard, an override that swallows the + * call) in which `deleteById()` returns void having done nothing at all, and the reason DataBrowser::delete() + * verifies the removal with `existsById()` instead of trusting it. + * + * @implements CrudRepository<PlainNote, int> + */ +final class ScopedNoteRepository implements CrudRepository +{ + public const string TABLE = 'admin_notes'; + + public function save(object $entity): PlainNote + { + DB::table(self::TABLE)->updateOrInsert( + ['id' => $entity->id()], + ['title' => $entity->title, 'body' => $entity->body, 'pinned' => $entity->pinned], + ); + + return $entity; + } + + /** + * @param iterable<PlainNote> $entities + * @return list<PlainNote> + */ + public function saveAll(iterable $entities): array + { + $saved = []; + foreach ($entities as $entity) { + $saved[] = $this->save($entity); + } + + return $saved; + } + + public function findById(mixed $id): ?PlainNote + { + $row = DB::table(self::TABLE)->where('id', '=', $id)->first(); + + return $row === null ? null : self::hydrate($row); + } + + /** @return list<PlainNote> */ + public function findAll(): array + { + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->orderBy('id')->get()->all())); + } + + /** + * @param iterable<int> $ids + * @return list<PlainNote> + */ + public function findAllById(iterable $ids): array + { + $list = is_array($ids) ? array_values($ids) : iterator_to_array($ids, false); + + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->whereIn('id', $list)->get()->all())); + } + + public function existsById(mixed $id): bool + { + return DB::table(self::TABLE)->where('id', '=', $id)->exists(); + } + + public function count(): int + { + return DB::table(self::TABLE)->count(); + } + + public function delete(object $entity): void + { + $this->deleteById($entity->id()); + } + + /** Only a pinned note is archivable; anything else is silently left where it is. */ + public function deleteById(mixed $id): void + { + DB::table(self::TABLE)->where('id', '=', $id)->where('pinned', '=', 1)->delete(); + } + + public function deleteAll(): void + { + DB::table(self::TABLE)->where('pinned', '=', 1)->delete(); + } + + private static function hydrate(object $row): PlainNote + { + $data = (array) $row; + $id = $data['id'] ?? null; + $title = $data['title'] ?? null; + $body = $data['body'] ?? null; + + return new PlainNote( + is_numeric($id) ? (int) $id : 0, + is_string($title) ? $title : '', + is_string($body) ? $body : null, + (bool) ($data['pinned'] ?? false), + ); + } +} diff --git a/packages/admin/tests/Data/Fixtures/Widget.php b/packages/admin/tests/Data/Fixtures/Widget.php new file mode 100644 index 0000000..1125d14 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/Widget.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use DateTimeImmutable; +use stdClass; + +/** + * A plain entity holding one field of every kind the projector has to reduce to something a template can + * print: a datetime, a backed enum, an array, a value object that is only printable through __toString, and + * an object that is not printable at all. + * + * Its key is `uuid`, not `id` — the second entry in the identifier preference order, and the reason that + * order exists. + */ +final class Widget +{ + /** @param list<string> $tags */ + public function __construct( + public string $uuid, + public string $name, + public DateTimeImmutable $occurredAt, + public WidgetStatus $status, + public array $tags, + public Money $price, + public stdClass $opaque, + ) {} +} diff --git a/packages/admin/tests/Data/Fixtures/WidgetRepository.php b/packages/admin/tests/Data/Fixtures/WidgetRepository.php new file mode 100644 index 0000000..264305b --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/WidgetRepository.php @@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +use DateTimeImmutable; +use Firefly\Data\Repository\CrudRepository; +use Illuminate\Support\Facades\DB; +use RuntimeException; +use stdClass; + +/** + * A real sqlite-backed CrudRepository whose entity carries typed PHP values rather than column scalars — + * the shape that exercises every branch of the projector's normalisation. + * + * @implements CrudRepository<Widget, string> + */ +final class WidgetRepository implements CrudRepository +{ + public const string TABLE = 'widgets'; + + public function save(object $entity): Widget + { + DB::table(self::TABLE)->updateOrInsert(['uuid' => $entity->uuid], [ + 'name' => $entity->name, + 'occurred_at' => $entity->occurredAt->format('Y-m-d H:i:s'), + 'status' => $entity->status->value, + 'tags' => (string) json_encode($entity->tags), + 'price' => (string) $entity->price, + ]); + + return $entity; + } + + /** + * @param iterable<Widget> $entities + * @return list<Widget> + */ + public function saveAll(iterable $entities): array + { + $saved = []; + foreach ($entities as $entity) { + $saved[] = $this->save($entity); + } + + return $saved; + } + + public function findById(mixed $id): ?Widget + { + $row = DB::table(self::TABLE)->where('uuid', '=', $id)->first(); + + return $row === null ? null : self::hydrate($row); + } + + /** @return list<Widget> */ + public function findAll(): array + { + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->orderBy('uuid')->get()->all())); + } + + /** + * @param iterable<string> $ids + * @return list<Widget> + */ + public function findAllById(iterable $ids): array + { + $list = is_array($ids) ? array_values($ids) : iterator_to_array($ids, false); + + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->whereIn('uuid', $list)->get()->all())); + } + + public function existsById(mixed $id): bool + { + return DB::table(self::TABLE)->where('uuid', '=', $id)->exists(); + } + + public function count(): int + { + return DB::table(self::TABLE)->count(); + } + + public function delete(object $entity): void + { + $this->deleteById($entity->uuid); + } + + public function deleteById(mixed $id): void + { + DB::table(self::TABLE)->where('uuid', '=', $id)->delete(); + } + + public function deleteAll(): void + { + DB::table(self::TABLE)->delete(); + } + + private static function hydrate(object $row): Widget + { + $data = (array) $row; + + $tags = json_decode(self::text($data, 'tags'), true); + $price = explode(' ', self::text($data, 'price')); + + return new Widget( + self::text($data, 'uuid'), + self::text($data, 'name'), + new DateTimeImmutable(self::text($data, 'occurred_at')), + WidgetStatus::from(self::text($data, 'status')), + is_array($tags) ? array_values(array_map(static fn (mixed $t): string => is_string($t) ? $t : '', $tags)) : [], + new Money($price[0], $price[1] ?? 'EUR'), + new stdClass, + ); + } + + /** @param array<array-key, mixed> $data */ + private static function text(array $data, string $key): string + { + $value = $data[$key] ?? null; + + return is_string($value) ? $value : throw new RuntimeException("Column [{$key}] is not text."); + } +} diff --git a/packages/admin/tests/Data/Fixtures/WidgetStatus.php b/packages/admin/tests/Data/Fixtures/WidgetStatus.php new file mode 100644 index 0000000..9b7eaca --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/WidgetStatus.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Fixtures; + +/** A backed enum, which is what an entity field of enum type actually holds after hydration. */ +enum WidgetStatus: string +{ + case Draft = 'draft'; + case Live = 'live'; +} diff --git a/packages/admin/tests/Data/Support/DataBrowserTestCase.php b/packages/admin/tests/Data/Support/DataBrowserTestCase.php new file mode 100644 index 0000000..3e19b4e --- /dev/null +++ b/packages/admin/tests/Data/Support/DataBrowserTestCase.php @@ -0,0 +1,255 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Data\Support; + +use Firefly\Actuator\Introspection\BeansCatalog; +use Firefly\Admin\Data\DataBrowser; +use Firefly\Admin\Data\DataColumn; +use Firefly\Admin\Data\DataRecord; +use Firefly\Admin\Data\DataResource; +use Firefly\Admin\Data\DataSchema; +use Firefly\Admin\Tests\Data\Fixtures\AdminRecordRepository; +use Firefly\Admin\Tests\Data\Fixtures\NotARepository; +use Firefly\Admin\Tests\Data\Fixtures\PlainNoteRepository; +use Firefly\Config\Config; +use Firefly\Testing\FireflyDatabaseTestCase; +use Illuminate\Config\Repository; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use RuntimeException; + +/** + * Everything the data-browser tests share: two REAL tables on the shared sqlite `:memory:` connection, two + * REAL repositories over them, and a BeansCatalog built the way ActuatorRouteRegistrar builds the live one. + * + * The catalogue rows are assembled with `class_implements()` because that is literally what ComponentScanner + * records at scan time — so the discovery these tests exercise is fed the same interface closure the running + * application's catalogue carries, not a hand-picked list that happens to contain the interface discovery is + * looking for. + */ +abstract class DataBrowserTestCase extends FireflyDatabaseTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Schema::create('admin_records', function (Blueprint $table): void { + $table->increments('id'); + $table->string('email'); + $table->string('api_token')->nullable(); + $table->string('recovery_phrase')->nullable(); + $table->integer('amount'); + $table->boolean('active')->default(true); + $table->text('meta')->nullable(); + $table->dateTime('created_at')->nullable(); + }); + + Schema::create('admin_notes', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->string('title'); + $table->text('body')->nullable(); + $table->boolean('pinned')->default(false); + }); + + // The second AdminRecord's table, for the slug-collision case. Its model declares NO casts, so its + // column types are whatever the driver alone reports — which is what proves the schema path carries + // its own weight rather than leaning on a cast for every non-string column. + Schema::create('alt_admin_records', function (Blueprint $table): void { + $table->increments('id'); + $table->string('label'); + $table->boolean('archived')->default(false); + $table->integer('rank')->default(0); + }); + + // A typed-value entity's table, and a table whose entity has no derivable identifier. + Schema::create('widgets', function (Blueprint $table): void { + $table->string('uuid')->primary(); + $table->string('name'); + $table->dateTime('occurred_at'); + $table->string('status'); + $table->text('tags'); + $table->string('price'); + }); + + Schema::create('pairs', function (Blueprint $table): void { + $table->string('left')->primary(); + $table->string('right'); + }); + } + + /** + * Seed the Eloquent-backed table. The `o'brien` address is not decoration: it is the row a search for + * `o'brien` has to find, which only happens if the term reached the driver as a BINDING. + */ + protected function seedRecords(): void + { + $rows = [ + ['id' => 1, 'email' => 'ada@example.test', 'api_token' => 'sk_live_ada_secret', 'recovery_phrase' => 'correct horse battery', 'amount' => 50, 'active' => 1, 'meta' => '{"tier":"gold"}', 'created_at' => '2026-01-01 10:00:00'], + ['id' => 2, 'email' => "o'brien@example.test", 'api_token' => null, 'recovery_phrase' => null, 'amount' => 150, 'active' => 1, 'meta' => null, 'created_at' => '2026-01-02 10:00:00'], + ['id' => 3, 'email' => 'grace@example.test', 'api_token' => 'sk_live_grace_secret', 'recovery_phrase' => null, 'amount' => 250, 'active' => 0, 'meta' => '{"tier":"silver"}', 'created_at' => '2026-01-03 10:00:00'], + ['id' => 4, 'email' => 'linus@example.test', 'api_token' => null, 'recovery_phrase' => null, 'amount' => 350, 'active' => 1, 'meta' => null, 'created_at' => '2026-01-04 10:00:00'], + ['id' => 5, 'email' => 'edsger@example.test', 'api_token' => null, 'recovery_phrase' => null, 'amount' => 450, 'active' => 0, 'meta' => null, 'created_at' => '2026-01-05 10:00:00'], + ]; + + DB::table('admin_records')->insert($rows); + } + + protected function seedWidgets(): void + { + DB::table('widgets')->insert([ + ['uuid' => 'w-1', 'name' => 'Sprocket', 'occurred_at' => '2026-02-01 09:30:00', 'status' => 'live', 'tags' => '["a","b"]', 'price' => '10.10 EUR'], + ['uuid' => 'w-2', 'name' => 'Cog', 'occurred_at' => '2026-02-02 09:30:00', 'status' => 'draft', 'tags' => '[]', 'price' => '0.00 EUR'], + ]); + } + + protected function seedPairs(): void + { + DB::table('pairs')->insert([ + ['left' => 'alpha', 'right' => 'one'], + ['left' => 'beta', 'right' => 'two'], + ]); + } + + protected function seedNotes(): void + { + DB::table('admin_notes')->insert([ + ['id' => 1, 'title' => 'Alpha', 'body' => 'first note', 'pinned' => 1], + ['id' => 2, 'title' => 'Beta', 'body' => 'second note', 'pinned' => 0], + ['id' => 3, 'title' => 'Gamma', 'body' => null, 'pinned' => 0], + ]); + } + + /** + * A browser over the default fixture catalogue. + * + * @param array<string, mixed> $data the `firefly.admin.data.*` subtree, dot-free + */ + protected function browser(array $data = ['enabled' => true]): DataBrowser + { + return $this->browserOver( + [AdminRecordRepository::class, PlainNoteRepository::class, NotARepository::class], + $data, + ); + } + + /** + * A browser over an arbitrary catalogue — the seam the slug-collision test needs. + * + * @param list<class-string> $classes + * @param array<string, mixed> $data + */ + protected function browserOver(array $classes, array $data = ['enabled' => true]): DataBrowser + { + $this->app()->instance(BeansCatalog::class, new BeansCatalog(array_map($this->row(...), $classes))); + + return DataBrowser::forContainer( + $this->app(), + new Config(new Repository(['firefly' => ['admin' => ['data' => $data]]])), + ); + } + + /** + * A browser over a catalogue whose rows carry a HAND-WRITTEN interface list rather than one derived from + * the classes themselves. That is not a contrivance: BeansCatalog is a compiled snapshot, and a snapshot + * taken before a refactor can name a class that no longer implements what the row says it does. + * + * @param array<class-string, list<class-string>> $classes bean class => the interfaces the row claims + * @param array<string, mixed> $data + */ + protected function browserOverStaleCatalog(array $classes, array $data = ['enabled' => true]): DataBrowser + { + $rows = []; + foreach ($classes as $class => $interfaces) { + $rows[] = [ + 'class' => $class, + 'stereotype' => 'repository', + 'scope' => 'Singleton', + 'name' => null, + 'interfaces' => $interfaces, + 'beans' => [], + ]; + } + + $this->app()->instance(BeansCatalog::class, new BeansCatalog($rows)); + + return DataBrowser::forContainer( + $this->app(), + new Config(new Repository(['firefly' => ['admin' => ['data' => $data]]])), + ); + } + + /** A browser with NO catalogue bound at all — the deployment where the actuator is switched off. */ + protected function browserWithoutCatalog(): DataBrowser + { + $this->app()->forgetInstance(BeansCatalog::class); + + return DataBrowser::forContainer( + $this->app(), + new Config(new Repository(['firefly' => ['admin' => ['data' => ['enabled' => true]]]])), + ); + } + + /** + * One catalogue row exactly as ActuatorRouteRegistrar publishes it. + * + * @param class-string $class + * @return array{class: string, stereotype: string, scope: string, name: string|null, interfaces: list<class-string>, beans: list<string>} + */ + private function row(string $class): array + { + return [ + 'class' => $class, + 'stereotype' => str_ends_with($class, 'Repository') ? 'repository' : 'service', + 'scope' => 'Singleton', + 'name' => null, + 'interfaces' => array_values(class_implements($class) ?: []), + 'beans' => [], + ]; + } + + /** + * Non-null accessors for the four nullable lookups the browser exposes. + * + * Every one of them returns null for a real reason the tests elsewhere assert on (switched off, unknown + * slug, no identifier), so the nullability is not an accident to be papered over. These exist so that a + * test whose SUBJECT is the returned value reads as a chain of assertions rather than as a null check + * followed by assertions — and so a lookup that unexpectedly returns null fails on the line that asked + * for it, naming what it asked for, instead of on a "property on null" ten lines later. + */ + protected function resourceOf(DataBrowser $browser, string $slug): DataResource + { + return $browser->resource($slug) ?? throw new RuntimeException("No browsable resource [{$slug}]."); + } + + protected function schemaOf(DataBrowser $browser, string $slug): DataSchema + { + return $browser->schema($slug) ?? throw new RuntimeException("No schema for resource [{$slug}]."); + } + + protected function columnOf(DataBrowser $browser, string $slug, string $column): DataColumn + { + return $this->schemaOf($browser, $slug)->column($column) + ?? throw new RuntimeException("No column [{$column}] on resource [{$slug}]."); + } + + protected function recordOf(DataBrowser $browser, string $slug, int|string $id): DataRecord + { + return $browser->find($slug, $id) ?? throw new RuntimeException("No record [{$id}] of resource [{$slug}]."); + } + + /** + * One cell of a listing row, proven to be a string. The rows are `array<string, mixed>` by construction — + * a column's PHP type depends on the driver — so a test asserting on the text of a cell says so here. + * + * @param array<string, mixed> $row + */ + protected function stringCell(array $row, string $column): string + { + $value = $row[$column] ?? null; + + return is_string($value) ? $value : throw new RuntimeException("Column [{$column}] is not a string."); + } +} diff --git a/packages/admin/tests/DataBrowserOffTest.php b/packages/admin/tests/DataBrowserOffTest.php new file mode 100644 index 0000000..b3cbc89 --- /dev/null +++ b/packages/admin/tests/DataBrowserOffTest.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +use Firefly\Admin\Tests\Support\DataBrowserOffTestCase; + +uses(DataBrowserOffTestCase::class); + +/** + * The data browser reads the records behind an application's repositories, which is a far bigger disclosure + * than beans or configuration — so it stays off even when the rest of the dashboard is on, and off means + * every shape answers the same way rather than some paths 404ing while others render. + */ +it('is hidden from the menu', function () { + /** @var DataBrowserOffTestCase $this */ + $response = $this->get('/firefly'); + + $response->assertStatus(200); + expect($response->getContent())->not->toContain('Browse data'); +}); + +it('404s every read shape', function (string $path) { + /** @var DataBrowserOffTestCase $this */ + $this->get($path)->assertStatus(404); +})->with([ + '/firefly/data', + '/firefly/data?resource=order', + '/firefly/data?resource=order&id=1', +]); + +// A write must answer exactly as a read does. Redirecting instead would tell the caller the request was +// understood and merely declined, which is a different fact from "this does not exist". +it('404s a write rather than redirecting', function (string $op) { + /** @var DataBrowserOffTestCase $this */ + $this->post('/firefly/data', ['resource' => 'order', 'id' => '1', 'op' => $op])->assertStatus(404); +})->with(['delete', 'update']); + +it('says how to switch it on rather than pretending nothing is there', function () { + /** @var DataBrowserOffTestCase $this */ + $this->get('/firefly/data') + ->assertSee('firefly.admin.data.enabled', false) + ->assertSee('firefly.admin.data.writable', false); +}); diff --git a/packages/admin/tests/DataBrowserPageTest.php b/packages/admin/tests/DataBrowserPageTest.php new file mode 100644 index 0000000..3685834 --- /dev/null +++ b/packages/admin/tests/DataBrowserPageTest.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +use Firefly\Admin\Tests\Support\DataBrowserTestCase; + +uses(DataBrowserTestCase::class); + +/** + * The dashboard's half of the data browser: routing, the nav entry, and that the page renders what the + * backend returns. The backend's own gates are proven in packages/admin/tests/Data/; these prove the UI + * cannot reach past them. + */ +it('offers the data browser in the menu when it is switched on', function () { + /** @var DataBrowserTestCase $this */ + $this->get('/firefly')->assertStatus(200)->assertSee('Browse data', false); +}); + +it('serves the resource index', function () { + /** @var DataBrowserTestCase $this */ + $this->get('/firefly/data')->assertStatus(200)->assertSee('Resources', false); +}); + +// A slug nothing declared must not render a broken listing. +it('answers a listing for an unknown resource without leaking a stack trace', function () { + /** @var DataBrowserTestCase $this */ + $response = $this->get('/firefly/data?resource=nope'); + + $response->assertStatus(200); + expect($response->getContent())->not->toContain('Stack trace'); +}); + +it('404s a record on an unknown resource', function () { + /** @var DataBrowserTestCase $this */ + $this->get('/firefly/data?resource=nope&id=1')->assertStatus(404); +}); diff --git a/packages/admin/tests/Support/DataBrowserOffTestCase.php b/packages/admin/tests/Support/DataBrowserOffTestCase.php new file mode 100644 index 0000000..927941a --- /dev/null +++ b/packages/admin/tests/Support/DataBrowserOffTestCase.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Support; + +/** The default posture: the dashboard on, the data browser off. */ +abstract class DataBrowserOffTestCase extends DataBrowserTestCase +{ + protected function dataEnabled(): bool + { + return false; + } +} diff --git a/packages/admin/tests/Support/DataBrowserTestCase.php b/packages/admin/tests/Support/DataBrowserTestCase.php new file mode 100644 index 0000000..fc9a34d --- /dev/null +++ b/packages/admin/tests/Support/DataBrowserTestCase.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Admin\Tests\Support; + +use Illuminate\Foundation\Application; + +/** The dashboard with the data browser switched ON and writes allowed. */ +abstract class DataBrowserTestCase extends AdminCapstoneTestCase +{ + /** @return array<string, mixed> */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.admin.data.enabled' => $this->dataEnabled(), + 'firefly.admin.data.writable' => $this->dataWritable(), + ]; + } + + protected function dataEnabled(): bool + { + return true; + } + + protected function dataWritable(): bool + { + return true; + } + + protected function defineFireflyEnvironment(Application $app): void + { + parent::defineFireflyEnvironment($app); + } +} diff --git a/packages/cli/src/Command/Make/MakeControllerCommand.php b/packages/cli/src/Command/Make/MakeControllerCommand.php index fa4201d..fee0d50 100644 --- a/packages/cli/src/Command/Make/MakeControllerCommand.php +++ b/packages/cli/src/Command/Make/MakeControllerCommand.php @@ -5,10 +5,42 @@ namespace Firefly\Cli\Command\Make; use Illuminate\Console\GeneratorCommand; +use Illuminate\Support\Str; +use Symfony\Component\Console\Input\InputOption; /** - * `make:firefly-controller` — scaffolds a #[RestController] with a sample #[GetMapping] action into - * app/Http (pyfly's `generate controller` parity). + * `make:firefly-controller` — scaffolds a FULL REST resource into app/Http: a #[RestController] with the five + * actions `artisan make:controller --resource` gives a Laravel developer (index/show/store/update/destroy), + * plus the request DTO its `store`/`update` bodies bind. `--plain` falls back to the single-action shape. + * + * WHAT WAS WRONG. The generator emitted ONE action, `index()`, mapped to `#[GetMapping('/{{ class }}')]` — + * the PHP class name substituted straight into a URL. `make:firefly-controller OrderController` therefore + * produced a route at `/OrderController`: capitalised, singular, and carrying the word "Controller" in the + * path. That is not a URL anyone ships, so the first thing every developer did with the framework's own + * scaffold was delete what it had just written. Worse, it set the expectation that a Firefly controller IS a + * single action, when the framework has verb attributes for the whole REST surface and an argument resolver + * built to bind validated request bodies into it. The scaffold was advertising a fraction of the framework. + * + * WHAT REPLACES IT, AND WHY THE DTO IS PART OF IT. A REST resource whose base path is derived from the + * resource name (OrderController -> /orders, OrderItemController -> /order-items, PersonController -> + * /people), declared once as a class-level #[RequestMapping] so each action carries only its own suffix. The + * `store`/`update` actions take a `#[Valid] #[RequestBody]` DTO, and that DTO is GENERATED ALONGSIDE the + * controller — exactly as `make:firefly-handler` generates the command class its handler's `handle()` takes, + * and for a stronger reason than symmetry: the controller names the DTO type in its signature, and + * `firefly:cache` reflects every controller parameter to compile its binding plan. A generated controller + * whose request type did not exist would not merely be incomplete, it would be a file PHP cannot load — so + * emitting the controller without the DTO would reproduce, in the web layer, precisely the "scaffold that + * poisons the next `firefly:cache`" defect that MakeHandlerCommand was rewritten to fix. + * + * WHY `--plain` STILL EXISTS. Not every #[RestController] is a collection of things. Webhook receivers, + * probes, report endpoints and RPC-shaped action verbs are single-action controllers with no member id and + * no request DTO, and they are common enough that making the CRUD resource the ONLY output would mean + * deleting four methods and a whole second file every time. `--plain` is the escape hatch, and it is a flag + * rather than the default because the resource is the shape the framework most needs to demonstrate and the + * one a developer coming from `make:controller --resource` expects. It is named `--plain` rather than + * `--api` deliberately: Laravel's `--api` means "a resource controller MINUS create/edit", which is a + * distinction that only exists in a framework with HTML form routes — Firefly's resource has no create/edit + * actions to remove, so borrowing the name would promise a difference that is not there. */ final class MakeControllerCommand extends GeneratorCommand { @@ -16,14 +48,27 @@ final class MakeControllerCommand extends GeneratorCommand protected $name = 'make:firefly-controller'; /** @var string */ - protected $description = 'Create a Firefly #[RestController].'; + protected $description = 'Create a Firefly #[RestController] REST resource and its request DTO (or a single action with --plain).'; /** @var string */ protected $type = 'Firefly controller'; + /** + * Set for the duration of the request-DTO emission so buildClass() picks the DTO stub instead of the + * controller stub. GeneratorCommand offers no per-call stub argument — buildClass() always asks + * getStub() — so this one-field override is the seam for writing a second file through the parent's own + * namespace/class replacement machinery rather than re-implementing it. (Same technique, same reason, as + * MakeHandlerCommand::$messageStub.) + */ + private ?string $requestStub = null; + protected function getStub(): string { - return __DIR__.'/../../../stubs/controller.stub'; + if ($this->requestStub !== null) { + return $this->requestStub; + } + + return __DIR__.'/../../../stubs/'.($this->option('plain') ? 'controller.stub' : 'controller-resource.stub'); } /** @@ -38,4 +83,148 @@ protected function getDefaultNamespace($rootNamespace): string { return $rootNamespace.'\\Http'; } + + /** + * Writes the controller (parent) and then, unless `--plain`, its request DTO. A false return from the + * parent means it refused — reserved name, or the controller already exists — and in that case nothing + * else is written, so a re-run never drops a stray DTO next to code it did not generate. + */ + public function handle(): ?bool + { + if (parent::handle() === false) { + return false; + } + + if (! $this->option('plain')) { + $this->writeRequestClass(); + } + + return null; + } + + /** + * Emits the request DTO next to the controller, in the same namespace. An existing file is left ALONE + * and reported: the developer has almost certainly already filled it with the real properties and + * constraints, and the controller that was just generated references it by name either way, so reusing + * it is the correct outcome. + */ + private function writeRequestClass(): void + { + $name = $this->qualifiedRequestClass(); + $path = $this->getPath($name); + + if ($this->files->exists($path)) { + $this->components->info(sprintf('Firefly request DTO [%s] already exists; reusing it.', $path)); + + return; + } + + $this->requestStub = __DIR__.'/../../../stubs/controller-request.stub'; + + try { + $this->makeDirectory($path); + $this->files->put($path, $this->sortImports($this->buildClass($name))); + } finally { + $this->requestStub = null; + } + + $this->components->info(sprintf('Firefly request DTO [%s] created successfully.', $path)); + } + + /** + * Substitutes the three placeholders the parent knows nothing about, on top of its `{{ class }}`: + * `{{ resourcePath }}` (the derived collection path), `{{ request }}` (the DTO's SHORT name — controller + * and DTO share a namespace and need no import) and `{{ controller }}` (the controller's short name, so + * the DTO's docblock can name the class that binds it). + * + * MUST keep both parameters UNTYPED. The parent declares `replaceClass($stub, $name)` with no parameter + * types, and PHP's contravariance rule requires an override's parameters to be at least as broad — a + * native `string` here would be a FATAL at class-load time (the same trap getDefaultNamespace() + * documents above). + * + * @param string $stub + * @param string $name + */ + protected function replaceClass($stub, $name): string + { + $stub = parent::replaceClass($stub, $name); + + return str_replace( + ['{{ resourcePath }}', '{{resourcePath}}', '{{ request }}', '{{request}}', '{{ controller }}', '{{controller}}'], + [$this->resourcePath(), $this->resourcePath(), $this->shortRequestClass(), $this->shortRequestClass(), $this->shortControllerClass(), $this->shortControllerClass()], + $stub, + ); + } + + /** + * The collection path: the resource name kebab-cased and then pluralised — OrderController -> `orders`, + * OrderItemController -> `order-items`, PersonController -> `people`, CategoryController -> `categories`. + * + * Kebab BEFORE plural on purpose. Str::plural inflects the last word of what it is given, so handing it + * the already-hyphenated `order-item` lets it see `item` as the trailing word; the two orders happen to + * agree on every name tried, but this one keeps the inflector's input in the lowercase, single-word shape + * its irregular-noun tables are written for. + * + * Only the SHORT class name is used, so `make:firefly-controller Admin/OrderController` still serves + * `/orders` — the PHP sub-namespace is a code-organisation choice and has never implied a URL prefix in + * this framework (a URL prefix is what the class-level #[RequestMapping] the scaffold writes is for, and + * it is one edit away). + */ + private function resourcePath(): string + { + return Str::plural(Str::kebab($this->resourceName())); + } + + /** + * The resource name behind the controller: a trailing "Controller" stripped (OrderController -> Order), + * or the name as typed when there is nothing to strip (Orders -> Orders). The length guard keeps a class + * literally named `Controller` from collapsing to the empty string and producing the path `/`. + */ + private function resourceName(): string + { + $short = $this->shortControllerClass(); + + return str_ends_with($short, 'Controller') && strlen($short) > strlen('Controller') + ? substr($short, 0, -strlen('Controller')) + : $short; + } + + private function shortControllerClass(): string + { + $segments = explode('\\', str_replace('/', '\\', ltrim($this->getNameInput(), '\\/'))); + + return (string) end($segments); + } + + /** + * The DTO's short name: the resource plus "Request" (OrderController -> OrderRequest). It can never + * collide with the controller's own name — the two differ by the stripped "Controller" suffix, and a + * name with no suffix to strip still differs by the appended "Request" — which matters because a + * collision would make the two files fight over one path. + */ + private function shortRequestClass(): string + { + return $this->resourceName().'Request'; + } + + /** + * The DTO's fully-qualified name, derived from the controller name the developer typed so that a nested + * `make:firefly-controller Admin/OrderController` puts both files in the same sub-namespace. + */ + private function qualifiedRequestClass(): string + { + $segments = explode('\\', str_replace('/', '\\', ltrim($this->getNameInput(), '\\/'))); + array_pop($segments); + $segments[] = $this->shortRequestClass(); + + return $this->qualifyClass(implode('\\', $segments)); + } + + /** @return list<array{0: string, 1: string|null, 2: int, 3: string}> */ + protected function getOptions(): array + { + return [ + ['plain', null, InputOption::VALUE_NONE, 'Generate a single-action controller with no request DTO.'], + ]; + } } diff --git a/packages/cli/stubs/controller-request.stub b/packages/cli/stubs/controller-request.stub new file mode 100644 index 0000000..e240a83 --- /dev/null +++ b/packages/cli/stubs/controller-request.stub @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace {{ namespace }}; + +use Firefly\Validation\Constraint\NotBlank; +use Firefly\Validation\Constraint\Size; + +/** + * The request body {{ controller }} accepts when creating or replacing a member. + */ +final readonly class {{ class }} +{ + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED AS THE SCHEMA DESCRIPTION in /openapi.json; line comments + // like this one are not. Describe the payload up there for the people who will send it; keep notes like + // these down here. + // + // WHY THIS FILE EXISTS AT ALL, AND WHY IT ARRIVED WITH THE CONTROLLER. + // + // {{ controller }} names this type in its signature, and `firefly:cache` reflects every controller + // parameter to compile its binding plan — so a controller generated without its request type would not + // merely be incomplete, it would be a file PHP cannot load. A DTO must live in its own PSR-4 file to be + // autoloadable, so it cannot be appended to the controller's own file. + // + // REPLACE THESE TWO PROPERTIES with the real ones. Two things are worth keeping when you do: + // + // * The constraints are the whole contract, and one declaration has three consumers. `firefly:cache` + // compiles them into constraints.php; the ArgumentResolver runs them BEFORE hydration, so an invalid + // body is a 422 with per-field errors and never reaches the controller; and firefly/openapi reads the + // same compiled rules to emit `required`, `maxLength` and friends into the schema. There is no + // FormRequest and no parallel rules() array to keep in step. + // * `?string $description` is nullable ON PURPOSE. Firefly applies Jakarta's null contract — a property + // whose declared type admits null gets Laravel's `nullable` prepended — so `{"description": null}` and + // an omitted key behave identically. Drop the `?` and an explicit null becomes a 422 while omitting + // the key still passes: two spellings of "no value" with opposite outcomes. + // + // A nested DTO (`#[Valid] public AddressPayload $shipTo`) and a list of them (an `array` property with a + // `@param list<LinePayload> $lines` tag on this constructor) both hydrate and both validate. They are + // not scaffolded here only because the generator cannot invent their domain; the skeleton application's + // OrderRequest is a worked example of both shapes. + + public function __construct( + #[NotBlank] + #[Size(max: 255)] + public string $name, + #[Size(max: 2000)] + public ?string $description = null, + ) {} +} diff --git a/packages/cli/stubs/controller-resource.stub b/packages/cli/stubs/controller-resource.stub new file mode 100644 index 0000000..968d3fd --- /dev/null +++ b/packages/cli/stubs/controller-resource.stub @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +namespace {{ namespace }}; + +use Firefly\Validation\Valid; +use Firefly\Web\Attributes\DeleteMapping; +use Firefly\Web\Attributes\GetMapping; +use Firefly\Web\Attributes\PathVariable; +use Firefly\Web\Attributes\PostMapping; +use Firefly\Web\Attributes\PutMapping; +use Firefly\Web\Attributes\QueryParam; +use Firefly\Web\Attributes\RequestBody; +use Firefly\Web\Attributes\RequestMapping; +use Firefly\Web\Attributes\RestController; + +/** + * The `{{ resourcePath }}` resource: list, read, create, replace and delete. + */ +#[RestController] +#[RequestMapping('/{{ resourcePath }}')] +final class {{ class }} +{ + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED, THIS COMMENT IS NOT. firefly/openapi uses a controller's + // class docblock as the tag description in /openapi.json and each action's docblock as that operation's + // summary and description, so anything written there is read by whoever consumes this API. Describe the + // resource up there; keep notes like these down here, where the generator never sees them. + // + // WHAT THIS SCAFFOLD ALREADY DOES, AND WHAT IS LEFT FOR YOU. + // + // The routing is complete. One class-level #[RequestMapping] fixes the collection path — derived from + // the class name, kebab-cased and pluralised, and written in exactly one place, so rename it freely — + // and each action adds only its own suffix and verb. `firefly:cache` compiles all five into the + // RouteManifest. Nothing goes in routes/web.php. `php artisan firefly:routes` lists them. + // + // The METHOD BODIES are the part to replace. They return correctly-shaped responses so the resource + // answers from the moment it is generated, but nothing here persists anything. Inject a #[Service] + // through the constructor and delegate to it. Throwing + // Firefly\Kernel\Exception\Business\ResourceNotFoundException from that service is how show/update/delete + // turn an unknown id into an RFC-7807 404, with no error handling in this class at all. + + /** + * List the collection, one page at a time. + * + * @return array<string, mixed> + */ + #[GetMapping(name: '{{ resourcePath }}.index')] + public function index( + // #[QueryParam] CARRIES ITS DEFAULT TWICE, and that is not redundant. RouteScanner compiles the + // binding's fallback from the ATTRIBUTE, never from the PHP default value: a parameter default is + // not reachable from the compiled, reflection-free plan the ArgumentResolver reads per request. + // Write only `int $page = 1` and an absent `?page` binds null, which then fails against the `int` in + // this very signature — a 500 for a request that merely omitted an optional parameter. The PHP + // default is kept beside it so the signature still reads honestly to a human and to static analysis. + #[QueryParam(default: 1)] int $page = 1, + #[QueryParam(default: 20)] int $size = 20, + ): array { + return ['page' => $page, 'size' => $size, 'total' => 0, 'items' => []]; + } + + /** + * Read one member of the collection by id. + * + * @return array<string, mixed> + */ + #[GetMapping('/{id}', name: '{{ resourcePath }}.show')] + public function show(#[PathVariable] int $id): array + { + // #[PathVariable] binds AND coerces: the URL segment is a string, this parameter is an int. + return ['id' => $id]; + } + + /** + * Create a new member. Responds 201 with the created representation. + * + * @return array<string, mixed> + */ + #[PostMapping(status: 201, name: '{{ resourcePath }}.store')] + public function store(#[Valid] #[RequestBody] {{ request }} $request): array + { + // #[Valid] runs {{ request }}'s compiled constraints BEFORE the DTO is hydrated, so an invalid body + // is a 422 carrying per-field errors and never reaches this line. The 201 is declared on the + // mapping; nothing here builds a response by hand. + return ['id' => 1, 'name' => $request->name, 'description' => $request->description]; + } + + /** + * Replace a member wholesale. Takes the same body as `store`. + * + * @return array<string, mixed> + */ + #[PutMapping('/{id}', name: '{{ resourcePath }}.update')] + public function update(#[PathVariable] int $id, #[Valid] #[RequestBody] {{ request }} $request): array + { + // The same DTO as `store` on purpose: a PUT that accepted a laxer shape than the POST is how a + // resource ends up with two contradictory schemas in its own OpenAPI document. + return ['id' => $id, 'name' => $request->name, 'description' => $request->description]; + } + + /** + * Delete a member. Responds 204 with an empty body. + */ + #[DeleteMapping('/{id}', status: 204, name: '{{ resourcePath }}.destroy')] + public function destroy(#[PathVariable] int $id): void + { + // A `void` action returns null, which at the mapping's 204 is written as an empty body. + } +} diff --git a/packages/cli/stubs/controller.stub b/packages/cli/stubs/controller.stub index fb7273f..b5dc628 100644 --- a/packages/cli/stubs/controller.stub +++ b/packages/cli/stubs/controller.stub @@ -7,11 +7,30 @@ namespace {{ namespace }}; use Firefly\Web\Attributes\GetMapping; use Firefly\Web\Attributes\RestController; +/** + * The `{{ resourcePath }}` endpoint. + */ #[RestController] final class {{ class }} { - /** @return array<string, mixed> */ - #[GetMapping('/{{ class }}')] + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED, THIS COMMENT IS NOT: firefly/openapi uses a controller's + // class docblock as the tag description in /openapi.json and each action's docblock as that operation's + // summary and description. + // + // The `--plain` shape: ONE action, for the endpoints that are not a collection of things — a webhook + // receiver, a probe of your own, a report, an RPC-shaped action verb. `make:firefly-controller` WITHOUT + // `--plain` generates the five-action REST resource and its request DTO instead, which is what most + // endpoints want. + // + // The path was DERIVED from the class name ({{ class }} -> /{{ resourcePath }}), not copied from it; + // rename it to whatever this endpoint actually is. + + /** + * Handle the request. + * + * @return array<string, mixed> + */ + #[GetMapping('/{{ resourcePath }}', name: '{{ resourcePath }}.index')] public function index(): array { return []; diff --git a/packages/cli/tests/Command/Make/GeneratedStubIntegrityTest.php b/packages/cli/tests/Command/Make/GeneratedStubIntegrityTest.php index 50d9486..6b49580 100644 --- a/packages/cli/tests/Command/Make/GeneratedStubIntegrityTest.php +++ b/packages/cli/tests/Command/Make/GeneratedStubIntegrityTest.php @@ -19,6 +19,10 @@ use Firefly\Domain\Entity; use Firefly\Eda\Scanner\EventListenerScanner; use Firefly\Messaging\Scanner\MessageListenerScanner; +use Firefly\Validation\Constraint\ConstraintManifest; +use Firefly\Validation\Constraint\ConstraintScanner; +use Firefly\Web\Route\RouteDescriptor; +use Firefly\Web\Route\RouteManifest; use Firefly\Web\Route\RouteScanner; /** @@ -60,6 +64,29 @@ GeneratedApp::clean(); }); +/** + * The `store` route of whichever resource controller currently sits in the generated app. + * + * A named helper rather than an inline loop in three tests: the scan returns a list, so every caller would + * otherwise carry the same `RouteDescriptor|null` that PHPStan (level max) rightly refuses to dereference. + * Throwing here also makes "the generator emitted no store action at all" fail with a sentence rather than + * with a null-property access several assertions later. + * + * A class-based helper (the ArtisanAssertions/GeneratedApp convention) is unnecessary for a function this + * local, but the name still has to be globally unique: the whole monorepo suite runs in ONE PHPUnit process, + * so a second file declaring `storeAction()` would fatal with "Cannot redeclare function". + */ +function storeAction(): RouteDescriptor +{ + foreach ((new RouteScanner)->scan(GeneratedApp::psr4()) as $route) { + if ($route->methodName === 'store') { + return $route; + } + } + + throw new RuntimeException('no generated controller exposes a store action.'); +} + it('generates a #[CommandHandler] whose message type firefly:cache can actually resolve', function (): void { /** @var MakeCommandsTestCase $this */ ArtisanAssertions::exitCode($this->artisan('make:firefly-handler', ['name' => 'StubCommandHandler']), 0); @@ -192,22 +219,161 @@ public function __construct(public string $name) {} 'message listener' => ['StubMessageListener', ['--message' => true], 'message'], ]); -it('generates a controller the component scan and the route scan both see', function (): void { +it('generates a controller whose five REST actions the route scan compiles onto a derived path', function (): void { /** @var MakeCommandsTestCase $this */ - ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'StubController']), 0); + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'StubOrderController']), 0); - GeneratedApp::lint(GeneratedApp::path().'/Http/StubController.php'); + // BOTH files, and both syntactically valid: the controller names the DTO in its signature, so a + // controller emitted alone would be a file PHP cannot even load once the scanners reflect it. + GeneratedApp::lint(GeneratedApp::path().'/Http/StubOrderController.php'); + GeneratedApp::lint(GeneratedApp::path().'/Http/StubOrderRequest.php'); + // Exactly ONE bean: the controller. A request DTO carries no stereotype by design — it is hydrated per + // request from the body, never injected, and registering it as a singleton would be actively wrong. $components = (new ComponentScanner)->scan(GeneratedApp::psr4()); expect($components)->toHaveCount(1); - expect($components[0]->class)->toBe('App\\Http\\StubController') + expect($components[0]->class)->toBe('App\\Http\\StubOrderController') ->and($components[0]->stereotype)->toBe('restcontroller'); + $routes = (new RouteScanner)->scan(GeneratedApp::psr4()); + $actual = []; + foreach ($routes as $route) { + $actual[$route->methodName] = [$route->httpMethod, $route->path, $route->status]; + } + + // The whole point of the rewrite: five actions on a plural, kebab-cased path derived from the resource + // name — NOT one action mapped to `/StubOrderController`, which is what this scaffold used to emit. + expect($actual)->toBe([ + 'index' => ['GET', '/stub-orders', 200], + 'show' => ['GET', '/stub-orders/{id}', 200], + 'store' => ['POST', '/stub-orders', 201], + 'update' => ['PUT', '/stub-orders/{id}', 200], + 'destroy' => ['DELETE', '/stub-orders/{id}', 204], + ]); +}); + +it('compiles a request-body binding plan the argument resolver can actually hydrate', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'StubOrderController']), 0); + + $body = null; + foreach (storeAction()->bindings as $binding) { + if ($binding['kind'] === 'body') { + $body = $binding; + } + } + + // A `body` binding at all is the assertion that matters. RouteScanner classifies an un-attributed class + // parameter as a container SERVICE; only #[RequestBody] makes it a body, and only #[Valid] makes the + // resolver run the compiled constraints before hydrating. `properties` is the constructor plan the + // reflection-free resolver unpacks by name — an empty list there means the DTO type did not resolve. + if ($body === null) { + throw new RuntimeException('the generated store action has no #[RequestBody] binding.'); + } + + expect($body['type'])->toBe('App\\Http\\StubOrderRequest') + ->and($body['valid'])->toBeTrue() + ->and($body['required'])->toBeTrue() + ->and($body['properties'])->toBe(['name', 'description']); +}); + +it('generates a request DTO whose constraints the validation compiler turns into real rules', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'StubOrderController']), 0); + + // The same call ManifestCacheWriter::writeManifests() makes for constraints.php. A DTO whose attributes + // compiled to nothing would still lint, still hydrate, and silently accept any body at all. + $rules = (new ConstraintScanner)->scan('App\\Http\\StubOrderRequest'); + + expect(array_keys($rules))->toBe(['name', 'description']); + expect($rules['name'])->toContain('required'); + + // `?string $description` admits null, so Jakarta's null contract applies: `nullable` is prepended and an + // explicit `{"description": null}` behaves exactly like an omitted key. + expect($rules['description'][0])->toBe('nullable'); +}); + +it('derives the collection path by kebab-casing and pluralising the resource name', function (string $class, string $path): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => $class]), 0); + + $paths = []; + foreach ((new RouteScanner)->scan(GeneratedApp::psr4()) as $route) { + $paths[$route->methodName] = $route->path; + } + + expect($paths['index'])->toBe($path); +})->with([ + 'simple noun' => ['StubOrderController', '/stub-orders'], + 'compound noun' => ['StubOrderItemController', '/stub-order-items'], + 'irregular plural' => ['StubPersonController', '/stub-people'], + 'consonant + y' => ['StubCategoryController', '/stub-categories'], +]); + +it('keeps the controller and its request DTO in the same sub-namespace', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'Widget/StubNestedController']), 0); + + GeneratedApp::lint(GeneratedApp::path().'/Http/Widget/StubNestedController.php'); + GeneratedApp::lint(GeneratedApp::path().'/Http/Widget/StubNestedRequest.php'); + + $routes = (new RouteScanner)->scan(GeneratedApp::psr4()); + expect($routes)->toHaveCount(5); + expect($routes[0]->controllerClass)->toBe('App\\Http\\Widget\\StubNestedController'); + + // A PHP sub-namespace is a code-organisation choice and has never implied a URL prefix here, so the + // derived path is still the bare collection — the class-level #[RequestMapping] is the one place to + // change that. + foreach ($routes as $route) { + expect($route->path)->toStartWith('/stub-nesteds'); + } +}); + +it('never overwrites a request DTO the developer already wrote', function (): void { + /** @var MakeCommandsTestCase $this */ + $existing = <<<'PHP' + <?php + + declare(strict_types=1); + + namespace App\Http; + + final readonly class StubKeptRequest + { + public function __construct(public string $name = '', public ?string $description = null) {} + } + + PHP; + if (! is_dir(GeneratedApp::path().'/Http')) { + mkdir(GeneratedApp::path().'/Http', 0o755, true); + } + file_put_contents(GeneratedApp::path().'/Http/StubKeptRequest.php', $existing); + + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'StubKeptController']), 0); + + expect((string) file_get_contents(GeneratedApp::path().'/Http/StubKeptRequest.php'))->toBe($existing); + + // And the controller that was just generated still binds it — reusing the developer's own DTO is the + // correct outcome, not a second file with a mangled name. + expect(storeAction()->bindings[0]['type'])->toBe('App\\Http\\StubKeptRequest'); +}); + +it('generates a single-action controller with no DTO under --plain', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'StubPlainController', '--plain' => true]), 0); + + GeneratedApp::lint(GeneratedApp::path().'/Http/StubPlainController.php'); + + // The escape hatch for the endpoints that are not a collection: no request DTO is written at all. + expect(is_file(GeneratedApp::path().'/Http/StubPlainRequest.php'))->toBeFalse(); + $routes = (new RouteScanner)->scan(GeneratedApp::psr4()); expect($routes)->toHaveCount(1); - expect($routes[0]->controllerClass)->toBe('App\\Http\\StubController') + expect($routes[0]->controllerClass)->toBe('App\\Http\\StubPlainController') ->and($routes[0]->methodName)->toBe('index') - ->and($routes[0]->httpMethod)->toBe('GET'); + ->and($routes[0]->httpMethod)->toBe('GET') + // Even the single-action shape gets a real path: it used to be `/StubPlainController`. + ->and($routes[0]->path)->toBe('/stub-plains'); }); it('generates plain stereotypes the component scan registers', function (string $command, string $name, string $stereotype): void { @@ -285,7 +451,7 @@ public function __construct(public string $name) {} // handler/event/message/scheduled/security/transactional artifacts were never written at all. ArtisanAssertions::exitCode($this->artisan('firefly:cache'), 0); - foreach ([FireflyCachePaths::COMPONENT, FireflyCachePaths::ROUTES, FireflyCachePaths::HANDLERS, FireflyCachePaths::EVENT_LISTENERS, FireflyCachePaths::MESSAGE_LISTENERS, FireflyCachePaths::TRANSACTIONAL, FireflyCachePaths::PROXY_MAP] as $basename) { + foreach ([FireflyCachePaths::COMPONENT, FireflyCachePaths::ROUTES, FireflyCachePaths::CONSTRAINTS, FireflyCachePaths::HANDLERS, FireflyCachePaths::EVENT_LISTENERS, FireflyCachePaths::MESSAGE_LISTENERS, FireflyCachePaths::TRANSACTIONAL, FireflyCachePaths::PROXY_MAP] as $basename) { expect(is_file($dir.'/'.$basename))->toBeTrue("expected firefly:cache to write {$basename}"); } @@ -300,6 +466,29 @@ public function __construct(public string $name) {} ->toContain('App\\CompiledRepository') ->toContain('App\\CompiledEventListener') ->toContain('App\\CompiledMessageListener'); + + // The generated REST resource compiled whole: all five actions in the route manifest, and the + // request DTO that the two body-taking actions reference in the CONSTRAINT manifest. The second half + // is the one that would silently rot — a DTO whose attributes failed to compile still lints, still + // hydrates, and quietly accepts anything a client sends. + $routes = RouteManifest::load($dir.'/'.FireflyCachePaths::ROUTES)->all(); + $resource = []; + foreach ($routes as $route) { + if ($route->controllerClass === 'App\\Http\\CompiledController') { + $resource[$route->methodName] = $route->httpMethod.' '.$route->path; + } + } + expect($resource)->toBe([ + 'index' => 'GET /compileds', + 'show' => 'GET /compileds/{id}', + 'store' => 'POST /compileds', + 'update' => 'PUT /compileds/{id}', + 'destroy' => 'DELETE /compileds/{id}', + ]); + + expect(ConstraintManifest::load($dir.'/'.FireflyCachePaths::CONSTRAINTS)->rulesFor('App\\Http\\CompiledRequest')) + ->toHaveKey('name') + ->toHaveKey('description'); } finally { foreach (glob($dir.'/*.php') ?: [] as $file) { unlink($file); diff --git a/packages/cli/tests/Command/Make/MakeCommandsTest.php b/packages/cli/tests/Command/Make/MakeCommandsTest.php index c166c84..00f9423 100644 --- a/packages/cli/tests/Command/Make/MakeCommandsTest.php +++ b/packages/cli/tests/Command/Make/MakeCommandsTest.php @@ -16,8 +16,10 @@ // which lands under app_path('Http/') (MakeControllerCommand::getDefaultNamespace() appends \Http). // Clean both locations after every test so the shared testbench workbench app/ dir stays pristine // across the whole single-process suite run. -// `make:firefly-handler` writes TWO files — the handler and the message class its handle() takes — so the -// glob below sweeps up DemoCommand.php / DemoQuery.php alongside the handlers themselves. +// `make:firefly-handler` writes TWO files — the handler and the message class its handle() takes — and +// `make:firefly-controller` likewise writes the controller AND the request DTO its store/update actions +// bind, so the globs below sweep up DemoCommand.php / DemoQuery.php / DemoRequest.php alongside the +// classes that were actually named on the command line. afterEach(function (): void { array_map('unlink', glob(app_path('*.php')) ?: []); array_map('unlink', glob(app_path('Http/*.php')) ?: []); @@ -25,7 +27,10 @@ dataset('generators', [ // command, name, relative generated path, expected needle in the generated file's contents. - 'controller' => ['make:firefly-controller', 'DemoController', 'Http/DemoController.php', '#[RestController]'], + // The controller scaffold is now a five-action REST resource, not a single index() mapped to the class + // name as a URL; the route table and the derived path are asserted behaviourally in + // GeneratedStubIntegrityTest, and the DTO it writes alongside has its own case below. + 'controller' => ['make:firefly-controller', 'DemoController', 'Http/DemoController.php', '#[RequestMapping('], 'service' => ['make:firefly-service', 'DemoService', 'DemoService.php', '#[Service]'], 'component' => ['make:firefly-component', 'DemoComponent', 'DemoComponent.php', '#[Component]'], 'handler (command)' => ['make:firefly-handler', 'DemoCommandHandler', 'DemoCommandHandler.php', '#[CommandHandler]'], @@ -71,3 +76,28 @@ expect((string) file_get_contents(app_path('DemoMessageListener.php')))->toContain("#[MessageListener('"); }); + +it('generates the request DTO alongside the controller', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'DemoOrderController']), 0); + + // Named from the resource, not from the controller: DemoOrderController -> DemoOrderRequest, in the same + // namespace so the controller needs no import for it. + expect((string) file_get_contents(app_path('Http/DemoOrderRequest.php'))) + ->toContain('final readonly class DemoOrderRequest') + ->toContain('#[NotBlank]') + ->toContain('#[Size(max: 255)]'); + + expect((string) file_get_contents(app_path('Http/DemoOrderController.php'))) + ->toContain('DemoOrderRequest $request'); +}); + +it('generates a single-action controller and no DTO under --plain', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-controller', ['name' => 'DemoPlainController', '--plain' => true]), 0); + + expect(is_file(app_path('Http/DemoPlainRequest.php')))->toBeFalse(); + expect((string) file_get_contents(app_path('Http/DemoPlainController.php'))) + ->toContain('#[RestController]') + ->not->toContain('#[RequestMapping('); +}); diff --git a/packages/cli/tests/Skeleton/SkeletonExampleTest.php b/packages/cli/tests/Skeleton/SkeletonExampleTest.php new file mode 100644 index 0000000..9022f6c --- /dev/null +++ b/packages/cli/tests/Skeleton/SkeletonExampleTest.php @@ -0,0 +1,272 @@ +<?php + +declare(strict_types=1); + +use Firefly\Cli\Cache\FireflyCachePaths; +use Firefly\Cli\Tests\Support\SkeletonApp; +use Firefly\Cli\Tests\Support\SkeletonExampleTestCase; +use Firefly\Container\Descriptor\ComponentDescriptor; +use Firefly\Container\Scanner\ComponentManifest; +use Firefly\Web\Route\RouteDescriptor; +use Firefly\Web\Route\RouteManifest; + +/** + * The shipped example, under the default gate. + * + * `composer create-project firefly/skeleton` hands a developer skeleton/app and then runs `firefly:cache` + * over it. Until now nothing in the monorepo's default suite compiled that directory or served a single one + * of its routes: the skeleton is its own composer package, its tests/ live outside every configured suite, + * and CreateProjectOfflineTest — the only test that touched it — is in the excluded `createproject` group + * and asserted merely that a routes manifest FILE had been written. The example could have degraded to a + * 500 on every endpoint with every gate still green. + * + * So this file compiles skeleton/app with the real ManifestCacheWriter (what `firefly:cache` runs) and then + * drives the sample CRUD resource over the real HTTP pipeline: routing, path/query binding and coercion, + * #[RequestBody] hydration of a NESTED DTO and a LIST of DTOs, the #[Valid] cascade's 422, the service's + * RFC-7807 404, and the declared 201/204 statuses. + */ +uses(SkeletonExampleTestCase::class); + +// Registered at file-load, before any test runs: every Firefly scanner discovers classes through +// class_exists(), and the monorepo's composer autoloader maps `App\` at a Pint directory that does not +// exist — so without this the compile below would produce empty manifests and every assertion would pass +// for entirely the wrong reason. +SkeletonApp::register(); + +it('compiles the shipped skeleton app with the real firefly:cache writer', function (): void { + $dir = (string) SkeletonExampleTestCase::$cacheDir; + + foreach ([FireflyCachePaths::COMPONENT, FireflyCachePaths::CONTEXT, FireflyCachePaths::ROUTES, FireflyCachePaths::CONSTRAINTS, FireflyCachePaths::CONFIG_PROPERTIES] as $basename) { + expect(is_file($dir.'/'.$basename))->toBeTrue("expected the skeleton compile to write {$basename}"); + } + + // Read the artifacts back through the framework's own loaders — the same call the cached boot makes. + $components = array_map( + static fn (ComponentDescriptor $d): string => $d->class, + ComponentManifest::load($dir.'/'.FireflyCachePaths::COMPONENT)->components, + ); + + expect($components) + ->toContain('App\\Http\\OrderController') + ->toContain('App\\Orders\\OrderService') + ->toContain('App\\Orders\\OrderRepository'); + + // The DTOs carry no stereotype and must NOT become beans — they are hydrated per request, not injected. + expect($components) + ->not->toContain('App\\Http\\OrderRequest') + ->not->toContain('App\\Http\\AddressPayload') + ->not->toContain('App\\Http\\OrderLinePayload'); +}); + +it('compiles all five REST actions of the sample resource into the route manifest', function (): void { + $routes = RouteManifest::load((string) SkeletonExampleTestCase::$cacheDir.'/'.FireflyCachePaths::ROUTES)->all(); + + $orders = []; + foreach ($routes as $route) { + if ($route->controllerClass === 'App\\Http\\OrderController') { + $orders[$route->methodName] = $route->httpMethod.' '.$route->path; + } + } + + // The base path is the class-level #[RequestMapping], joined with each action's own suffix — and it is + // the plural, kebab-cased collection path, never the class name. + expect($orders)->toBe([ + 'index' => 'GET /orders', + 'show' => 'GET /orders/{id}', + 'store' => 'POST /orders', + 'update' => 'PUT /orders/{id}', + 'destroy' => 'DELETE /orders/{id}', + ]); + + $statuses = []; + foreach ($routes as $route) { + if ($route->controllerClass === 'App\\Http\\OrderController') { + $statuses[$route->methodName] = $route->status; + } + } + + // 201 and 204 are declared on the mapping; nothing in the controller builds a response by hand. + expect($statuses['store'])->toBe(201) + ->and($statuses['destroy'])->toBe(204) + ->and($statuses['index'])->toBe(200); +}); + +it('serves the whole CRUD lifecycle over the real HTTP pipeline', function (): void { + /** @var SkeletonExampleTestCase $this */ + + // CREATE — 201, and the nested DTO plus the list of DTOs both hydrated: `total` is computed from the + // OrderLine objects the resolver built, so a raw sub-array reaching the domain would show up here. + $created = $this->postJson('/orders', SkeletonApp::orderBody()); + $created->assertStatus(201) + ->assertJsonPath('customer', 'Ada Lovelace') + ->assertJsonPath('shipTo.city', 'London') + ->assertJsonPath('lines.0.sku', 'WIDGET-1') + ->assertJsonPath('total', 22.25); + + // Narrowed rather than merely asserted: the id is threaded into four URLs below, and a null there would + // otherwise surface as a confusing 404 instead of "the create response carried no id". + $id = $created->json('id'); + if (! is_int($id)) { + throw new RuntimeException('the created order came back without an integer id.'); + } + + // READ — the id came back through a #[PathVariable] and was COERCED from the URL string to an int. + $this->getJson('/orders/'.$id) + ->assertOk() + ->assertJsonPath('id', $id) + ->assertJsonPath('email', 'ada@example.com'); + + // LIST — paged, with both #[QueryParam]s bound and coerced from their query-string form. + $this->getJson('/orders?page=1&size=5') + ->assertOk() + ->assertJsonPath('page', 1) + ->assertJsonPath('size', 5) + ->assertJsonPath('total', 1) + ->assertJsonPath('items.0.id', $id); + + // REPLACE — the same DTO as store, so the same validation applies to both. + $this->putJson('/orders/'.$id, SkeletonApp::orderBody(['customer' => 'Grace Hopper'])) + ->assertOk() + ->assertJsonPath('id', $id) + ->assertJsonPath('customer', 'Grace Hopper'); + + // DELETE — 204 with an EMPTY body, from a `void` action and a status declared on the mapping. + $this->deleteJson('/orders/'.$id)->assertNoContent(); + + $this->getJson('/orders/'.$id)->assertStatus(404); +}); + +it('omits both paging parameters without a 500 — the #[QueryParam] default trap', function (): void { + /** @var SkeletonExampleTestCase $this */ + // A #[QueryParam]'s fallback is compiled from the ATTRIBUTE, never from the PHP default value. Without + // `default:` on the attribute an absent `?page` binds null and dies against the `int` in the signature, + // which is a 500 for a request that merely omitted an optional parameter. + $this->getJson('/orders') + ->assertOk() + ->assertJsonPath('page', 1) + ->assertJsonPath('size', 20); +}); + +it('clamps an oversized ?size to the controller\'s own maximum', function (): void { + /** @var SkeletonExampleTestCase $this */ + // MAX_PAGE_SIZE is the only thing standing between `?size=100000` and pushing the whole store through + // one response. The echoed `size` is the CLAMPED value the service was actually called with, so this + // fails the moment the clamp is dropped from the action. + $this->getJson('/orders?size=100000') + ->assertOk() + ->assertJsonPath('size', 100); + + // The lower bound too: a nonsensical page or size is floored at 1 rather than reaching array_slice as a + // negative offset. + $this->getJson('/orders?page=0&size=0') + ->assertOk() + ->assertJsonPath('page', 1) + ->assertJsonPath('size', 1); +}); + +it('pages past the first page rather than repeating it', function (): void { + /** @var SkeletonExampleTestCase $this */ + // Every other case here creates ONE order, which cannot tell a real 1-based offset from a repository + // that ignores $page entirely and always returns the head of the list. Three orders and a second page + // can. + $ids = []; + foreach (['Ada Lovelace', 'Grace Hopper', 'Alan Turing'] as $customer) { + $created = $this->postJson('/orders', SkeletonApp::orderBody(['customer' => $customer])); + $created->assertStatus(201); + $id = $created->json('id'); + if (! is_int($id)) { + throw new RuntimeException('the created order came back without an integer id.'); + } + $ids[] = $id; + } + + $this->getJson('/orders?page=1&size=2') + ->assertOk() + ->assertJsonPath('total', 3) + ->assertJsonCount(2, 'items') + ->assertJsonPath('items.0.id', $ids[0]) + ->assertJsonPath('items.1.id', $ids[1]); + + // The second page holds the REMAINDER — one row, the third id — not the first two over again. + $this->getJson('/orders?page=2&size=2') + ->assertOk() + ->assertJsonPath('total', 3) + ->assertJsonCount(1, 'items') + ->assertJsonPath('items.0.id', $ids[2]); + + // Past the end is an empty page, not a wrapped one. + $this->getJson('/orders?page=9&size=2') + ->assertOk() + ->assertJsonCount(0, 'items'); +}); + +it('rejects an invalid nested field as a 422 naming the dotted path the client sent', function (): void { + /** @var SkeletonExampleTestCase $this */ + $body = SkeletonApp::orderBody([ + 'shipTo' => [ + 'street' => '12 Analytical Way', + 'city' => 'London', + 'postcode' => '', // fails #[NotBlank] + 'country' => 'XX', // not an ISO 3166-1 alpha-2 country + ], + ]); + + // The #[Valid] cascade compiles AddressPayload's rules under dot keys, so the field errors name + // `shipTo.country` — the exact JSON path the client posted, not a flattened alias. + $response = $this->postJson('/orders', $body); + $response->assertStatus(422); + + $fields = array_column((array) $response->json('errors'), 'field'); + expect($fields)->toContain('shipTo.country')->toContain('shipTo.postcode'); +}); + +it('rejects a missing required body field as a 422 rather than a bind failure', function (): void { + /** @var SkeletonExampleTestCase $this */ + $body = SkeletonApp::orderBody(); + unset($body['lines']); + + // #[NotEmpty] emits Laravel's implicit `required`; a rule OBJECT such as #[Size] is skipped for an + // absent key, so without the implicit constraint this body would sail past validation and fail in the + // DTO constructor as a 400 "could not bind" instead. + $response = $this->postJson('/orders', $body); + $response->assertStatus(422); + + expect(array_column((array) $response->json('errors'), 'field'))->toContain('lines'); +}); + +it('answers an unknown order with an RFC-7807 problem document, not a bare 404', function (): void { + /** @var SkeletonExampleTestCase $this */ + // OrderService throws ResourceNotFoundException; firefly/web renders the whole FireflyException taxonomy + // as problem+json at the exception's own status. The controller contains no error handling at all. + $this->getJson('/orders/424242') + ->assertStatus(404) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'ORDER_NOT_FOUND') + ->assertJsonPath('detail', 'Order 424242 does not exist.'); +}); + +it('still serves the minimal greeting slice the tutorial is built on', function (): void { + /** @var SkeletonExampleTestCase $this */ + // #[ConfigProperties('greeting')] bound from configuration (the 'Hello' default), autowired into a + // #[Service], returned through a one-line #[RestController]. The README and the tutorial quote these + // three files verbatim, so they are load-bearing documentation as well as a sample. + $this->getJson('/greetings/Ada') + ->assertOk() + ->assertExactJson(['message' => 'Hello, Ada!']); +}); + +it('keeps every sample route on a path a developer would actually ship', function (): void { + // The generator used to emit `#[GetMapping('/OrderController')]` — the class name as a URL. Nothing in + // the shipped example may carry a path segment with a capital letter or the word "Controller" in it. + $routes = RouteManifest::load((string) SkeletonExampleTestCase::$cacheDir.'/'.FireflyCachePaths::ROUTES)->all(); + + $paths = array_map(static fn (RouteDescriptor $r): string => $r->path, $routes); + expect($paths)->not->toBeEmpty(); + + foreach ($paths as $path) { + // `{id}` placeholders are the one legal source of a non-lowercase-friendly segment, and they are + // lowercase here anyway; the assertion is on the literal segments. + expect($path)->not->toMatch('/Controller/') + ->and(preg_match('/[A-Z]/', $path))->toBe(0, "route path [{$path}] contains an upper-case segment"); + } +}); diff --git a/packages/cli/tests/Skeleton/SkeletonScannedBootTest.php b/packages/cli/tests/Skeleton/SkeletonScannedBootTest.php new file mode 100644 index 0000000..80cb593 --- /dev/null +++ b/packages/cli/tests/Skeleton/SkeletonScannedBootTest.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +use Firefly\Cli\Tests\Support\SkeletonApp; +use Firefly\Cli\Tests\Support\SkeletonScannedBootTestCase; + +/** + * The shipped skeleton on the UNCACHED boot path — what a developer gets from `artisan serve` after editing + * app/ and before re-running `firefly:cache`. + * + * SkeletonExampleTest covers the compiled path. This file exists because the two are different code: one + * requires a pure-array manifest, the other reflects at boot. The list-of-DTOs binding is the reason to care + * — its element type is recovered from a constructor DOCBLOCK, and a docblock is exactly the kind of input + * that can be present for the compiler and absent at runtime (opcache's `opcache.save_comments=0` strips + * doc comments outright, which is a real production configuration). + */ +uses(SkeletonScannedBootTestCase::class); + +SkeletonApp::register(); + +it('serves the sample resource with no compiled manifest at all', function (): void { + /** @var SkeletonScannedBootTestCase $this */ + $created = $this->postJson('/orders', SkeletonApp::orderBody()); + + $created->assertStatus(201) + ->assertJsonPath('shipTo.postcode', 'W1A 1AA') + // 22.25 is only reachable if every element of `lines` became a real OrderLinePayload: the domain + // computes the total from OrderLine objects mapped out of them. + ->assertJsonPath('total', 22.25); + + $id = $created->json('id'); + if (! is_int($id)) { + throw new RuntimeException('the created order came back without an integer id.'); + } + + $this->getJson('/orders/'.$id)->assertOk()->assertJsonPath('id', $id); + $this->deleteJson('/orders/'.$id)->assertNoContent(); + $this->getJson('/orders/'.$id)->assertStatus(404); +}); + +it('still validates the nested payload when the constraints are scanned rather than loaded', function (): void { + /** @var SkeletonScannedBootTestCase $this */ + $response = $this->postJson('/orders', SkeletonApp::orderBody([ + 'shipTo' => ['street' => '12 Analytical Way', 'city' => 'London', 'postcode' => 'W1A 1AA', 'country' => 'XX'], + ])); + + $response->assertStatus(422); + expect(array_column((array) $response->json('errors'), 'field'))->toContain('shipTo.country'); +}); + +it('serves the minimal greeting slice on the uncached path too', function (): void { + /** @var SkeletonScannedBootTestCase $this */ + // #[ConfigProperties] DTOs are only BOUND on the cached path; on this one GreetingProperties is resolved + // by plain autowiring, which lands on its constructor defaults. The skeleton ships no `greeting.*` + // configuration, so both paths must agree — and this asserts they do. + $this->getJson('/greetings/Ada') + ->assertOk() + ->assertExactJson(['message' => 'Hello, Ada!']); +}); diff --git a/packages/cli/tests/Support/SkeletonApp.php b/packages/cli/tests/Support/SkeletonApp.php new file mode 100644 index 0000000..1428a10 --- /dev/null +++ b/packages/cli/tests/Support/SkeletonApp.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Cli\Tests\Support; + +/** + * Locates and autoloads the SHIPPED skeleton application — `skeleton/app`, the example a developer receives + * from `composer create-project firefly/skeleton` — so the monorepo suite can compile it with the real + * `firefly:cache` machinery and serve its routes over the real HTTP pipeline. + * + * WHY THIS EXISTS AT ALL. The skeleton has its own tests/ directory, but nothing in the monorepo runs it: + * phpunit.xml.dist's suite covers the root `tests` directory, every package's own tests and the lumen + * sample's, and the skeleton is a + * separate composer package with no vendor/ of its own. So until now the shipped example was verified only + * by CreateProjectOfflineTest, which is in the `createproject` group — EXCLUDED from the default gate + * because it shells out to a full composer install — and which asserts only that `routes.php` exists, not + * that any route answers. A sample slice could therefore rot all the way to a 500 and every gate would stay + * green. Loading skeleton/app from here puts the shipped example under the same default gate as the + * framework itself. + * + * WHY A DEDICATED AUTOLOADER. The monorepo's composer autoloader claims `App\` for `vendor/laravel/pint/app` + * — a directory that does not even exist — so `class_exists('App\Http\OrderController')` is false without + * help, and every scanner in the framework discovers classes through `class_exists()`. Composer's loader + * returns quietly when it cannot map a class, so an appended loader still gets its turn. + * + * IT COEXISTS WITH GeneratedAppAutoloader, which claims the same `App\` prefix for testbench's `app/` + * directory (where the `make:firefly-*` generators write). Both decline silently for a file they do not + * have, so the two answer for disjoint sets of classes — which holds only as long as no generator test + * writes a class whose name collides with a skeleton one. The generator tests use `Stub*`/`Compiled*` names + * precisely so that stays true: a collision would not fail loudly, it would silently resolve to whichever + * directory won the race, so the convention is the guard. + */ +final class SkeletonApp +{ + private const string PREFIX = 'App\\'; + + private static bool $registered = false; + + /** + * The PSR-4 map every scanner is handed: the app namespace against the shipped skeleton/app. + * + * @return array<string,string> + */ + public static function psr4(): array + { + return [self::PREFIX => self::path()]; + } + + /** packages/cli/tests/Support -> tests -> cli -> packages -> the monorepo root. */ + public static function path(): string + { + return dirname(__DIR__, 4).'/skeleton/app'; + } + + /** + * A complete, VALID order body for the sample resource — the shape App\Http\OrderRequest documents, + * with a nested address and two lines. + * + * It lives on this class rather than as a global helper function in the Pest file because the whole + * monorepo suite runs in ONE PHPUnit process (phpunit.xml.dist configures neither ParaTest nor process + * isolation), so two test files declaring the same global function fatal with "Cannot redeclare + * function" — the same reasoning that produced ArtisanAssertions and GeneratedApp. + * + * @param array<string, mixed> $overrides + * @return array<string, mixed> + */ + public static function orderBody(array $overrides = []): array + { + return [ + 'customer' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'shipTo' => [ + 'street' => '12 Analytical Way', + 'city' => 'London', + 'postcode' => 'W1A 1AA', + 'country' => 'GB', + ], + 'lines' => [ + ['sku' => 'WIDGET-1', 'quantity' => 2, 'unitPrice' => 9.5], + ['sku' => 'GEAR-77', 'quantity' => 1, 'unitPrice' => 3.25], + ], + ...$overrides, + ]; + } + + /** Idempotent: several test files may call this, but the loader must only be appended once. */ + public static function register(): void + { + if (self::$registered) { + return; + } + + self::$registered = true; + + spl_autoload_register(static function (string $class): void { + if (! str_starts_with($class, self::PREFIX)) { + return; + } + + $file = self::path().'/'.str_replace('\\', '/', substr($class, strlen(self::PREFIX))).'.php'; + if (is_file($file)) { + require $file; + } + }); + } +} diff --git a/packages/cli/tests/Support/SkeletonExampleTestCase.php b/packages/cli/tests/Support/SkeletonExampleTestCase.php new file mode 100644 index 0000000..761c0a4 --- /dev/null +++ b/packages/cli/tests/Support/SkeletonExampleTestCase.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Cli\Tests\Support; + +use Firefly\Cli\Boot\FireflyCacheServiceProvider; +use Firefly\Cli\Cache\CacheReport; +use Firefly\Cli\Cache\FireflyCachePaths; +use Firefly\Cli\Cache\ManifestCacheWriter; +use Firefly\Cli\CliServiceProvider; +use Firefly\Testing\FireflyTestCase; +use Firefly\Validation\ValidationServiceProvider; +use Firefly\Web\WebServiceProvider; +use Illuminate\Support\ServiceProvider; + +/** + * Boots the SHIPPED skeleton application — skeleton/app, exactly as a developer receives it — over the + * CACHED zero-reflection path, so the monorepo's default gate proves the example actually works. + * + * The compile in setUp() is the real ManifestCacheWriter, i.e. literally what `php artisan firefly:cache` + * runs, pointed at the skeleton's own PSR-4 root. That is the assertion nobody was making: the skeleton is a + * separate composer package, its tests/ directory is not in any suite the monorepo runs, and the one test + * that did touch it (CreateProjectOfflineTest) lives in the excluded `createproject` group and only checks + * that a routes manifest file EXISTS. A sample controller could be renamed, a DTO could stop hydrating, a + * constraint could stop compiling, and every gate would still be green while `composer create-project` + * handed the next user a broken example. + * + * Booting on the CACHED path rather than letting the app scan is deliberate for the same reason: the + * skeleton's own composer.json runs `firefly:cache` in post-create-project-cmd, so the cached path is the + * one a real created application actually boots on, and it is the path where a manifest that failed to + * compile shows up as a missing route rather than as a silent fallback to reflection. + * + * Named support base (NOT an anonymous-class uses()): Pest's uses() takes a class-string, and evaluating + * `new class extends FireflyTestCase {...}::class` constructs the class immediately, which throws before + * Pest can bind it — the same fix already applied in MakeCommandsTestCase and friends. + */ +abstract class SkeletonExampleTestCase extends FireflyTestCase +{ + /** The temp dir the compiled manifests are emitted into (shared across the class' tests). */ + public static ?string $cacheDir = null; + + /** The real compile report, so a test can assert the compile itself did something. */ + public static ?CacheReport $report = null; + + protected function setUp(): void + { + SkeletonApp::register(); + + if (self::$cacheDir === null) { + self::$cacheDir = sys_get_temp_dir().'/firefly-skeleton-'.bin2hex(random_bytes(6)); + // Uncaught by design: if compiling the SHIPPED example throws, that is the headline failure and + // it must read as one, not as a cascade of "route not found" further down. + self::$report = (new ManifestCacheWriter)->write(SkeletonApp::psr4(), self::$cacheDir); + } + + parent::setUp(); + } + + public static function tearDownAfterClass(): void + { + if (self::$cacheDir !== null) { + foreach (glob(self::$cacheDir.'/'.FireflyCachePaths::PROXY_DIR.'/*.php') ?: [] as $file) { + unlink($file); + } + @rmdir(self::$cacheDir.'/'.FireflyCachePaths::PROXY_DIR); + foreach (glob(self::$cacheDir.'/*.php') ?: [] as $file) { + unlink($file); + } + @rmdir(self::$cacheDir); + self::$cacheDir = null; + self::$report = null; + } + + parent::tearDownAfterClass(); + } + + /** @return list<class-string<ServiceProvider>> */ + protected function fireflyProviders(): array + { + return [ + ValidationServiceProvider::class, + WebServiceProvider::class, + CliServiceProvider::class, + // Last: its unconditional $app->instance() overrides beat every *WiringProvider's bound()-guarded + // empty default regardless of ordering, and register() runs before any boot pass resolves a bean. + FireflyCacheServiceProvider::class, + ]; + } + + /** @return array<string,mixed> */ + protected function configOverrides(): array + { + $dir = self::$cacheDir ?? ''; + + return [ + // The cached zero-reflection path: point at the compiled manifests and set NO firefly.scan.paths, + // so FireflyAutoConfigureServiceProvider takes the ::load() branch and never scans. + 'firefly.cache.path' => $dir, + 'firefly.cache.component_manifest' => $dir.'/'.FireflyCachePaths::COMPONENT, + 'firefly.cache.context_manifest' => $dir.'/'.FireflyCachePaths::CONTEXT, + // Three tests here provoke a 404 or a 422 ON PURPOSE, and Laravel's exception handler logs each + // one with a full stack trace. FireflyTestCase's filesystem-free `errorlog` channel writes that + // to STDERR, which buries the suite's actual output under ~60 frames per deliberate failure. + // Raising the level keeps the channel (so a genuine emergency still surfaces) while silencing the + // errors these tests are asserting the existence of. + 'logging.channels.errorlog' => ['driver' => 'errorlog', 'level' => 'emergency'], + ]; + } +} diff --git a/packages/cli/tests/Support/SkeletonScannedBootTestCase.php b/packages/cli/tests/Support/SkeletonScannedBootTestCase.php new file mode 100644 index 0000000..ad19b9e --- /dev/null +++ b/packages/cli/tests/Support/SkeletonScannedBootTestCase.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Cli\Tests\Support; + +use Firefly\Testing\FireflyTestCase; +use Firefly\Validation\ValidationServiceProvider; +use Firefly\Web\WebServiceProvider; +use Illuminate\Support\ServiceProvider; + +/** + * The other half of SkeletonExampleTestCase: the shipped skeleton booted UNCACHED, by in-process scan. + * + * Both paths have to work and they are genuinely different code. With a compiled artifact present the + * framework `require`s a pure-array manifest; with none it runs the scanner in-process at boot and builds + * the same manifest from reflection. A DTO shape or a docblock the SCANNER cannot read but the compiled + * manifest already contains would pass every cached-path assertion and still break the first request a + * developer makes — because the first request a developer makes is under `artisan serve`, BEFORE they have + * run `firefly:cache` even once. (The skeleton's composer.json does run it in post-create-project-cmd, but + * every subsequent edit to app/ invalidates it, and nothing forces a re-run.) + * + * So: `firefly.scan.paths` set, `firefly.cache.path` pointed at a directory with nothing in it. + */ +abstract class SkeletonScannedBootTestCase extends FireflyTestCase +{ + protected function setUp(): void + { + SkeletonApp::register(); + + parent::setUp(); + } + + /** @return list<class-string<ServiceProvider>> */ + protected function fireflyProviders(): array + { + return [ + ValidationServiceProvider::class, + WebServiceProvider::class, + ]; + } + + /** @return array<string,mixed> */ + protected function configOverrides(): array + { + return [ + 'firefly.scan.paths' => SkeletonApp::psr4(), + // A path that cannot hold artifacts, so every `is_file()` probe in the boot path answers false + // and the scan branch is the one under test — rather than silently reusing whatever a previous + // test in this process happened to compile. + 'firefly.cache.path' => sys_get_temp_dir().'/firefly-skeleton-uncached-'.bin2hex(random_bytes(6)), + // The 404 and 422 cases below are provoked on purpose; see SkeletonExampleTestCase for why the + // channel is kept but raised rather than removed. + 'logging.channels.errorlog' => ['driver' => 'errorlog', 'level' => 'emergency'], + ]; + } +} diff --git a/packages/openapi/src/Generator/OpenApiGenerator.php b/packages/openapi/src/Generator/OpenApiGenerator.php index 7a5f99e..eb31623 100644 --- a/packages/openapi/src/Generator/OpenApiGenerator.php +++ b/packages/openapi/src/Generator/OpenApiGenerator.php @@ -49,6 +49,17 @@ final class OpenApiGenerator */ private const array VERB_ORDER = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + /** + * JSON Schema keywords whose value is an INSTANCE — a value the schema DESCRIBES, rather than a + * subschema or a map of them. An empty array under one of these is payload data, so its JSON type is + * decided by the schema it sits in; everywhere else in this document an empty array is a map. See + * objectify(). + */ + private const array INSTANCE_KEYWORDS = ['default', 'const', 'example']; + + /** The same, for the keywords that hold a LIST of instances rather than one. */ + private const array INSTANCE_LIST_KEYWORDS = ['enum', 'examples']; + /** @var array<string, mixed>|null */ private ?array $document = null; @@ -80,10 +91,16 @@ public function generate(): array * cannot tell an empty map from an empty list: `json_encode([])` is `[]`, so an app with no routes would * serialise `"paths": []` and an unconstrained property would serialise as `[]` — both of which are type * errors against the OpenAPI 3.1 meta-schema, and both of which make a strict validator reject an - * otherwise perfect document. Every empty array is therefore re-encoded as `{}` here. That rewrite is - * unconditionally safe in THIS document because nothing in it ever emits an empty LIST: `required`, - * `tags`, `parameters`, `servers`, `allOf` and the constraint extension are each omitted entirely rather - * than emitted empty (see DtoSchemaFactory and OperationFactory, which say so at each site). + * otherwise perfect document. Empty arrays are therefore re-encoded as `{}` here. + * + * THAT REWRITE USED TO BE UNCONDITIONAL, and it was justified by a claim that had quietly stopped being + * true: "nothing in this document ever emits an empty LIST". A constructor default does. + * `array $lines = []` is documented as `default: []`, the rewrite turned it into `"default": {}`, and the + * document then told every client that omitting `lines` yields an empty OBJECT for a member the same + * schema declares `type: array` two lines above. A generated client either fails to compile against its + * own type or ships a wrong default. The value is data, not structure, and structure is all this rewrite + * was ever meant to fix — so the instance keywords are now resolved against the schema's own `type` + * instead (see objectify() and instance()). */ public function toJson(): string { @@ -297,8 +314,9 @@ private function sortVerbs(array $item): array } /** - * Recursively re-encodes empty arrays as empty JSON OBJECTS — see toJson() for why this is both - * necessary and safe here. + * Recursively re-encodes empty arrays as empty JSON OBJECTS — see toJson() for why that is necessary — + * EXCEPT under the instance keywords, whose value is a payload value rather than part of the document's + * structure and is therefore handed to instance() to be typed by the schema it sits in. */ private function objectify(mixed $value): mixed { @@ -310,6 +328,89 @@ private function objectify(mixed $value): mixed return new stdClass; } - return array_map(fn (mixed $item): mixed => $this->objectify($item), $value); + $type = $this->declaredType($value); + + $encoded = []; + foreach ($value as $key => $item) { + $encoded[$key] = match (true) { + $type === null => $this->objectify($item), + in_array($key, self::INSTANCE_KEYWORDS, true) => $this->instance($item, $type), + in_array($key, self::INSTANCE_LIST_KEYWORDS, true) && is_array($item) => array_map( + fn (mixed $member): mixed => $this->instance($member, $type), + $item, + ), + default => $this->objectify($item), + }; + } + + return $encoded; + } + + /** + * The node's declared `type` in one of the two spellings OpenAPI 3.1 permits — a name, or a LIST of names, + * which is how 3.1 spells nullability now that 3.0's `nullable` keyword is gone — and null for anything + * else, which is this method's real job. + * + * Null means "not a Schema Object", and that is what keeps the instance-keyword exception from firing + * where it must not. Every keyword it names is also a legal map key elsewhere in the document: the + * Responses Object has a `default` status, and a DTO may perfectly well have members called `default`, + * `example` or `enum`, which appear as keys of a `properties` map. Neither node declares a type, so + * neither is ever treated as one — and a `properties` map that DOES happen to hold a member named `type` + * fails the shape test here, because that member's value is a schema map rather than a type name. + * + * @param array<array-key, mixed> $node + * @return list<string>|string|null + */ + private function declaredType(array $node): array|string|null + { + /** @var mixed $type */ + $type = $node['type'] ?? null; + + if (is_string($type)) { + return $type; + } + + // `$type !== []` is load-bearing, not defensive. An empty array passes every other test here + // (`array_is_list([])` and `array_filter([], 'is_string') === []` are both true), so without it a + // `properties` map holding an UNCONSTRAINED member named `type` — whose schema is `[]` — reads as a + // Schema Object declaring no types at all, and its sibling members named `enum`/`default`/`example` + // are then treated as instance keywords. That emitted `"enum": []` for a member named `enum`: a JSON + // array where the meta-schema requires a Schema Object, which is the very defect objectify() exists + // to prevent. No real schema declares an empty `type`; 3.1 requires at least one name. + if (is_array($type) && $type !== [] && array_is_list($type) && array_filter($type, 'is_string') === $type) { + /** @var list<string> $type */ + return $type; + } + + return null; + } + + /** + * One instance value, passed through UNTOUCHED except for the single thing PHP cannot express on its own: + * `[]`, which is both the empty list and the empty map, and which only the schema's declared `type` can + * disambiguate. `type: array` (including the 3.1 nullable spelling `type: [array, 'null']`) makes it a + * JSON array; any other declared type falls back to the structural `{}` rewrite the document has always + * applied, which is the honest answer for `type: object` and a harmless one for the rest, since a schema + * that declares `type: string` cannot have a legitimate array default in the first place. + * + * The one case this deliberately does not fix is a member with no declared type at all — `mixed $tags = []` + * — where the schema states nothing for anything to decide from and the `{}` rewrite still applies. + * Guessing from the PHP value there would mean trusting a shape the document itself never claimed. + * + * Nothing recurses into a non-empty instance: json_encode's own list-vs-map rule is already exactly right + * for it, and objectify()ing a caller's #[ApiProperty] example would rewrite THEIR empty arrays into + * objects — the same bug this method exists to fix, one level down. + * + * @param list<string>|string $type + */ + private function instance(mixed $value, array|string $type): mixed + { + if ($value !== []) { + return $value; + } + + $admitsArray = is_array($type) ? in_array('array', $type, true) : $type === 'array'; + + return $admitsArray ? [] : new stdClass; } } diff --git a/packages/openapi/src/Generator/OperationFactory.php b/packages/openapi/src/Generator/OperationFactory.php index bc9d017..a22883c 100644 --- a/packages/openapi/src/Generator/OperationFactory.php +++ b/packages/openapi/src/Generator/OperationFactory.php @@ -7,6 +7,7 @@ use Firefly\OpenApi\Attributes\ApiParameter; use Firefly\OpenApi\Attributes\ApiResponse; use Firefly\OpenApi\Schema\DtoSchemaFactory; +use Firefly\OpenApi\Schema\ElementTypes; use Firefly\OpenApi\Schema\ProblemSchema; use Firefly\OpenApi\Schema\SchemaRegistry; use Firefly\OpenApi\Schema\TypeSchema; @@ -163,6 +164,14 @@ private function parameter(array $binding, string $in, bool $required, ?ApiParam } /** + * The binding's `dtos` table is handed down with it, and that is the whole of the fix for a body DTO that + * documented `#[Valid] array $lines` as `Array<any>`. RouteScanner compiled that table so ArgumentResolver + * could HYDRATE the nested payload without reflecting; passing it here means the schema is written from + * the same statement of the payload's shape that the server binds against, for every class in the graph + * rather than only the one at the top. The key is optional on a compiled binding — it is written only + * when the DTO actually nests — so an absent one degrades to ElementTypes' own resolution rather than + * silently dropping `items` again. + * * @param Binding $binding * @return array<string, mixed> */ @@ -171,7 +180,7 @@ private function requestBody(array $binding, SchemaRegistry $registry): array $type = $binding['type']; $schema = $type !== null && TypeSchema::isDto($type) - ? ['$ref' => $this->schemas->ref($type, $registry, $binding['properties'])] + ? ['$ref' => $this->schemas->ref($type, $registry, $binding['properties'], [], new ElementTypes($binding['dtos'] ?? []))] : ['type' => 'object']; return [ diff --git a/packages/openapi/src/Schema/DtoSchemaFactory.php b/packages/openapi/src/Schema/DtoSchemaFactory.php index 92dffff..bb5cc96 100644 --- a/packages/openapi/src/Schema/DtoSchemaFactory.php +++ b/packages/openapi/src/Schema/DtoSchemaFactory.php @@ -50,6 +50,19 @@ * under the app's scan roots, not just body DTOs) its own rules are used. When it does not, the parent's * dotted `#[Valid]`-cascaded keys (`beneficiary.postcode`) are unflattened back into it, so a nested schema * is still constrained rather than a bare `type: object`. + * + * A LIST of nested DTOs is the same story told through `items`, and it is the case this factory used to get + * silently wrong: `#[Valid] array $lines` documented itself as a bare `type: array`, the element class never + * became a component at all, and a generated client got `Array<any>` for the one member that most needed a + * type. PHP's `array` says nothing about its elements, so the element class comes from ElementTypes — which + * reads the table RouteScanner already compiled for the HYDRATOR, so the document and the server agree by + * construction. That table is threaded down the whole descent rather than looked up once, because it is keyed + * by class and therefore answers for every level of the graph, not just the body DTO at the top. + * + * RULES FOR A LIST ELEMENT COME FROM THE ELEMENT'S OWN MANIFEST ENTRY, never from the parent's dotted keys. + * ConstraintScanner cascades a #[Valid] only through a CLASS-typed member (classTypeOf() returns null for an + * `array`), so a parent's dotted keys can never describe a list element in the first place — and unflattening + * a Laravel-style `lines.*.sku` into an element schema would invent a member literally named `*.sku`. */ final class DtoSchemaFactory { @@ -63,10 +76,12 @@ public function __construct( * autoloaded in this process and reflection is therefore unavailable * @param array<string, list<string|ValidationRule>> $fallbackRules a nested class's rules recovered * from the parent's dotted keys + * @param ElementTypes $elements the compiled `dtos` table of the binding this DTO was reached through; + * defaults to an empty one, which resolves element types by reflection */ - public function ref(string $class, SchemaRegistry $registry, array $properties = [], array $fallbackRules = []): string + public function ref(string $class, SchemaRegistry $registry, array $properties = [], array $fallbackRules = [], ElementTypes $elements = new ElementTypes): string { - return $registry->ref($class, fn (): array => $this->build($class, $registry, $properties, $fallbackRules)); + return $registry->ref($class, fn (): array => $this->build($class, $registry, $properties, $fallbackRules, $elements)); } /** @@ -74,7 +89,7 @@ public function ref(string $class, SchemaRegistry $registry, array $properties = * @param array<string, list<string|ValidationRule>> $fallbackRules * @return array<string, mixed> */ - private function build(string $class, SchemaRegistry $registry, array $properties, array $fallbackRules): array + private function build(string $class, SchemaRegistry $registry, array $properties, array $fallbackRules, ElementTypes $elements): array { $rules = $this->constraints->rulesFor($class); if ($rules === []) { @@ -83,11 +98,16 @@ private function build(string $class, SchemaRegistry $registry, array $propertie [$own, $nested] = $this->partition($rules); + // Resolved once for the whole class rather than per member: both paths behind it — a table row and a + // constructor docblock — answer for every member at once, and asking per member would re-read the + // same doc comment once per `array` property. + $lists = $elements->forClass($class); + $fields = []; $required = []; foreach ($this->members($class, $properties, $own) as $name => $member) { - $property = $this->property($member, $own[$name] ?? [], $nested[$name] ?? [], $registry); + $property = $this->property($member, $own[$name] ?? [], $nested[$name] ?? [], $registry, $elements, $lists[$name] ?? null); $fields[$name] = $property->schema; if ($property->required) { @@ -114,13 +134,14 @@ private function build(string $class, SchemaRegistry $registry, array $propertie /** * @param list<string|ValidationRule> $rules this member's own compiled rule list * @param array<string, list<string|ValidationRule>> $nestedRules rules cascaded from a parent #[Valid] + * @param string|null $element the class this member's list holds, when it holds a list of one */ - private function property(MemberType $member, array $rules, array $nestedRules, SchemaRegistry $registry): PropertySchema + private function property(MemberType $member, array $rules, array $nestedRules, SchemaRegistry $registry, ElementTypes $elements, ?string $element): PropertySchema { $type = $member->type; if ($type !== null && TypeSchema::isDto($type)) { - $ref = $this->ref($type, $registry, [], $nestedRules); + $ref = $this->ref($type, $registry, [], $nestedRules, $elements); // A `$ref` cannot usefully be widened with a sibling `type` in 2020-12 — VALIDATION keywords // beside a reference are applied WITH it, so `type: 'null'` would have to pass as well as the @@ -138,6 +159,16 @@ private function property(MemberType $member, array $rules, array $nestedRules, } $base = TypeSchema::for($type) ?? []; + + // `items` is seeded into the BASE rather than layered on afterwards so the constraint mapper's + // first-writer-wins ordering sees a complete declared-type fragment — and so `minItems`/`maxItems` + // still resolve against the `type: array` sitting beside it, which is how MapperState tells a #[Size] + // on a list from a #[Size] on a string. + $items = $element === null ? null : $this->items($element, $registry, $elements); + if ($items !== null && ($base['type'] ?? null) === 'array') { + $base['items'] = $items; + } + $property = $this->mapper->apply($base, $rules, $member->nullable, $member->required()); $schema = $property->schema; @@ -148,6 +179,33 @@ private function property(MemberType $member, array $rules, array $nestedRules, return new PropertySchema($member->doc->apply($schema), $property->required); } + /** + * The `items` subschema for a list member. + * + * A list of DTOs becomes a `$ref` — the recursion is safe for the same reason a plain nested DTO's is: + * SchemaRegistry reserves the component name BEFORE the builder runs, so `CategoryNode { list<CategoryNode> + * $children }` closes its own cycle on the component being built instead of expanding forever. A list of + * anything TypeSchema can state inline (a backed enum, a DateTimeInterface, a scalar) is inlined instead: + * an enum is not a reusable component, and minting one per enum would hand every generated client a named + * type where an inline union is what the payload actually is. + * + * An element TypeSchema cannot resolve at all yields null rather than an empty `{}` subschema. The two say + * exactly the same thing — an absent `items` accepts any element — and the empty one says it in the one + * spelling that has to survive a later `[]`-vs-`{}` decision at encoding time. + * + * @return array<string, mixed>|null + */ + private function items(string $element, SchemaRegistry $registry, ElementTypes $elements): ?array + { + if (TypeSchema::isDto($element)) { + return ['$ref' => $this->ref($element, $registry, [], [], $elements)]; + } + + $schema = TypeSchema::for($element) ?? []; + + return $schema === [] ? null : $schema; + } + /** * The members to document, in the order a reader expects: constructor parameters first (declaration * order, the order the class itself states), then any rule-only member the constructor does not take. diff --git a/packages/openapi/src/Schema/ElementTypes.php b/packages/openapi/src/Schema/ElementTypes.php new file mode 100644 index 0000000..314bef1 --- /dev/null +++ b/packages/openapi/src/Schema/ElementTypes.php @@ -0,0 +1,209 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Schema; + +use ReflectionClass; +use ReflectionNamedType; + +/** + * Answers the one question a PHP `array` type cannot: WHAT IS IN IT. + * + * `#[Valid] public readonly array $lines = []` documented itself as `{"type": "array"}` with no `items`, + * which a client generator faithfully turns into `Array<any>` — a typed client with an untyped hole in it, + * for a payload whose element type the framework has known all along. `list<OrderLineRequest>` is written in + * the constructor docblock, and packages/web already reads it: RouteScanner::dtoShapes() resolves it at + * `firefly:cache` time and compiles it into the body binding's `dtos` table so ArgumentResolver can hydrate + * the nested payload without reflecting. That table is a `class => member => {class, list}` map covering + * EVERY class reachable from the body DTO, at any depth, and it is the first thing this class consults — + * because it is not merely a copy of the answer, it is the answer the HYDRATOR uses, so a document generated + * from it cannot describe a shape the server would refuse to build. + * + * WHY THERE IS A SECOND PATH, AND WHY IT IS ONLY EVER A FALLBACK. Three reachable shapes carry no compiled + * table: a DTO named by #[ApiResponse(type:)] (a RESPONSE has no binding plan at all), a DTO handed straight + * to DtoSchemaFactory::ref() by something other than a request body, and a route manifest compiled before + * RouteScanner emitted the `dtos` key — which is a supported state, since that key is written only when a + * body DTO actually nests and ArgumentResolver reads it with a `?? []` default. In all three the element type + * is still sitting in the docblock, and the choice is between reading it and shipping `Array<any>` again. + * RouteScanner's resolution is private to packages/web and reachable only through a compiled binding, so it + * cannot be called; it is therefore MIRRORED here, rule for rule — the same two `@param` spellings, the same + * name resolution (qualified name, then the declaring class's namespace, then the file's `use` imports), and + * the same restriction to a parameter DECLARED `array`, so a member the hydrator would leave alone is never + * given `items` here either. Two implementations of one rule is a real cost; the alternative was a generator + * whose output silently depended on whether a route happened to reach the class. + * + * The mirror is deliberately not consulted when the table HAS a row for the class. A row is complete — the + * scanner walked every constructor parameter to build it — so a member missing from it is a member the + * hydrator will not treat as a list, and second-guessing that with reflection is exactly how the two paths + * would drift into disagreeing about the same class. + * + * @phpstan-import-type PropertyPlan from \Firefly\Web\Route\RouteDescriptor + */ +final class ElementTypes +{ + /** + * @param array<string, array<string, PropertyPlan>> $table the body binding's compiled `dtos` table, + * empty when the caller has none + */ + public function __construct(private readonly array $table = []) {} + + /** + * The members of $class that hold a LIST of some class, as member name => element class. A member holding + * a list of scalars is absent: `list<string>` needs no element CLASS, and TypeSchema has nothing to + * resolve for it that the constraint list does not already say. + * + * @return array<string, string> + */ + public function forClass(string $class): array + { + $row = $this->table[$class] ?? null; + + if ($row === null) { + return $this->reflect($class); + } + + $elements = []; + foreach ($row as $member => $plan) { + if ($plan['list'] && $plan['class'] !== null) { + $elements[$member] = $plan['class']; + } + } + + return $elements; + } + + /** + * The fallback path — see the class docblock for the three shapes that reach it and why it exists. + * + * @return array<string, string> + */ + private function reflect(string $class): array + { + if (! class_exists($class)) { + return []; + } + + $reflection = new ReflectionClass($class); + $constructor = $reflection->getConstructor(); + + if ($constructor === null) { + return []; + } + + $documented = $this->docblockParamTypes($constructor->getDocComment() ?: '', $reflection); + + $elements = []; + foreach ($constructor->getParameters() as $parameter) { + $type = $parameter->getType(); + $name = $parameter->getName(); + + // Only a parameter DECLARED `array` may take an element type from a comment. A class-typed member + // is a nested DTO the caller already resolves from the declared type, and `iterable` is excluded + // because RouteScanner excludes it — a document that gave `items` to a member the hydrator does + // not bind as a list would describe a request the server cannot accept. + if ($type instanceof ReflectionNamedType && $type->getName() === 'array' && isset($documented[$name])) { + $elements[$name] = $documented[$name]; + } + } + + return $elements; + } + + /** + * Element classes read out of a constructor docblock: `@param list<Line> $lines`, `@param Line[] $lines` + * and `@param array<int, Line> $lines` all denote the same payload shape. A name that does not resolve to + * a real class is dropped entirely rather than emitted as a dangling `$ref` — the same choice RouteScanner + * makes when it leaves such a member out of the hydration table. + * + * @param ReflectionClass<object> $declaring + * @return array<string, string> + */ + private function docblockParamTypes(string $docComment, ReflectionClass $declaring): array + { + if ($docComment === '') { + return []; + } + + $types = []; + + foreach ([ + '/@param\s+(?:list|array|iterable)<(?:[^,<>]+,\s*)?([^<>]+)>\s+\$(\w+)/', + '/@param\s+([\w\\\\]+)\[\]\s+\$(\w+)/', + ] as $pattern) { + if (preg_match_all($pattern, $docComment, $matches, PREG_SET_ORDER) === false) { + continue; + } + + foreach ($matches as $match) { + $resolved = $this->resolveClassName(trim($match[1]), $declaring); + if ($resolved !== null) { + $types[$match[2]] = $resolved; + } + } + } + + return $types; + } + + /** + * @param ReflectionClass<object> $declaring + */ + private function resolveClassName(string $name, ReflectionClass $declaring): ?string + { + $name = ltrim($name, '\\'); + + if (class_exists($name)) { + return $name; + } + + $namespace = $declaring->getNamespaceName(); + if ($namespace !== '' && class_exists($candidate = $namespace.'\\'.$name)) { + return $candidate; + } + + foreach ($this->imports($declaring) as $alias => $fqcn) { + if ($alias === $name && class_exists($fqcn)) { + return $fqcn; + } + } + + return null; + } + + /** + * The file's `use` imports, alias => FQCN, read from the source because reflection does not expose them. + * + * @param ReflectionClass<object> $declaring + * @return array<string, string> + */ + private function imports(ReflectionClass $declaring): array + { + $file = $declaring->getFileName(); + + if ($file === false || ! is_file($file)) { + return []; + } + + $source = (string) file_get_contents($file); + + if (preg_match_all('/^use\s+([\w\\\\]+)(?:\s+as\s+(\w+))?\s*;/mi', $source, $matches, PREG_SET_ORDER) === false) { + return []; + } + + $imports = []; + foreach ($matches as $match) { + $fqcn = $match[1]; + $alias = $match[2] ?? ''; + + if ($alias === '') { + $parts = explode('\\', $fqcn); + $alias = end($parts); + } + + $imports[$alias] = $fqcn; + } + + return $imports; + } +} diff --git a/packages/openapi/tests/Generator/NestedSchemaTest.php b/packages/openapi/tests/Generator/NestedSchemaTest.php new file mode 100644 index 0000000..5c42adf --- /dev/null +++ b/packages/openapi/tests/Generator/NestedSchemaTest.php @@ -0,0 +1,276 @@ +<?php + +declare(strict_types=1); + +use Firefly\OpenApi\Generator\OpenApiGenerator; +use Firefly\OpenApi\Generator\OperationFactory; +use Firefly\OpenApi\Schema\SchemaRegistry; +use Firefly\OpenApi\Tests\NestedFixture\CreateOrderRequest; +use Firefly\OpenApi\Tests\NestedFixture\LineOptionRequest; +use Firefly\OpenApi\Tests\Support\FixtureDocument; +use Firefly\Web\Route\RouteDescriptor; +use Firefly\Web\Route\RouteManifest; + +/** + * The nested-payload half of the document, asserted on the EMITTED DOCUMENT — and, where the flaw was a + * serialisation one, on the emitted JSON TEXT, because PHP cannot tell `[]` from `{}` once it has been + * decoded and the whole defect lived in that distinction. + * + * Three defects, reproduced against a running application, all with the same root: the generator described + * the payload's SURFACE and stopped. `#[Valid] array $lines` became `{"type": "array", "default": {}}` — no + * `items`, so a client generator emitted `Array<any>` for the one member that most needed a type; + * OrderLineRequest never appeared as a component at all, though the framework had already resolved it well + * enough to HYDRATE it; and the PHP default `[]` was encoded as a JSON object, contradicting the `type: array` + * on the line above it. + * + * Every assertion here runs against fixtures reached the way an application reaches them — a real + * #[RestController] scanned by the real RouteScanner, whose compiled `dtos` table is the element-type source + * — so a change to that table's shape breaks these tests rather than an application's generated client. + * Members are read with FixtureDocument::resolve(), which is the document's own `$ref` resolver: a test that + * cannot reach a node the same way a client would is testing something else. + */ +it('gives a list of DTOs an items $ref and emits the element as its own component', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // THE DEFECT: this was `['type' => 'array', 'default' => []]` and nothing else. + expect(FixtureDocument::resolve($document, '#/components/schemas/CreateOrderRequest/properties/lines')) + ->toBe([ + 'description' => 'The lines to order, at least one.', + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/OrderLineRequest'], + 'default' => [], + ]) + // ...and the element type it names has to actually be there, or the pointer dangles and every client + // generator aborts on it. + ->and(FixtureDocument::resolve($document, '#/components/schemas/OrderLineRequest')) + ->not->toBeNull(); +}); + +it('emits a component for a DTO reachable only two lists deep', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // CreateOrderRequest -> lines[] -> options[] -> LineOptionRequest: a depth no #[Valid] cascade reaches, + // since ConstraintScanner flattens exactly one level and never through an `array` member at all. + expect(FixtureDocument::resolve($document, '#/components/schemas/OrderLineRequest/properties/options/items')) + ->toBe(['$ref' => '#/components/schemas/LineOptionRequest']) + ->and(FixtureDocument::resolve($document, '#/components/schemas/LineOptionRequest/properties/code')) + ->toBe(['type' => 'string', 'pattern' => '\S']); +}); + +it('gives a nested component the required list its OWN constraints state', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // Each list is that class's own contract, not an echo of its parent's: #[NotBlank] sku, #[Min(1)] + // quantity and a non-nullable enum with no default on the line; #[NotBlank] code on the option, whose + // surchargeMinor has a default and so can never be omitted-and-fail. + expect(FixtureDocument::resolve($document, '#/components/schemas/OrderLineRequest/required')) + ->toBe(['sku', 'quantity', 'fulfilment']) + ->and(FixtureDocument::resolve($document, '#/components/schemas/LineOptionRequest/required')) + ->toBe(['code']) + ->and(FixtureDocument::resolve($document, '#/components/schemas/CreateOrderRequest/required')) + ->toBe(['reference', 'customerEmail', 'totalMinor']); +}); + +it('closes the cycle on a self-referential DTO whose recursion runs through a list', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // `items` pointing back at the component being built is what makes this terminate at all — the + // alternative is an expansion that never ends. Reaching this assertion is most of the test. + expect(FixtureDocument::resolve($document, '#/components/schemas/CategoryNode/properties/children')) + ->toBe([ + 'description' => 'Sub-categories, to any depth.', + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/CategoryNode'], + 'default' => [], + ]); +}); + +it('registers each reachable DTO exactly once, however many members point at it', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // CategoryNode is reached twice — a nullable member of the body, and its own `children` list — and + // appears once. Duplication is what makes a client generator mint two structurally identical types. + expect(array_keys(FixtureDocument::resolve($document, '#/components/schemas') ?? []))->toBe([ + 'CategoryNode', 'CreateOrderRequest', 'LineOptionRequest', 'OrderLineRequest', 'ProblemDetails', + ]); +}); + +it('resolves every $ref in the nested document against the document itself', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + $refs = FixtureDocument::refs($document); + + expect($refs)->not->toBeEmpty(); + + foreach ($refs as $ref) { + expect(FixtureDocument::resolve($document, $ref))->not->toBeNull("dangling pointer {$ref}"); + } +}); + +it('inlines a list of backed enums instead of minting a component for it', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // An enum has no members to reflect a component out of, and a named type per enum is noise in every + // generated client — so the accepted set is stated inline, where a reader of the list sees it. + expect(FixtureDocument::resolve($document, '#/components/schemas/CreateOrderRequest/properties/channels')) + ->toBe([ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => ['standard', 'express']], + 'default' => [], + ]) + ->and(FixtureDocument::resolve($document, '#/components/schemas/Fulfilment'))->toBeNull(); +}); + +it('states an enum-typed member as the exact set of cases it accepts', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + expect(FixtureDocument::resolve($document, '#/components/schemas/OrderLineRequest/properties/fulfilment')) + ->toBe(['type' => 'string', 'enum' => ['standard', 'express']]); +}); + +it('spells a nullable member the way OpenAPI 3.1 does, never with the 3.0 nullable keyword', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // 3.1 IS JSON Schema 2020-12, which dropped 3.0's `nullable: true` in favour of a type UNION. A `$ref` + // cannot be widened by a sibling `type` there — validation keywords beside a reference are applied WITH + // it, so `type: 'null'` would have to hold as well as the reference and never could — which is why a + // nullable nested DTO is spelled as the union it actually is. + expect(FixtureDocument::resolve($document, '#/components/schemas/CreateOrderRequest/properties/catalogue')) + ->toBe(['anyOf' => [['$ref' => '#/components/schemas/CategoryNode'], ['type' => 'null']]]) + ->and(FixtureDocument::generatorFor('NestedFixture')->toJson())->not->toContain('"nullable"'); +}); + +it('encodes an array default as a JSON array and every empty map as a JSON object', function () { + $json = FixtureDocument::generatorFor('NestedFixture')->toJson(); + + // Asserted on the TEXT because that is the only place the distinction survives: json_decode() with + // associative arrays reads both `[]` and `{}` back as the same empty PHP array, which is the very + // ambiguity that produced the defect. A client generator reads the text. + // + // THE DEFECT: `"default": {}` on a member the same schema declares `type: array` two lines above. A + // generated client either fails to compile against its own type or ships a wrong default. + expect($json)->toContain('"default": []') + ->and($json)->not->toContain('"default": {}') + // The structural rewrite this document has always needed is still in force: `paths` and an + // unconstrained schema must serialise as maps, never as `[]`. + ->and(json_decode($json, true, 512, JSON_THROW_ON_ERROR))->toBeArray(); +}); + +it('leaves the Responses Object default alone even though `default` is an instance keyword', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // `default` names a STATUS here, not a payload value, and the node it sits in declares no `type` — which + // is exactly the guard that keeps the array-default rule from firing outside a Schema Object. + expect(FixtureDocument::resolve($document, '#/paths/~1api~1orders/post/responses/default')) + ->toBe(['$ref' => '#/components/responses/Problem']); +}); + +it('resolves element types by reflection for a DTO reached without a compiled binding', function () { + // #[ApiResponse(type:)] and a direct ref() both arrive with no `dtos` table — a RESPONSE has no binding + // plan at all — and so does a route manifest compiled before RouteScanner emitted the key, which is a + // supported state. The document must not silently lose `items` in any of the three, so the fallback path + // is asserted to produce exactly what the compiled table produces. + $registry = new SchemaRegistry; + FixtureDocument::schemas('NestedFixture')->ref(CreateOrderRequest::class, $registry); + + $document = ['components' => ['schemas' => $registry->all()]]; + + expect(FixtureDocument::resolve($document, '#/components/schemas/CreateOrderRequest/properties/lines/items')) + ->toBe(['$ref' => '#/components/schemas/OrderLineRequest']) + ->and(FixtureDocument::resolve($document, '#/components/schemas/CreateOrderRequest/properties/channels/items')) + ->toBe(['type' => 'string', 'enum' => ['standard', 'express']]) + ->and(array_keys($registry->all())) + ->toBe(['CategoryNode', 'CreateOrderRequest', 'LineOptionRequest', 'OrderLineRequest']); +}); + +it('does not invent items for a list whose element type is not a class', function () { + $document = FixtureDocument::generatorFor('NestedFixture')->generate(); + + // `list<string>` names no class, so RouteScanner leaves it out of the hydration table and the generator + // leaves `items` off. Widening the rule to cover scalars here would make the fallback path emit an + // `items` the compiled-table path does not — two implementations of one rule, disagreeing about the same + // member, which is precisely the drift that must not happen. + expect(FixtureDocument::resolve($document, '#/components/schemas/LineOptionRequest/properties/notes')) + ->toBe(['type' => 'array', 'default' => []]); +}); + +it('reads element types out of a manifest that has been through the compiled array form', function () { + // `firefly:cache` var_exports the manifest and production loads it back; the generator then runs against + // THAT, never against a freshly reflected one. The `dtos` table has to survive the round trip, or a + // cached application would document `Array<any>` while a development one documented the element type — + // the worst possible split, because the published spec is generated from the cached side. + $compiled = array_map( + static fn (RouteDescriptor $route): array => $route->toArray(), + FixtureDocument::routes('NestedFixture')->all(), + ); + + $document = (new OpenApiGenerator( + new RouteManifest(array_map(RouteDescriptor::fromArray(...), $compiled)), + FixtureDocument::properties(), + new OperationFactory(FixtureDocument::schemas('NestedFixture')), + ))->generate(); + + expect(FixtureDocument::resolve($document, '#/components/schemas/CreateOrderRequest/properties/lines/items')) + ->toBe(['$ref' => '#/components/schemas/OrderLineRequest']); +}); + +it('writes the document from the binding\'s compiled dtos table rather than re-reading the docblock', function () { + $compiled = array_map( + static fn (RouteDescriptor $route): array => $route->toArray(), + FixtureDocument::routes('NestedFixture')->all(), + ); + + // ONE entry of the REAL compiled table is repointed at a different class. The docblock still says + // `list<OrderLineRequest>`; the table now says LineOptionRequest. The document must follow the TABLE, + // because the table is the statement ArgumentResolver hydrates from — a document written from a second, + // independent reading of the docblock could describe a payload the server would refuse to build, and + // would also pass every assertion in this file while the table was never consulted at all. + $repointed = 0; + foreach ($compiled as $i => $route) { + foreach ($route['bindings'] as $j => $binding) { + $table = $binding['dtos'] ?? []; + + if (! isset($table[CreateOrderRequest::class]['lines'])) { + continue; + } + + $table[CreateOrderRequest::class]['lines'] = ['class' => LineOptionRequest::class, 'list' => true]; + $binding['dtos'] = $table; + $compiled[$i]['bindings'][$j] = $binding; + $repointed++; + } + } + + // Guards the guard: if `dtos` ever stops surviving toArray()/fromArray(), this test must fail loudly + // rather than quietly assert nothing. + expect($repointed)->toBe(1); + + $document = (new OpenApiGenerator( + new RouteManifest(array_map(RouteDescriptor::fromArray(...), $compiled)), + FixtureDocument::properties(), + new OperationFactory(FixtureDocument::schemas('NestedFixture')), + ))->generate(); + + expect(FixtureDocument::resolve($document, '#/components/schemas/CreateOrderRequest/properties/lines/items')) + ->toBe(['$ref' => '#/components/schemas/LineOptionRequest']); +}); + +it('keeps a properties map a map even when a member is named after a JSON Schema keyword', function () { + $json = FixtureDocument::generatorFor('KeywordFixture')->toJson(); + + // `default`, `enum`, `example` and `type` are all legal PHP property names, and a `properties` map is + // keyed by property name. The instance-keyword rule must never fire on that map — it is not a Schema + // Object — and the test for that has to run against a node where the two readings DISAGREE. + // + // THE DEFECT: an unconstrained member named `type` gives the properties map the schema `[]`, which + // satisfied every part of the Schema-Object shape test except emptiness. The map was then read as a + // Schema Object declaring no type, and the sibling member named `enum` was emitted as `"enum": []` — a + // JSON array where the meta-schema requires a Schema Object, which is exactly the flaw the empty-array + // rewrite exists to prevent. + expect($json)->toContain('"enum": {}') + ->and($json)->not->toContain('"enum": []') + ->and(FixtureDocument::resolve( + FixtureDocument::generatorFor('KeywordFixture')->generate(), + '#/components/schemas/KeywordRequest/required', + ))->toBe(['reference']); +}); diff --git a/packages/openapi/tests/KeywordFixture/KeywordController.php b/packages/openapi/tests/KeywordFixture/KeywordController.php new file mode 100644 index 0000000..c90db4c --- /dev/null +++ b/packages/openapi/tests/KeywordFixture/KeywordController.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\KeywordFixture; + +use Firefly\Validation\Valid; +use Firefly\Web\Attributes\PostMapping; +use Firefly\Web\Attributes\RequestBody; +use Firefly\Web\Attributes\RequestMapping; +use Firefly\Web\Attributes\RestController; + +/** + * Keywords. + * + * A real controller so the keyword payload is reached the way an application reaches one — through the real + * RouteScanner and the real generator — rather than by handing a hand-written node to a private method. + */ +#[RestController] +#[RequestMapping('/api/keywords')] +final class KeywordController +{ + /** @return array<string, mixed> */ + #[PostMapping(status: 201)] + public function create(#[Valid] #[RequestBody] KeywordRequest $body): array + { + return ['reference' => $body->reference]; + } +} diff --git a/packages/openapi/tests/KeywordFixture/KeywordRequest.php b/packages/openapi/tests/KeywordFixture/KeywordRequest.php new file mode 100644 index 0000000..b46b60d --- /dev/null +++ b/packages/openapi/tests/KeywordFixture/KeywordRequest.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\KeywordFixture; + +use Firefly\Validation\Constraint\NotBlank; + +/** + * Members named after JSON Schema keywords, which are ordinary PHP property names and so ordinary keys of a + * `properties` map. Nothing about this payload is exotic — it exists because the generator now treats + * `default`/`const`/`example`/`enum`/`examples` as INSTANCE keywords inside a Schema Object, and that rule + * must never fire on a `properties` map, which is keyed by property name rather than by keyword. + * + * `mixed $type` is the member that made the distinction fail: an unconstrained member has the schema `[]`, + * and an empty array satisfied every part of the Schema-Object shape test except emptiness. The map was then + * read as a Schema Object declaring no type, and the sibling `enum` member serialised as `"enum": []` — a + * JSON array where the meta-schema requires a Schema Object. + */ +final class KeywordRequest +{ + public function __construct( + #[NotBlank] public readonly string $reference, + public readonly mixed $type = null, + public readonly mixed $enum = null, + public readonly mixed $default = null, + public readonly mixed $example = null, + ) {} +} diff --git a/packages/openapi/tests/NestedFixture/CategoryNode.php b/packages/openapi/tests/NestedFixture/CategoryNode.php new file mode 100644 index 0000000..a4d0659 --- /dev/null +++ b/packages/openapi/tests/NestedFixture/CategoryNode.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\NestedFixture; + +/** + * A self-referential DTO whose cycle runs through a LIST rather than through a nullable member — the shape + * that makes an `items` builder recurse forever if the component name is not reserved before the body is + * built. SchemaRegistry reserves it, so the inner `$ref` closes the cycle on the component being built. + */ +final class CategoryNode +{ + /** + * @param list<CategoryNode> $children Sub-categories, to any depth. + */ + public function __construct( + public readonly string $label, + public readonly array $children = [], + ) {} +} diff --git a/packages/openapi/tests/NestedFixture/CreateOrderRequest.php b/packages/openapi/tests/NestedFixture/CreateOrderRequest.php new file mode 100644 index 0000000..4ba3ec0 --- /dev/null +++ b/packages/openapi/tests/NestedFixture/CreateOrderRequest.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\NestedFixture; + +use Firefly\Validation\Constraint\Email; +use Firefly\Validation\Constraint\Min; +use Firefly\Validation\Constraint\NotBlank; +use Firefly\Validation\Constraint\Size; +use Firefly\Validation\Valid; + +/** + * The request body that reproduced the defect this fixture family exists for: `lines` documented itself as + * `{"type": "array", "default": {}}` — no `items`, no OrderLineRequest component anywhere in the document, + * and an empty PHP array encoded as a JSON OBJECT. + * + * Every element type here is stated in the constructor docblock, which is exactly where RouteScanner already + * reads it from to hydrate the payload, so nothing about this class is new information to the framework. + */ +final class CreateOrderRequest +{ + /** + * @param list<OrderLineRequest> $lines The lines to order, at least one. + * @param list<Fulfilment> $channels + */ + public function __construct( + #[NotBlank] #[Size(min: 3, max: 40)] public readonly string $reference, + #[NotBlank] #[Email] public readonly string $customerEmail, + #[Min(1)] public readonly int $totalMinor, + #[Valid] public readonly array $lines = [], + public readonly array $channels = [], + public readonly ?CategoryNode $catalogue = null, + ) {} +} diff --git a/packages/openapi/tests/NestedFixture/Fulfilment.php b/packages/openapi/tests/NestedFixture/Fulfilment.php new file mode 100644 index 0000000..28bf10c --- /dev/null +++ b/packages/openapi/tests/NestedFixture/Fulfilment.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\NestedFixture; + +/** + * The element type of a list of ENUMS rather than of DTOs. `list<Fulfilment>` must produce + * `items: {type: string, enum: [...]}` inline — an enum is not a component, so turning it into a `$ref` + * would mint a named type per enum in every generated client for no gain. + */ +enum Fulfilment: string +{ + case Standard = 'standard'; + case Express = 'express'; +} diff --git a/packages/openapi/tests/NestedFixture/LineOptionRequest.php b/packages/openapi/tests/NestedFixture/LineOptionRequest.php new file mode 100644 index 0000000..2467b79 --- /dev/null +++ b/packages/openapi/tests/NestedFixture/LineOptionRequest.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\NestedFixture; + +use Firefly\Validation\Constraint\Min; +use Firefly\Validation\Constraint\NotBlank; + +/** + * The THIRD level of the graph: reached only through `CreateOrderRequest -> lines[] -> options[]`, so it + * exists to prove that a component is emitted at a depth no single #[Valid] cascade reaches. ConstraintScanner + * flattens exactly one #[Valid] level and never cascades through an `array` member at all, so every rule on + * this class reaches the document through its OWN manifest entry or not at all. + */ +final class LineOptionRequest +{ + /** + * `$notes` holds a list of SCALARS, which is the case both element-type paths deliberately decline to + * answer: `string` is not a class, so RouteScanner leaves the member out of its hydration table and the + * generator leaves `items` off rather than emit one only the reflection path could produce. + * + * @param list<string> $notes + */ + public function __construct( + #[NotBlank] public readonly string $code, + #[Min(0)] public readonly int $surchargeMinor = 0, + public readonly array $notes = [], + ) {} +} diff --git a/packages/openapi/tests/NestedFixture/OrderController.php b/packages/openapi/tests/NestedFixture/OrderController.php new file mode 100644 index 0000000..e4af2fc --- /dev/null +++ b/packages/openapi/tests/NestedFixture/OrderController.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\NestedFixture; + +use Firefly\Validation\Valid; +use Firefly\Web\Attributes\PostMapping; +use Firefly\Web\Attributes\RequestBody; +use Firefly\Web\Attributes\RequestMapping; +use Firefly\Web\Attributes\RestController; + +/** + * Orders. + * + * The controller exists so the nested fixtures are reached the way an application reaches them — through a + * real #[RequestBody] binding compiled by the real RouteScanner, whose `dtos` table is the element-type + * source the generator consumes. + */ +#[RestController] +#[RequestMapping('/api/orders')] +final class OrderController +{ + /** + * Places an order. + * + * @return array<string, mixed> + */ + #[PostMapping(status: 201, name: 'nested.orders.create')] + public function create(#[Valid] #[RequestBody] CreateOrderRequest $body): array + { + return ['reference' => $body->reference]; + } +} diff --git a/packages/openapi/tests/NestedFixture/OrderLineRequest.php b/packages/openapi/tests/NestedFixture/OrderLineRequest.php new file mode 100644 index 0000000..42d5069 --- /dev/null +++ b/packages/openapi/tests/NestedFixture/OrderLineRequest.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\NestedFixture; + +use Firefly\Validation\Constraint\Min; +use Firefly\Validation\Constraint\NotBlank; +use Firefly\Validation\Valid; + +/** One line of an order. */ +final class OrderLineRequest +{ + /** + * @param list<LineOptionRequest> $options Per-line options, each surcharged separately. + */ + public function __construct( + #[NotBlank] public readonly string $sku, + #[Min(1)] public readonly int $quantity, + public readonly Fulfilment $fulfilment, + #[Valid] public readonly array $options = [], + ) {} +} diff --git a/skeleton/app/Http/AddressPayload.php b/skeleton/app/Http/AddressPayload.php new file mode 100644 index 0000000..af921ae --- /dev/null +++ b/skeleton/app/Http/AddressPayload.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace App\Http; + +use Firefly\Validation\Constraint\CountryCode; +use Firefly\Validation\Constraint\NotBlank; +use Firefly\Validation\Constraint\Pattern; +use Firefly\Validation\Constraint\Size; + +/** + * Where an order ships to. + * + * `country` is an ISO 3166-1 alpha-2 code (`GB`, `ES`, `NL`); `postcode` is validated loosely enough to + * accept the common European and North American formats. + */ +final readonly class AddressPayload +{ + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED AS THE SCHEMA DESCRIPTION in /openapi.json; line comments + // like this one are not. + // + // THE NESTED HALF OF OrderRequest, and the reason it is a class rather than four flattened `shipTo*` + // fields. `#[Valid] AddressPayload $shipTo` on OrderRequest makes the ConstraintScanner cascade the + // rules below into DOT KEYS — `shipTo.street`, `shipTo.postcode`, … — so a bad postcode comes back as a + // 422 whose field error names the exact JSON path the client sent, and firefly/openapi emits this class + // as its own reusable component that the order schema $refs. + // + // #[CountryCode] is one of the first-party constraint OBJECTS (alongside #[Iban], #[Bic], #[Luhn], + // #[Isin], #[PostalCode] and friends): a real ISO 3166-1 alpha-2 membership check, not a regex that + // merely looks like one. + + public function __construct( + #[NotBlank] + #[Size(max: 120)] + public string $street, + #[NotBlank] + #[Size(max: 80)] + public string $city, + #[NotBlank] + #[Pattern('/^[A-Z0-9][A-Z0-9 -]{2,9}$/D')] + public string $postcode, + #[NotBlank] + #[CountryCode] + public string $country, + ) {} +} diff --git a/skeleton/app/Http/OrderController.php b/skeleton/app/Http/OrderController.php new file mode 100644 index 0000000..090aa9a --- /dev/null +++ b/skeleton/app/Http/OrderController.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +namespace App\Http; + +use App\Orders\Address; +use App\Orders\Order; +use App\Orders\OrderLine; +use App\Orders\OrderService; +use Firefly\Validation\Valid; +use Firefly\Web\Attributes\DeleteMapping; +use Firefly\Web\Attributes\GetMapping; +use Firefly\Web\Attributes\PathVariable; +use Firefly\Web\Attributes\PostMapping; +use Firefly\Web\Attributes\PutMapping; +use Firefly\Web\Attributes\QueryParam; +use Firefly\Web\Attributes\RequestBody; +use Firefly\Web\Attributes\RequestMapping; +use Firefly\Web\Attributes\RestController; + +/** + * Customer orders: list, read, place, replace and cancel. + * + * An order carries the buyer, a shipping address and one or more lines; its `total` is derived from those + * lines and is never accepted from the client. An unknown id is answered with an RFC-7807 problem document + * whose `code` is `ORDER_NOT_FOUND`. + */ +#[RestController] +#[RequestMapping('/orders')] +final class OrderController +{ + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED, THIS COMMENT IS NOT. firefly/openapi uses a controller's + // class docblock as the tag description in /openapi.json and each action's docblock as that + // operation's summary and description, so anything written there is read by whoever consumes this + // API. Line comments like this one are invisible to the generator, which is why the notes on how the + // framework serves this resource live here. + // + // THE SAMPLE REST RESOURCE — what `php artisan make:firefly-controller OrderController` generates, + // filled in. Read it as five statements about the framework: + // + // * ROUTING. One class-level #[RequestMapping] fixes the collection path; each action adds only its own + // suffix and verb. Nothing is registered in routes/web.php — `firefly:cache` compiles all five into + // the manifest the dispatcher reads, and `php artisan firefly:routes` lists them. + // * BINDING. #[PathVariable] and #[QueryParam] bind AND coerce (`/orders/7` arrives as an int), and + // #[RequestBody] decodes JSON into a DTO — including the nested AddressPayload and the list of + // OrderLinePayloads, built from a shape table compiled at cache time so the request path never + // reflects. + // * VALIDATION. #[Valid] runs the compiled constraints BEFORE hydration, so an invalid body is a 422 + // with per-field errors (`shipTo.postcode`) and never reaches these method bodies. There is no + // FormRequest and no `$request->validate(...)` call anywhere. + // * STATUS. 201 on `store` and 204 on `destroy` are declared on the mapping, not built by hand; a + // `void` action is how you say "no body". + // * ERRORS. Nothing here handles a missing order. OrderService throws ResourceNotFoundException and + // firefly/web renders the whole FireflyException taxonomy as problem+json at the exception's own + // status — a 404 with a stable error code, from zero lines of error handling in this class. + // + // WHY IT MAPS INSTEAD OF FORWARDING THE DTO. toOrder() translates the HTTP payloads into App\Orders + // types rather than passing OrderRequest down into the service. It costs six lines and buys the property + // that nothing under App\Orders imports anything under App\Http: the use cases can be driven from a + // console command or a queued job, and the wire format can change without the domain noticing. In a + // slice this small the two shapes look almost identical — which is exactly when the habit is cheap to + // form. + // + // `/` belongs to App\Http\WelcomeController, a #[Controller] that renders HTML; every action here + // returns a value the ResponseFactory negotiates into JSON. That is the difference between the two + // stereotypes. + + /** The largest page a client may ask for, so `?size=100000` cannot force the whole store out at once. */ + private const int MAX_PAGE_SIZE = 100; + + public function __construct(private readonly OrderService $orders) {} + + /** + * List orders, newest id last, one page at a time. + * + * @return array{page: int, size: int, total: int, items: list<Order>} + */ + #[GetMapping(name: 'orders.index')] + public function index( + // #[QueryParam] CARRIES ITS DEFAULT TWICE, and the repetition is load-bearing. RouteScanner compiles + // the binding's fallback from the ATTRIBUTE, never from the PHP default value: a parameter default + // is not reachable from the compiled, reflection-free plan the ArgumentResolver reads per request. + // Write only `int $page = 1` and an absent `?page` binds null, which then fails against the `int` in + // this very signature — a 500 for a request that merely omitted an optional parameter. + #[QueryParam(default: 1)] int $page = 1, + #[QueryParam(default: 20)] int $size = 20, + ): array { + return $this->orders->page(max(1, $page), min(self::MAX_PAGE_SIZE, max(1, $size))); + } + + /** Read one order by id. */ + #[GetMapping('/{id}', name: 'orders.show')] + public function show(#[PathVariable] int $id): Order + { + return $this->orders->find($id); + } + + /** Place a new order. Responds 201 with the stored order, including its assigned id and its total. */ + #[PostMapping(status: 201, name: 'orders.store')] + public function store(#[Valid] #[RequestBody] OrderRequest $request): Order + { + return $this->orders->place($this->toOrder(null, $request)); + } + + /** + * Replace an order wholesale, keeping its id. Takes the same body as placing one. + */ + #[PutMapping('/{id}', name: 'orders.update')] + public function update(#[PathVariable] int $id, #[Valid] #[RequestBody] OrderRequest $request): Order + { + // The same DTO as `store` on purpose: a PUT that accepted a laxer shape than the POST is how a + // resource ends up with two contradictory schemas in its own OpenAPI document. + return $this->orders->replace($id, $this->toOrder($id, $request)); + } + + /** Cancel an order. Responds 204 with an empty body. */ + #[DeleteMapping('/{id}', status: 204, name: 'orders.destroy')] + public function destroy(#[PathVariable] int $id): void + { + $this->orders->cancel($id); + } + + /** + * The one place the wire format meets the domain. + * + * Private, so the RouteScanner — which only reads PUBLIC methods — can never mistake it for an action. + */ + private function toOrder(?int $id, OrderRequest $request): Order + { + return new Order( + $id, + $request->customer, + $request->email, + new Address( + $request->shipTo->street, + $request->shipTo->city, + $request->shipTo->postcode, + $request->shipTo->country, + ), + array_values(array_map( + static fn (OrderLinePayload $line): OrderLine => new OrderLine($line->sku, $line->quantity, $line->unitPrice), + $request->lines, + )), + ); + } +} diff --git a/skeleton/app/Http/OrderLinePayload.php b/skeleton/app/Http/OrderLinePayload.php new file mode 100644 index 0000000..3e1dcfd --- /dev/null +++ b/skeleton/app/Http/OrderLinePayload.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace App\Http; + +use Firefly\Validation\Constraint\Max; +use Firefly\Validation\Constraint\NotBlank; +use Firefly\Validation\Constraint\Pattern; +use Firefly\Validation\Constraint\Positive; +use Firefly\Validation\Constraint\PositiveOrZero; + +/** + * One line of an order: a product, how many of it, and the price per unit at the time of ordering. + */ +final readonly class OrderLinePayload +{ + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED AS THE SCHEMA DESCRIPTION in /openapi.json; line comments + // like this one are not. + // + // THE ELEMENT TYPE OF OrderRequest's LIST, and the one shape PHP cannot describe on its own. An `array` + // carries no element type, so the ONLY place the framework can learn that `$lines` holds these is the + // `@param list<OrderLinePayload> $lines` tag on OrderRequest's constructor. RouteScanner reads that tag + // at cache time and records it in the route's binding plan, which is what lets the ArgumentResolver + // build each element as an OrderLinePayload instead of handing the controller a bag of raw arrays. + // Delete the tag and the framework has nothing to go on: the sub-arrays reach this constructor, which + // refuses them, and the request is answered with a 400 naming `lines[0]`. + + public function __construct( + #[NotBlank] + #[Pattern('/^[A-Z0-9][A-Z0-9-]{2,31}$/D')] + public string $sku, + #[Positive] + #[Max(999)] + public int $quantity, + #[PositiveOrZero] + public float $unitPrice, + ) {} +} diff --git a/skeleton/app/Http/OrderRequest.php b/skeleton/app/Http/OrderRequest.php new file mode 100644 index 0000000..f59a601 --- /dev/null +++ b/skeleton/app/Http/OrderRequest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace App\Http; + +use Firefly\Validation\Constraint\Email; +use Firefly\Validation\Constraint\NotBlank; +use Firefly\Validation\Constraint\NotEmpty; +use Firefly\Validation\Constraint\NotNull; +use Firefly\Validation\Constraint\Size; +use Firefly\Validation\Valid; + +/** + * An order to be placed or replaced: who is buying, where it ships, and what is on it. + * + * `lines` must hold between 1 and 50 entries. `shipTo` is required in full — its own fields are validated + * and reported under their dotted paths (`shipTo.postcode`). + */ +final readonly class OrderRequest +{ + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED AS THE SCHEMA DESCRIPTION in /openapi.json; line comments + // like this one are not. Describe the payload up there for the people who will send it; keep the + // framework notes down here. + // + // THE THREE SHAPES ON PURPOSE — a scalar, a NESTED DTO and a LIST of DTOs — because those exercise every + // part of the pipeline at once: + // + // * HYDRATION. RouteScanner compiles a shape table for this class at cache time (the one sanctioned + // reflection site in firefly/web), so the per-request ArgumentResolver builds `shipTo` as an + // AddressPayload and every element of `lines` as an OrderLinePayload without reflecting at all. The + // list element type comes from the `@param list<OrderLinePayload>` tag below and NOWHERE else. + // * VALIDATION. `#[Valid]` on `$shipTo` makes the ConstraintScanner cascade AddressPayload's rules into + // dot keys, so a bad postcode is reported as `shipTo.postcode` — the path the client actually sent. + // * DOCUMENTATION. firefly/openapi reads the same compiled rules and the same shape table, so + // `required`, `maxLength`, the `$ref` to AddressPayload and the `items: $ref` to OrderLinePayload all + // appear in /openapi.json without one annotation written for the document's benefit. + // + // ONE HONEST LIMIT, worth knowing before copying this shape. The compiled #[Valid] cascade descends into + // a class-typed property; it does not descend into the ELEMENTS of a list. `lines` is therefore checked + // as a list — present, non-empty, at most 50 entries — and each element is hydrated into an + // OrderLinePayload, but OrderLinePayload's own #[Positive]/#[Pattern] rules are not run by the cascade. + // A malformed element still fails, because the element's constructor refuses it and the resolver turns + // that into a 400 naming `lines[0]`; it simply arrives as a 400 "could not bind" rather than a 422 with + // per-field errors. + // + // WHY EVERY REQUIRED PROPERTY ALSO CARRIES #[NotNull] OR #[NotEmpty]. Rule OBJECTS (#[Size], + // #[CountryCode]) are not "implicit" to Illuminate, so they are skipped entirely for a key that is + // absent: a body with no `lines` at all would sail past a lone #[Size(min: 1)] and then fail in the + // constructor as a 400. The two implicit constraints are what turn a missing required field back into + // the 422 it should be. + + /** + * @param list<OrderLinePayload> $lines + */ + public function __construct( + #[NotBlank] + #[Size(max: 120)] + public string $customer, + #[NotBlank] + #[Email] + public string $email, + #[NotNull] + #[Valid] + public AddressPayload $shipTo, + #[NotEmpty] + #[Size(min: 1, max: 50)] + public array $lines, + ) {} +} diff --git a/skeleton/app/Orders/Address.php b/skeleton/app/Orders/Address.php new file mode 100644 index 0000000..bd71025 --- /dev/null +++ b/skeleton/app/Orders/Address.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace App\Orders; + +/** + * A postal address, as the domain understands it. + * + * It is deliberately a DIFFERENT class from App\Http\AddressPayload, which is the same four fields as they + * arrive over HTTP. The duplication is the point: the payload carries validation attributes and is shaped by + * the wire format, this one is shaped by the domain and is free to change without breaking a published API. + * App\Http\OrderController owns the translation between them, which is why nothing under App\Orders imports + * anything from App\Http — a dependency direction worth keeping as the application grows. + */ +final readonly class Address +{ + public function __construct( + public string $street, + public string $city, + public string $postcode, + public string $country, + ) {} +} diff --git a/skeleton/app/Orders/Order.php b/skeleton/app/Orders/Order.php new file mode 100644 index 0000000..f99a98b --- /dev/null +++ b/skeleton/app/Orders/Order.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +namespace App\Orders; + +use JsonSerializable; + +/** + * An order: who placed it, where it ships, and what is on it. + * + * IMPLEMENTS JsonSerializable ON PURPOSE. A controller action may return any value; the ResponseFactory + * hands it to the negotiated MessageConverter, and the JSON converter honours JsonSerializable natively. So + * the wire shape of an order is declared ONCE, here, next to the data — every action that returns an order + * gets the same representation, and adding a field cannot leave one endpoint out of step with another. Note + * that `total` is part of that shape even though it is a method: json_encode() only sees public properties, + * so a derived value has to be published deliberately. + * + * The id is nullable because an Order exists before it is stored — `OrderController::store()` builds one + * with no id and OrderRepository::save() returns the stored copy that has one. Modelling "not yet + * persisted" as a null id rather than as a second class keeps one type in play across the whole slice. + */ +final readonly class Order implements JsonSerializable +{ + /** + * @param list<OrderLine> $lines + */ + public function __construct( + public ?int $id, + public string $customer, + public string $email, + public Address $shipTo, + public array $lines, + ) {} + + /** The order's value, derived from its lines rather than stored beside them. */ + public function total(): float + { + return round(array_sum(array_map(static fn (OrderLine $line): float => $line->subtotal(), $this->lines)), 2); + } + + /** The stored copy of an order that had no id yet. */ + public function withId(int $id): self + { + return new self($id, $this->customer, $this->email, $this->shipTo, $this->lines); + } + + /** @return array<string, mixed> */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'customer' => $this->customer, + 'email' => $this->email, + 'shipTo' => $this->shipTo, + 'lines' => $this->lines, + 'total' => $this->total(), + ]; + } +} diff --git a/skeleton/app/Orders/OrderLine.php b/skeleton/app/Orders/OrderLine.php new file mode 100644 index 0000000..100c279 --- /dev/null +++ b/skeleton/app/Orders/OrderLine.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace App\Orders; + +/** + * One line of an order: what was bought, how many, and at what unit price. + * + * `subtotal()` is here rather than in the controller or the service because it is a fact about a line, not + * about a request or a use case — the small habit that keeps a domain model from decaying into a bag of + * public properties with all the behaviour somewhere else. + */ +final readonly class OrderLine +{ + public function __construct( + public string $sku, + public int $quantity, + public float $unitPrice, + ) {} + + public function subtotal(): float + { + return round($this->quantity * $this->unitPrice, 2); + } +} diff --git a/skeleton/app/Orders/OrderRepository.php b/skeleton/app/Orders/OrderRepository.php new file mode 100644 index 0000000..0e2cebb --- /dev/null +++ b/skeleton/app/Orders/OrderRepository.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace App\Orders; + +use Firefly\Container\Attributes\Repository; + +/** + * The order store, as a #[Repository] bean. + * + * WHY IT IS IN MEMORY AND NOT ELOQUENT. A skeleton has to work the instant `composer create-project` + * finishes. That sequence runs `key:generate` and `firefly:cache` — it does not run `migrate` — so a sample + * backed by EloquentRepository would answer its very first request with "no such table: orders", and the + * first thing a new user would learn about the framework is how its 500 page looks. An array is the only + * store that is honest about having nothing set up yet, and swapping it out is the exercise: extend + * Firefly\Data\Repository\EloquentRepository, point `$model` at an Eloquent model, and every method below + * disappears in favour of the inherited save/findById/findAll/count/delete plus derived queries such as + * `findByCustomer(...)` parsed straight from the method name. + * + * The state survives between requests because a stereotype is registered as a SINGLETON: the component scan + * finds #[Repository] (it specialises #[Component]) and the container resolves one instance per application. + * That also means the data lives for exactly as long as the PHP process — a page reload under `artisan serve` + * keeps it, a fresh process does not. That is a property of the array, not of the framework. + * + * Deliberately NOT final: `firefly:cache` emits a #[Transactional] proxy that `extends` the annotated class, + * so the moment a method here gains #[Transactional] a final class would stop the compile dead. + */ +#[Repository] +class OrderRepository +{ + /** @var array<int, Order> */ + private array $orders = []; + + private int $nextId = 1; + + /** Stores a new order and returns the stored copy — the one that has an id. */ + public function save(Order $order): Order + { + $stored = $order->withId($this->nextId++); + $this->orders[(int) $stored->id] = $stored; + + return $stored; + } + + /** Replaces an existing order wholesale, keeping its id. Returns null when there is nothing to replace. */ + public function replace(int $id, Order $order): ?Order + { + if (! isset($this->orders[$id])) { + return null; + } + + return $this->orders[$id] = $order->withId($id); + } + + public function find(int $id): ?Order + { + return $this->orders[$id] ?? null; + } + + public function delete(int $id): bool + { + if (! isset($this->orders[$id])) { + return false; + } + + unset($this->orders[$id]); + + return true; + } + + public function count(): int + { + return count($this->orders); + } + + /** + * One page of orders, oldest id first. $page is 1-based because that is what a URL says. + * + * @return list<Order> + */ + public function page(int $page, int $size): array + { + $rows = array_values($this->orders); + usort($rows, static fn (Order $a, Order $b): int => (int) $a->id <=> (int) $b->id); + + return array_slice($rows, max(0, $page - 1) * $size, $size); + } +} diff --git a/skeleton/app/Orders/OrderService.php b/skeleton/app/Orders/OrderService.php new file mode 100644 index 0000000..8e2466f --- /dev/null +++ b/skeleton/app/Orders/OrderService.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +namespace App\Orders; + +use Firefly\Container\Attributes\Service; +use Firefly\Kernel\Exception\Business\ResourceNotFoundException; + +/** + * The order use cases, as a #[Service] bean: auto-registered as a singleton and resolved through the + * container, so its OrderRepository dependency is autowired by constructor type. No provider, no binding, + * no `$this->app->singleton(...)` anywhere in the application. + * + * WHY "NOT FOUND" IS THROWN HERE AND NOT HANDLED IN THE CONTROLLER. ResourceNotFoundException is a + * FireflyException carrying its own HTTP status (404), error code and category, and firefly/web registers an + * RFC-7807 renderable for the whole taxonomy at boot. Throwing it from the use case therefore produces a + * `application/problem+json` 404 with a stable `errorCode` — the same shape every other Firefly error takes + * — without a try/catch, an #[ExceptionHandler], or an `if (! $order) return response(..., 404)` in any of + * the three actions that need it. The controller stays a mapping layer; the domain decides what "missing" + * means. + * + * It takes and returns DOMAIN types only. The HTTP payloads live in App\Http and are translated by the + * controller, so this class could be driven from a console command, a queued job or a CQRS handler without + * dragging a request DTO along. + */ +#[Service] +final class OrderService +{ + public function __construct(private readonly OrderRepository $orders) {} + + /** + * @return array{page: int, size: int, total: int, items: list<Order>} + */ + public function page(int $page, int $size): array + { + return [ + 'page' => $page, + 'size' => $size, + 'total' => $this->orders->count(), + 'items' => $this->orders->page($page, $size), + ]; + } + + /** @throws ResourceNotFoundException when no order carries that id */ + public function find(int $id): Order + { + return $this->orders->find($id) ?? throw $this->missing($id); + } + + public function place(Order $order): Order + { + return $this->orders->save($order); + } + + /** @throws ResourceNotFoundException when no order carries that id */ + public function replace(int $id, Order $order): Order + { + return $this->orders->replace($id, $order) ?? throw $this->missing($id); + } + + /** @throws ResourceNotFoundException when no order carries that id */ + public function cancel(int $id): void + { + if (! $this->orders->delete($id)) { + throw $this->missing($id); + } + } + + /** + * One place builds the exception so the message and the error code cannot drift between the three + * callers — a 404 whose `errorCode` varies by endpoint is one a client cannot branch on. + */ + private function missing(int $id): ResourceNotFoundException + { + return new ResourceNotFoundException(sprintf('Order %d does not exist.', $id), 'ORDER_NOT_FOUND'); + } +} diff --git a/skeleton/tests/Feature/OrderTest.php b/skeleton/tests/Feature/OrderTest.php new file mode 100644 index 0000000..a95d053 --- /dev/null +++ b/skeleton/tests/Feature/OrderTest.php @@ -0,0 +1,177 @@ +<?php + +declare(strict_types=1); + +namespace Tests\Feature; + +use Tests\TestCase; + +/** + * The sample REST resource, end to end. + * + * `app/Http/OrderController.php` is what `php artisan make:firefly-controller OrderController` generates, + * filled in — so these cases double as the documentation for what the generator gives you: five routes on a + * derived collection path, a validated request body with a nested DTO and a list of DTOs, declared 201/204 + * statuses, and an RFC-7807 404 that no line of controller code produces. + * + * The store is in memory (see App\Orders\OrderRepository for why a skeleton must not assume a migrated + * database), and the repository is a singleton, so state persists across the requests WITHIN one test. Each + * test creates whatever it needs rather than relying on another test's leftovers, because PHPUnit gives no + * ordering guarantee and a fresh application is booted per test. + */ +final class OrderTest extends TestCase +{ + /** + * @param array<string, mixed> $overrides + * @return array<string, mixed> + */ + private function body(array $overrides = []): array + { + return [ + 'customer' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'shipTo' => [ + 'street' => '12 Analytical Way', + 'city' => 'London', + 'postcode' => 'W1A 1AA', + 'country' => 'GB', + ], + 'lines' => [ + ['sku' => 'WIDGET-1', 'quantity' => 2, 'unitPrice' => 9.5], + ['sku' => 'GEAR-77', 'quantity' => 1, 'unitPrice' => 3.25], + ], + ...$overrides, + ]; + } + + public function test_it_creates_an_order_with_a_nested_address_and_a_list_of_lines(): void + { + $response = $this->postJson('/orders', $this->body()); + + // 201 is declared on #[PostMapping(status: 201)]; nothing in the controller builds a response. + $response->assertStatus(201) + ->assertJsonPath('customer', 'Ada Lovelace') + // The nested payload was hydrated into an AddressPayload and mapped to the domain Address. + ->assertJsonPath('shipTo.city', 'London') + // Each element of `lines` became an OrderLinePayload — `total` is computed from the OrderLine + // objects built from them, so a raw sub-array reaching the domain would show up right here. + ->assertJsonPath('lines.0.sku', 'WIDGET-1') + ->assertJsonPath('total', 22.25); + + $this->assertIsInt($response->json('id')); + } + + public function test_it_reads_lists_replaces_and_deletes_an_order(): void + { + $id = $this->postJson('/orders', $this->body())->json('id'); + + // #[PathVariable] binds AND coerces: the URL segment is a string, the action takes an int. + $this->getJson('/orders/'.$id) + ->assertOk() + ->assertJsonPath('id', $id) + ->assertJsonPath('email', 'ada@example.com'); + + $this->getJson('/orders?page=1&size=5') + ->assertOk() + ->assertJsonPath('page', 1) + ->assertJsonPath('size', 5) + ->assertJsonPath('items.0.id', $id); + + // PUT is a full replacement and takes the SAME DTO as POST, so the same rules apply to both. + $this->putJson('/orders/'.$id, $this->body(['customer' => 'Grace Hopper'])) + ->assertOk() + ->assertJsonPath('customer', 'Grace Hopper'); + + // A `void` action plus #[DeleteMapping(status: 204)] is how you say "no body". + $this->deleteJson('/orders/'.$id)->assertNoContent(); + $this->getJson('/orders/'.$id)->assertStatus(404); + } + + public function test_it_defaults_both_paging_parameters_when_the_query_string_omits_them(): void + { + // A #[QueryParam]'s fallback is compiled from the ATTRIBUTE, never from the PHP default value — + // which is why the controller writes `#[QueryParam(default: 1)] int $page = 1`. Without the + // attribute default an absent `?page` binds null and fails against the `int` in the signature. + $this->getJson('/orders') + ->assertOk() + ->assertJsonPath('page', 1) + ->assertJsonPath('size', 20); + } + + public function test_it_clamps_an_oversized_page_size(): void + { + // MAX_PAGE_SIZE is what stops `?size=100000` pushing the whole store through one response. The + // echoed `size` is the CLAMPED value the service was actually called with. + $this->getJson('/orders?size=100000') + ->assertOk() + ->assertJsonPath('size', 100); + + // And the lower bound: a nonsensical page or size is floored at 1, never reaching the repository as + // a negative offset. + $this->getJson('/orders?page=0&size=0') + ->assertOk() + ->assertJsonPath('page', 1) + ->assertJsonPath('size', 1); + } + + public function test_it_pages_past_the_first_page(): void + { + // One order cannot tell a real 1-based offset from a repository that always returns the head of the + // list, so this case creates three and asks for the second page. + $ids = []; + foreach (['Ada Lovelace', 'Grace Hopper', 'Alan Turing'] as $customer) { + $ids[] = $this->postJson('/orders', $this->body(['customer' => $customer]))->json('id'); + } + + $this->getJson('/orders?page=1&size=2') + ->assertOk() + ->assertJsonPath('total', 3) + ->assertJsonCount(2, 'items') + ->assertJsonPath('items.0.id', $ids[0]); + + // The second page is the REMAINDER — one row, the third id — not the first two over again. + $this->getJson('/orders?page=2&size=2') + ->assertOk() + ->assertJsonCount(1, 'items') + ->assertJsonPath('items.0.id', $ids[2]); + + // Past the end is an empty page, not a wrapped one. + $this->getJson('/orders?page=9&size=2') + ->assertOk() + ->assertJsonCount(0, 'items'); + } + + public function test_it_rejects_an_invalid_nested_field_with_a_422_naming_the_dotted_path(): void + { + $body = $this->body(); + $body['shipTo']['country'] = 'XX'; + + $response = $this->postJson('/orders', $body); + + // #[Valid] on the nested AddressPayload makes the constraint scanner compile its rules under dot + // keys, so the client is told `shipTo.country` — the exact path it sent. + $response->assertStatus(422); + $this->assertContains('shipTo.country', array_column((array) $response->json('errors'), 'field')); + } + + public function test_it_rejects_a_missing_required_field_with_a_422(): void + { + $body = $this->body(); + unset($body['lines']); + + $response = $this->postJson('/orders', $body); + + $response->assertStatus(422); + $this->assertContains('lines', array_column((array) $response->json('errors'), 'field')); + } + + public function test_an_unknown_order_is_an_rfc_7807_problem_document(): void + { + // OrderService throws ResourceNotFoundException; firefly/web renders the whole FireflyException + // taxonomy as problem+json at the exception's own status. The controller handles nothing. + $this->getJson('/orders/424242') + ->assertStatus(404) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'ORDER_NOT_FOUND'); + } +} diff --git a/skeleton/tests/Feature/WelcomeTest.php b/skeleton/tests/Feature/WelcomeTest.php index 92208d1..f6f1023 100644 --- a/skeleton/tests/Feature/WelcomeTest.php +++ b/skeleton/tests/Feature/WelcomeTest.php @@ -8,9 +8,9 @@ /** * The skeleton shipped no test suite at all, while its README referenced a tests/ directory and a Pest - * plugin that did not exist. These two cases are the smoke test a new application should start from: the - * HTML stereotype renders, and the JSON stereotype negotiates — the difference between #[Controller] and - * #[RestController]. + * plugin that did not exist. These cases are the smoke test a new application should start from: the HTML + * stereotype renders, and the JSON stereotype negotiates — the difference between #[Controller] and + * #[RestController]. The sample REST resource has its own file, OrderTest. */ final class WelcomeTest extends TestCase { @@ -32,6 +32,18 @@ public function test_the_sample_rest_controller_returns_json(): void ->assertExactJson(['message' => 'Hello, Ada!']); } + public function test_the_welcome_page_lists_the_sample_resource(): void + { + // The page enumerates the RouteManifest the dispatcher itself reads, so this is a check that the + // sample resource is genuinely compiled and routable — not that a string was hard-coded in a view. + $response = $this->get('/'); + + $response->assertOk(); + $response->assertSee('/orders', false); + $response->assertSee('/orders/{id}', false); + $response->assertSee('OrderController', false); + } + public function test_the_actuator_reports_health(): void { $this->getJson('/actuator/health') diff --git a/tests/Psr4LayoutTest.php b/tests/Psr4LayoutTest.php new file mode 100644 index 0000000..b9cdf37 --- /dev/null +++ b/tests/Psr4LayoutTest.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/** + * Every src file must declare the namespace its package prefix and directory imply. + * + * This exists because a stray file once landed in the wrong package: a byte-identical copy of + * packages/openapi/src/Schema/MemberType.php appeared at packages/admin/src/Data/MemberType.php, still + * declaring `namespace Firefly\OpenApi\Schema`. Composer's PSR-4 autoloader will not find a class there, so + * nothing broke at runtime and nothing failed in the package's own suite — it surfaced only as a duplicate + * class in a repo-wide static analysis run, which is a long way from the mistake. + * + * A misfiled class is also how a package silently acquires a dependency it never declared, which deptrac + * cannot see because the file claims to belong to the other layer. + */ +it('declares a namespace matching the package prefix and directory for every src file', function () { + $mismatches = []; + + foreach (glob(dirname(__DIR__).'/packages/*/composer.json') ?: [] as $composer) { + $package = dirname($composer); + /** @var mixed $json */ + $json = json_decode((string) file_get_contents($composer), true); + + $autoload = is_array($json) ? ($json['autoload'] ?? null) : null; + $declared = is_array($autoload) ? ($autoload['psr-4'] ?? null) : null; + + /** @var array<string, string> $psr4 */ + $psr4 = is_array($declared) ? $declared : []; + + foreach ($psr4 as $prefix => $relative) { + $root = $package.'/'.rtrim($relative, '/'); + if (! is_dir($root)) { + continue; + } + + /** @var iterable<SplFileInfo> $files */ + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $source = (string) file_get_contents($file->getPathname()); + if (preg_match('/^namespace\s+([^;]+);/m', $source, $matches) !== 1) { + continue; + } + + $declared = trim($matches[1]).'\\'; + $sub = str_replace('/', '\\', dirname(substr($file->getPathname(), strlen($root) + 1))); + $expected = rtrim(rtrim($prefix, '\\').'\\'.($sub === '.' ? '' : $sub), '\\').'\\'; + + if ($declared !== $expected) { + $mismatches[] = sprintf('%s declares %s, expected %s', $file->getPathname(), $declared, $expected); + } + } + } + } + + expect($mismatches)->toBe([]); +}); From e1d867be123b6c0424f8827bba725c833d77e903 Mon Sep 17 00:00:00 2001 From: Andres Contreras <andres.contreras@soon.es> Date: Thu, 3 Sep 2026 18:22:20 -0700 Subject: [PATCH 18/31] fix(skeleton): a created project actually ships the dashboard, the API docs and a working REST resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by installing the skeleton instead of reading it. Three defects, none of which any suite could see, because a package suite runs from the monorepo where every source file is already on disk. 1. NOTHING REQUIRED firefly/admin OR firefly/openapi `composer create-project firefly/skeleton` resolved 260 packages and neither of those was among them. The dashboard, the bean graph, the data browser, the OpenAPI document and the Swagger UI were all built, tested, documented in the book and offered by `firefly new --with admin,openapi` while no manifest anywhere pulled them in. The welcome page checks `class_exists()` before it links anything, so it did not render a broken link — it silently rendered two cards fewer, which is a worse failure: nothing looked wrong. Fixed in the BOM rather than in the skeleton, because the asymmetry is the actual bug. CapabilityCatalog offers thirteen non-adapter capabilities; for eleven of them `--with` promotes an already-installed package to an explicit dependency, and the code arrives with firefly/firefly either way. admin and openapi were the only two where `--with` decided whether the code existed at all, so one flag meant two different things depending on which capability you named. Both are now in the BOM and `--with` means one thing. Installed is still not enabled: firefly.admin.enabled defaults to app.debug and the data browser defaults to off, which is the same bargain firefly/actuator already makes. The book, the README and four module pages said "opt-in package" in a way that implied absence; they now say what actually gates it. tests/MetapackageCoverageTest.php holds the line from both ends — every runtime package is reachable from the BOM unless it is on an exclusion list that must name real packages, and every non-adapter capability's package is required by default. Pulling firefly/openapi back out fails both. 2. THE SAMPLE REST RESOURCE DID NOT PERSIST, AND ITS DOCBLOCK SAID IT DID OrderRepository kept orders in an array on a singleton and claimed "the state survives between requests ... a page reload under `artisan serve` keeps it". PHP shares nothing between requests, so it does not. Over real HTTP: POST /orders returned 201 with id 1, and the very next GET /orders reported an empty store. That is the first thing a new user does. The skeleton's own suite passed throughout, because Laravel reuses ONE application across the requests of a single test — `postJson()` then `getJson()` cannot tell an array on a singleton from a database. So the fix is not only the store: OrderTest now asserts against the connection (assertDatabaseHas/Missing/Count), and one case reads the row back through a repository instance built after the write. Reverting the repository to the array now fails five cases instead of zero. OrderRepository became `extends EloquentRepository` with a model name and no method bodies — which is what the framework's own data layer is for, and what the skeleton was conspicuously not demonstrating. The domain value objects stay: OrderEntity is the row, App\Orders\Order is the domain, OrderRequest is the wire, and OrderService is the single place that knows the first two exist. `migrate` joins post-create-project-cmd so `POST /orders` works on the first request rather than after a step nobody mentions. It also fixes the Django-style data browser having nothing to browse: it discovers beans implementing CrudRepository, and a fresh project had exactly zero. Orders now appear as a browsable resource. 3. THE SHIPPED SKELETON'S OWN HARNESS DID NOT HAVE A DATABASE packages/cli's SkeletonExampleTestCase and SkeletonScannedBootTestCase boot skeleton/app on both the cached and the scanned path. They now build the schema by EXECUTING the shipped migration rather than restating it, so a column renamed there fails here — a hand-written CREATE TABLE would have been a second definition of the schema, quietly diverging from the one a real install gets. VERIFIED BY DRIVING IT, twice, from `composer install` through post-create-project-cmd to `artisan serve`: all five CRUD verbs over real HTTP with state surviving between processes (201/200/200/204, 404 carrying ORDER_NOT_FOUND, 422 naming shipTo.postcode), /firefly, /firefly/graph (83 nodes, 83 edges drawn), /openapi with the official swagger-ui dist served at correct content types and traversal refused, /openapi.json emitting OrderRequest with a $ref to AddressPayload and an items-$ref to OrderLinePayload, and /firefly/data 404ing while off then listing real orders once switched on. 1998 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- README.md | 3 +- book/src-es/11-observability-actuator.md | 2 +- book/src/11-observability-actuator.md | 2 +- docs/getting-started.md | 5 +- docs/modules/admin.md | 10 +- docs/modules/bean-graph.md | 2 +- docs/modules/data-browser.md | 2 +- docs/modules/openapi.md | 3 + docs/tutorial.md | 4 +- packages/cli/tests/Support/SkeletonApp.php | 25 ++++ .../tests/Support/SkeletonExampleTestCase.php | 8 +- .../Support/SkeletonScannedBootTestCase.php | 10 +- packages/firefly/composer.json | 2 + packages/installer/src/CapabilityCatalog.php | 15 +- skeleton/README.md | 30 +++- skeleton/app/Orders/Order.php | 8 +- skeleton/app/Orders/OrderEntity.php | 41 ++++++ skeleton/app/Orders/OrderRepository.php | 95 +++++-------- skeleton/app/Orders/OrderService.php | 128 +++++++++++++++--- skeleton/composer.json | 2 + skeleton/config/firefly.php | 48 +++++++ .../0001_01_01_000000_create_orders_table.php | 46 +++++++ skeleton/tests/Feature/OrderTest.php | 64 ++++++++- tests/MetapackageCoverageTest.php | 107 +++++++++++++++ 24 files changed, 545 insertions(+), 117 deletions(-) create mode 100644 skeleton/app/Orders/OrderEntity.php create mode 100644 skeleton/database/migrations/0001_01_01_000000_create_orders_table.php create mode 100644 tests/MetapackageCoverageTest.php diff --git a/README.md b/README.md index faf2012..1c037e7 100644 --- a/README.md +++ b/README.md @@ -625,7 +625,8 @@ built-in indicators, `firefly:health`/`firefly:metrics` actuator-over-CLI — se ### Two browser surfaces, neither of which needs npm or a CDN -`composer require firefly/admin` mounts a server-rendered dashboard at `/firefly`: thirteen pages over the +`firefly/admin` — which arrives with the runtime family — mounts a server-rendered dashboard at `/firefly` +behind `firefly.admin.enabled` (default: `app.debug`): thirteen pages over the actuator's own endpoints — health, metrics, HTTP traffic, beans, a drawn [**bean graph**](docs/modules/bean-graph.md), conditions, routes, scheduled tasks, environment, config properties, caches and loggers. It reads those endpoints **in-process** rather than over HTTP, so it renders diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index fd3e684..f5275a2 100644 --- a/book/src-es/11-observability-actuator.md +++ b/book/src-es/11-observability-actuator.md @@ -652,7 +652,7 @@ Por último, un puerto `Tracer` remata el paquete — una abstracción mínima d ## El panel de administración: `firefly/admin` -Todo lo visto hasta aquí en este capítulo es JSON, y JSON es la forma correcta para un balanceador de carga, una sonda de Kubernetes y un scraper de Prometheus. No es la forma correcta para una persona a las 3 de la madrugada que quiere saber si este proceso compiló sus manifiestos, qué auto-configuración se echó atrás, y a qué resolvió realmente `firefly.data.*`. `firefly/admin` es un tercer paquete opcional para esa persona: un panel de administración renderizado en el servidor sobre esos mismos endpoints del actuator, en el espíritu de Spring Boot Admin. +Todo lo visto hasta aquí en este capítulo es JSON, y JSON es la forma correcta para un balanceador de carga, una sonda de Kubernetes y un scraper de Prometheus. No es la forma correcta para una persona a las 3 de la madrugada que quiere saber si este proceso compiló sus manifiestos, qué auto-configuración se echó atrás, y a qué resolvió realmente `firefly.data.*`. `firefly/admin` es el paquete para esa persona: un panel de administración renderizado en el servidor sobre esos mismos endpoints del actuator, en el espíritu de Spring Boot Admin. Llega con `firefly/firefly` como el resto de la familia, así que un proyecto del esqueleto ya lo tiene — y, igual que con `firefly/actuator`, tenerlo instalado no es lo mismo que tenerlo activado. Añádelo directamente solo si cogiste los paquetes por separado: ```bash composer require firefly/admin diff --git a/book/src/11-observability-actuator.md b/book/src/11-observability-actuator.md index a48940e..0dc7d1e 100644 --- a/book/src/11-observability-actuator.md +++ b/book/src/11-observability-actuator.md @@ -652,7 +652,7 @@ Finally, a `Tracer` port rounds out the package — a minimal `trace(string $nam ## The admin dashboard: `firefly/admin` -Everything so far in this chapter is JSON, and JSON is the right shape for a load balancer, a Kubernetes probe and a Prometheus scraper. It is not the right shape for a person at 3am who wants to know whether this process compiled its manifests, which auto-configuration backed off, and what `firefly.data.*` actually resolved to. `firefly/admin` is a third opt-in package for that person: a server-rendered browser dashboard over the very same actuator endpoints, in the spirit of Spring Boot Admin. +Everything so far in this chapter is JSON, and JSON is the right shape for a load balancer, a Kubernetes probe and a Prometheus scraper. It is not the right shape for a person at 3am who wants to know whether this process compiled its manifests, which auto-configuration backed off, and what `firefly.data.*` actually resolved to. `firefly/admin` is the package for that person: a server-rendered browser dashboard over the very same actuator endpoints, in the spirit of Spring Boot Admin. It arrives with `firefly/firefly` like the rest of the family, so a skeleton project already has it — and, as with `firefly/actuator`, having it installed is not the same as having it switched on. Add it directly only if you took the packages à la carte: ```bash composer require firefly/admin diff --git a/docs/getting-started.md b/docs/getting-started.md index 1999839..d1b71d6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,8 +13,9 @@ php artisan firefly:cache php artisan firefly:serve ``` -`composer create-project` alone already ran `firefly:cache` for you (via `post-create-project-cmd`), so the app -boots reflection-free from the first request; re-run `firefly:cache` whenever you add or change +`composer create-project` alone already ran `migrate` and `firefly:cache` for you (via +`post-create-project-cmd`), so the app boots reflection-free and its sample `POST /orders` persists from the +first request; re-run `firefly:cache` whenever you add or change `#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. classes. `firefly:clear` drops back to the in-process scan, which costs a reflection pass per boot but is functionally identical — every manifest resolves to the compiled artifact if present, otherwise a scan of `firefly.scan.paths`, otherwise empty. diff --git a/docs/modules/admin.md b/docs/modules/admin.md index 24f6b47..ca87191 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -5,12 +5,18 @@ structural difference: it is not a separate monitoring application you deploy an Blade views *inside* the application it reports on, which is why it can read the endpoint registry directly, and why its access model matters as much as it does. +It arrives with the runtime family — `firefly/firefly` requires it, so a `composer create-project +firefly/skeleton` project already has it and `firefly new --with admin` only makes the dependency explicit in +your own `composer.json`. Add it directly if you took the packages à la carte: + ```bash composer require firefly/admin ``` -Then open `/firefly`. It is not part of the `firefly/firefly` metapackage — like `firefly/openapi` and the broker -adapters, it is an opt-in dependency. +Then open `/firefly`. **Installed is not enabled**: `firefly.admin.enabled` defaults to `app.debug`, so the +package being present costs a production deployment nothing. That default, not the absence of the package, is +what stands between the dashboard and the internet — which is why the warning below matters more than the +install line above. !!! warning "The access model is the whole security model" `firefly.admin.enabled` defaults to `app.debug`, because the dashboard bypasses the actuator's diff --git a/docs/modules/bean-graph.md b/docs/modules/bean-graph.md index bf68131..47a3d59 100644 --- a/docs/modules/bean-graph.md +++ b/docs/modules/bean-graph.md @@ -9,7 +9,7 @@ way you expected, when a cycle has hung a boot, or when you are trying to work o installed attached itself to. ``` -composer require firefly/admin # the graph is a page of the dashboard, not a package of its own +composer require firefly/admin # already required by firefly/firefly; the graph is a page of the dashboard, not a package of its own ``` ## What counts as a node diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md index e01b1da..d6d6b27 100644 --- a/docs/modules/data-browser.md +++ b/docs/modules/data-browser.md @@ -14,7 +14,7 @@ It is **off by default, and it does not inherit the dashboard's default.** Read [Known-latent](#known-latent). ```bash -composer require firefly/admin # the browser is part of the dashboard, not a package of its own +composer require firefly/admin # already required by firefly/firefly; the browser is a part of the dashboard, not a package of its own ``` `DataBrowser` is the single entry point, and `DataBrowser::forContainer($container)` assembles one from the diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md index 477e049..c8a5139 100644 --- a/docs/modules/openapi.md +++ b/docs/modules/openapi.md @@ -9,6 +9,9 @@ component. Install the package and a LaraFly app has a spec — and therefore ty Because every fact in the document is read from the same compiled artifacts the dispatcher dispatches from and the validator validates with, **the spec cannot drift from the server**. +`firefly/firefly` requires it, so a skeleton project already serves `/openapi` and `/openapi.json`. Add it +directly if you took the packages à la carte: + ```bash composer require firefly/openapi ``` diff --git a/docs/tutorial.md b/docs/tutorial.md index 1b26e00..814aff1 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -48,7 +48,9 @@ cd my-app `firefly/skeleton`'s `post-create-project-cmd` runs automatically and leaves you with a booting, already-cached app: it copies `.env.example` to `.env`, touches `database/database.sqlite`, runs -`php artisan key:generate`, and runs `php artisan firefly:cache` — the zero-reflection compile step you'll +`php artisan key:generate`, runs `php artisan migrate` (the sample resource stores into an `orders` table, +so `POST /orders` works on the first request rather than after a step you have to be told about), and runs +`php artisan firefly:cache` — the zero-reflection compile step you'll revisit in [Step 11](#step-11-the-zero-reflection-cache-and-health-introspection). See [Installation](installation.md) for the equivalent `firefly new my-app` global-installer shortcut. diff --git a/packages/cli/tests/Support/SkeletonApp.php b/packages/cli/tests/Support/SkeletonApp.php index 1428a10..b6dd125 100644 --- a/packages/cli/tests/Support/SkeletonApp.php +++ b/packages/cli/tests/Support/SkeletonApp.php @@ -53,6 +53,31 @@ public static function path(): string return dirname(__DIR__, 4).'/skeleton/app'; } + /** + * Builds the sample's schema by RUNNING THE SHIPPED MIGRATION, not by restating it here. + * + * App\Orders\OrderRepository is an EloquentRepository over the `orders` table, so the sample resource + * cannot answer a single request without one — and a hand-written CREATE TABLE in this file would be a + * second, quietly diverging definition of the schema a real `composer create-project` gets from + * `artisan migrate`. Executing the migration itself means a column renamed there fails here, which is + * the whole reason the shipped example is in this suite. + */ + public static function migrate(): void + { + $files = glob(dirname(__DIR__, 4).'/skeleton/database/migrations/*.php') ?: []; + + foreach ($files as $file) { + // Laravel migrations are anonymous classes extending Migration, which declares neither up() nor + // down() — the base is a marker and the methods are a convention, so method_exists() is both the + // guard and the only way to tell static analysis this call is real. + $migration = require $file; + + if (is_object($migration) && method_exists($migration, 'up')) { + $migration->up(); + } + } + } + /** * A complete, VALID order body for the sample resource — the shape App\Http\OrderRequest documents, * with a nested address and two lines. diff --git a/packages/cli/tests/Support/SkeletonExampleTestCase.php b/packages/cli/tests/Support/SkeletonExampleTestCase.php index 761c0a4..2b4cf49 100644 --- a/packages/cli/tests/Support/SkeletonExampleTestCase.php +++ b/packages/cli/tests/Support/SkeletonExampleTestCase.php @@ -9,7 +9,8 @@ use Firefly\Cli\Cache\FireflyCachePaths; use Firefly\Cli\Cache\ManifestCacheWriter; use Firefly\Cli\CliServiceProvider; -use Firefly\Testing\FireflyTestCase; +use Firefly\Data\DataServiceProvider; +use Firefly\Testing\FireflyDatabaseTestCase; use Firefly\Validation\ValidationServiceProvider; use Firefly\Web\WebServiceProvider; use Illuminate\Support\ServiceProvider; @@ -35,7 +36,7 @@ * `new class extends FireflyTestCase {...}::class` constructs the class immediately, which throws before * Pest can bind it — the same fix already applied in MakeCommandsTestCase and friends. */ -abstract class SkeletonExampleTestCase extends FireflyTestCase +abstract class SkeletonExampleTestCase extends FireflyDatabaseTestCase { /** The temp dir the compiled manifests are emitted into (shared across the class' tests). */ public static ?string $cacheDir = null; @@ -55,6 +56,8 @@ protected function setUp(): void } parent::setUp(); + + SkeletonApp::migrate(); } public static function tearDownAfterClass(): void @@ -81,6 +84,7 @@ protected function fireflyProviders(): array return [ ValidationServiceProvider::class, WebServiceProvider::class, + DataServiceProvider::class, CliServiceProvider::class, // Last: its unconditional $app->instance() overrides beat every *WiringProvider's bound()-guarded // empty default regardless of ordering, and register() runs before any boot pass resolves a bean. diff --git a/packages/cli/tests/Support/SkeletonScannedBootTestCase.php b/packages/cli/tests/Support/SkeletonScannedBootTestCase.php index ad19b9e..ae97983 100644 --- a/packages/cli/tests/Support/SkeletonScannedBootTestCase.php +++ b/packages/cli/tests/Support/SkeletonScannedBootTestCase.php @@ -4,7 +4,8 @@ namespace Firefly\Cli\Tests\Support; -use Firefly\Testing\FireflyTestCase; +use Firefly\Data\DataServiceProvider; +use Firefly\Testing\FireflyDatabaseTestCase; use Firefly\Validation\ValidationServiceProvider; use Firefly\Web\WebServiceProvider; use Illuminate\Support\ServiceProvider; @@ -22,13 +23,15 @@ * * So: `firefly.scan.paths` set, `firefly.cache.path` pointed at a directory with nothing in it. */ -abstract class SkeletonScannedBootTestCase extends FireflyTestCase +abstract class SkeletonScannedBootTestCase extends FireflyDatabaseTestCase { protected function setUp(): void { SkeletonApp::register(); parent::setUp(); + + SkeletonApp::migrate(); } /** @return list<class-string<ServiceProvider>> */ @@ -37,6 +40,9 @@ protected function fireflyProviders(): array return [ ValidationServiceProvider::class, WebServiceProvider::class, + // The sample repository extends EloquentRepository, so the data layer has to be wired for the + // resource to answer at all — the uncached path resolves the same beans the cached one does. + DataServiceProvider::class, ]; } diff --git a/packages/firefly/composer.json b/packages/firefly/composer.json index a42fdf8..cf2868a 100644 --- a/packages/firefly/composer.json +++ b/packages/firefly/composer.json @@ -23,6 +23,7 @@ "require": { "php": "^8.3", "firefly/actuator": "*@dev", + "firefly/admin": "*@dev", "firefly/autoconfigure": "*@dev", "firefly/cli": "*@dev", "firefly/config": "*@dev", @@ -35,6 +36,7 @@ "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", "firefly/observability": "*@dev", + "firefly/openapi": "*@dev", "firefly/resilience": "*@dev", "firefly/scheduling": "*@dev", "firefly/scheduling-postgres": "*@dev", diff --git a/packages/installer/src/CapabilityCatalog.php b/packages/installer/src/CapabilityCatalog.php index d1e6ad1..d6e5de3 100644 --- a/packages/installer/src/CapabilityCatalog.php +++ b/packages/installer/src/CapabilityCatalog.php @@ -19,10 +19,17 @@ * * The second candidate, "read the list out of the skeleton's composer.json", fails for a different reason: * the skeleton requires exactly `firefly/cli` + `firefly/firefly`. firefly/firefly is the runtime BOM (the - * Composer analog of a Maven BOM) and firefly/cli transitively drags most of the family behind it, so the - * skeleton's require block names TWO packages and describes seventeen. There is no capability list in it to - * read, and reading one would require resolving the dependency graph — i.e. running Composer — before we - * are allowed to ask the user anything. + * Composer analog of a Maven BOM) and drags the whole family behind it, so the skeleton's require block + * names two packages and describes the lot. There is no capability list in it to read, and reading one + * would require resolving the dependency graph — i.e. running Composer — before we are allowed to ask the + * user anything. + * + * WHICH IS ALSO WHY `--with` NEVER DECIDES WHETHER CODE EXISTS. Every non-adapter capability's package is + * required by the BOM, so `--with security` promotes an already-installed package to an explicit dependency + * in the generated composer.json — it does not fetch anything new. That uniformity is asserted by + * tests/MetapackageCoverageTest.php, and it is not free: firefly/admin and firefly/openapi were once + * offered here while the BOM required neither, so `--with admin` was the only way to get the dashboard at + * all and a plain `create-project` silently had none. * * So the map below is owned here, and the rot it invites is handled where it can actually be caught: the * CapabilityCatalogTest enumerates the REAL packages/* directory in the monorepo and fails the build if any diff --git a/skeleton/README.md b/skeleton/README.md index 61493bc..a80e34b 100644 --- a/skeleton/README.md +++ b/skeleton/README.md @@ -18,7 +18,8 @@ cd my-app 1. copies `.env.example` to `.env`, 2. touches the default `database/database.sqlite`, 3. runs `php artisan key:generate` to set `APP_KEY`, -4. runs `php artisan firefly:cache` to compile the app manifests into `bootstrap/cache/firefly/`. +4. runs `php artisan migrate` to create the `orders` table the sample resource stores into, +5. runs `php artisan firefly:cache` to compile the app manifests into `bootstrap/cache/firefly/`. ## The sample slice @@ -29,8 +30,31 @@ cd my-app to JSON. The pair is the difference between the two stereotypes. - `app/GreetingService.php` — a `#[Service]` autowired into the controller. - `app/GreetingProperties.php` — a `#[ConfigProperties('greeting')]` DTO bound from configuration. -- `tests/Feature/WelcomeTest.php` — the smoke test a new application should start from: HTML renders, JSON - negotiates, `/actuator/health` reports UP. Run it with `composer test`. + +The second slice is a full REST resource, and it is what `php artisan make:firefly-controller OrderController` +generates, filled in: + +- `app/Http/OrderController.php` — five actions on `/orders` (`GET` collection, `GET` one, `POST`, `PUT`, + `DELETE`) from one class-level `#[RequestMapping]`. The `201` and the `204` are declared on their mappings, + the paging parameters are bound and coerced by `#[QueryParam]`, and nothing in the class handles a missing + order — `OrderService` throws, and `firefly/web` renders the whole exception taxonomy as RFC-7807 + `problem+json` at the exception's own status. +- `app/Http/OrderRequest.php`, `AddressPayload.php`, `OrderLinePayload.php` — the request body, a nested DTO + and a list of DTOs. `#[Valid]` runs the compiled constraints *before* hydration, so an invalid body is a 422 + naming `shipTo.postcode` and never reaches an action. These are also what `/openapi.json` publishes as + component schemas, `$ref`s and all. +- `app/Orders/` — the domain and the store. `Order`/`Address`/`OrderLine` are immutable value objects, + `OrderEntity` is the Eloquent row, and `OrderRepository` is the interesting one: `extends + EloquentRepository` plus a model name, and every CRUD method is inherited. `OrderService` maps between the + two shapes and is the only place that knows both exist. +- `database/migrations/` — the `orders` table, created for you by `post-create-project-cmd`. +- `tests/Feature/` — `WelcomeTest` is the smoke test a new application should start from (HTML renders, JSON + negotiates, `/actuator/health` reports UP); `OrderTest` drives the whole resource, response *and* row. Run + both with `composer test`. + +Because `OrderRepository` implements `CrudRepository`, the dashboard's data browser lists orders as a +browsable resource the moment you set `firefly.admin.data.enabled` — see `config/firefly.php`. Delete +`app/Orders`, `app/Http/Order*`, `app/Http/AddressPayload.php` and the migration to remove the sample. ## Configuration diff --git a/skeleton/app/Orders/Order.php b/skeleton/app/Orders/Order.php index f99a98b..f642cfb 100644 --- a/skeleton/app/Orders/Order.php +++ b/skeleton/app/Orders/Order.php @@ -17,7 +17,7 @@ * so a derived value has to be published deliberately. * * The id is nullable because an Order exists before it is stored — `OrderController::store()` builds one - * with no id and OrderRepository::save() returns the stored copy that has one. Modelling "not yet + * with no id, and the id is assigned by the database when OrderService writes the row. Modelling "not yet * persisted" as a null id rather than as a second class keeps one type in play across the whole slice. */ final readonly class Order implements JsonSerializable @@ -39,12 +39,6 @@ public function total(): float return round(array_sum(array_map(static fn (OrderLine $line): float => $line->subtotal(), $this->lines)), 2); } - /** The stored copy of an order that had no id yet. */ - public function withId(int $id): self - { - return new self($id, $this->customer, $this->email, $this->shipTo, $this->lines); - } - /** @return array<string, mixed> */ public function jsonSerialize(): array { diff --git a/skeleton/app/Orders/OrderEntity.php b/skeleton/app/Orders/OrderEntity.php new file mode 100644 index 0000000..81b4fa4 --- /dev/null +++ b/skeleton/app/Orders/OrderEntity.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace App\Orders; + +use Illuminate\Database\Eloquent\Model; + +/** + * The persistence shape of an order — an ordinary Eloquent model over the `orders` table. + * + * IT IS A THIRD CLASS ON PURPOSE, and the skeleton now has all three: App\Http\OrderRequest is the order as + * it arrives over HTTP (validation attributes, wire names), App\Orders\Order is the order as the domain + * understands it (immutable, derives its own total), and this is the order as a row. Each changes for its + * own reason — a column rename must not alter a published API, and a new API field must not force a + * migration — which is the whole argument for not letting one class do all three jobs. + * + * The mapping between this and Order lives in OrderService, the same place Spring puts it when a repository + * returns entities and the use cases speak in domain types. + * + * `ship_to` and `lines` are cast to arrays because they are json columns; `total` is a decimal column, and + * PDO hands decimals back as strings, so without the cast the API's `total` would silently change from a + * number to a string the first time the value came from the database instead of from Order::total(). + */ +class OrderEntity extends Model +{ + protected $table = 'orders'; + + /** @var list<string> */ + protected $fillable = ['customer', 'email', 'ship_to', 'lines', 'total']; + + /** @return array<string, string> */ + protected function casts(): array + { + return [ + 'ship_to' => 'array', + 'lines' => 'array', + 'total' => 'float', + ]; + } +} diff --git a/skeleton/app/Orders/OrderRepository.php b/skeleton/app/Orders/OrderRepository.php index 0e2cebb..2dec2c3 100644 --- a/skeleton/app/Orders/OrderRepository.php +++ b/skeleton/app/Orders/OrderRepository.php @@ -5,85 +5,52 @@ namespace App\Orders; use Firefly\Container\Attributes\Repository; +use Firefly\Data\Repository\EloquentRepository; /** - * The order store, as a #[Repository] bean. + * The order store — and the shortest interesting class in the skeleton, because there is nothing to write. * - * WHY IT IS IN MEMORY AND NOT ELOQUENT. A skeleton has to work the instant `composer create-project` - * finishes. That sequence runs `key:generate` and `firefly:cache` — it does not run `migrate` — so a sample - * backed by EloquentRepository would answer its very first request with "no such table: orders", and the - * first thing a new user would learn about the framework is how its 500 page looks. An array is the only - * store that is honest about having nothing set up yet, and swapping it out is the exercise: extend - * Firefly\Data\Repository\EloquentRepository, point `$model` at an Eloquent model, and every method below - * disappears in favour of the inherited save/findById/findAll/count/delete plus derived queries such as - * `findByCustomer(...)` parsed straight from the method name. + * Extending EloquentRepository and naming a model is the whole implementation: save, findById, findAll, + * findAllById, existsById, count, delete, deleteById, deleteAll, findPaged, findSorted and specification + * queries are all inherited. That is the Spring Data bargain — `interface OrderRepository extends + * JpaRepository<OrderEntity, Long> {}` — expressed the way PHP can express it. * - * The state survives between requests because a stereotype is registered as a SINGLETON: the component scan - * finds #[Repository] (it specialises #[Component]) and the container resolves one instance per application. - * That also means the data lives for exactly as long as the PHP process — a page reload under `artisan serve` - * keeps it, a fresh process does not. That is a property of the array, not of the framework. + * DERIVED QUERIES COME FROM THE METHOD NAME. `findByEmail()` below has no body worth the name: the parser + * reads the name, splits it into a property and a comparison, and builds the query. `findByEmailAndTotalGreaterThan`, + * `findByCustomerOrderByTotalDesc` and `countByEmail` would all work the same way, and none of them needs to + * be declared at all — an undeclared call lands in __call and is dispatched identically. It is declared here + * only so the signature is visible to static analysis and to your editor. + * + * WHY IT IS A BEAN. #[Repository] specialises #[Component], so the component scan registers this class as a + * singleton and OrderService gets it autowired by constructor type — no provider, no binding, no + * `$this->app->singleton(...)` anywhere in the application. + * + * WHY IT ALSO SHOWS UP IN THE ADMIN DASHBOARD. EloquentRepository implements CrudRepository, and the data + * browser at /firefly/data lists every bean that does. Switch `firefly.admin.data.enabled` on and orders + * become browsable, searchable and sortable with no further wiring — that is the whole integration. * * Deliberately NOT final: `firefly:cache` emits a #[Transactional] proxy that `extends` the annotated class, * so the moment a method here gains #[Transactional] a final class would stop the compile dead. + * + * @extends EloquentRepository<OrderEntity> */ #[Repository] -class OrderRepository +class OrderRepository extends EloquentRepository { - /** @var array<int, Order> */ - private array $orders = []; - - private int $nextId = 1; - - /** Stores a new order and returns the stored copy — the one that has an id. */ - public function save(Order $order): Order - { - $stored = $order->withId($this->nextId++); - $this->orders[(int) $stored->id] = $stored; - - return $stored; - } - - /** Replaces an existing order wholesale, keeping its id. Returns null when there is nothing to replace. */ - public function replace(int $id, Order $order): ?Order - { - if (! isset($this->orders[$id])) { - return null; - } - - return $this->orders[$id] = $order->withId($id); - } - - public function find(int $id): ?Order - { - return $this->orders[$id] ?? null; - } - - public function delete(int $id): bool - { - if (! isset($this->orders[$id])) { - return false; - } - - unset($this->orders[$id]); - - return true; - } - - public function count(): int - { - return count($this->orders); - } + /** @var class-string<OrderEntity> */ + protected string $model = OrderEntity::class; /** - * One page of orders, oldest id first. $page is 1-based because that is what a URL says. + * Every order placed by one address, newest first — parsed from this name, not from a body. * - * @return list<Order> + * @return list<OrderEntity> */ - public function page(int $page, int $size): array + public function findByEmailOrderByIdDesc(string $email): array { - $rows = array_values($this->orders); - usort($rows, static fn (Order $a, Order $b): int => (int) $a->id <=> (int) $b->id); + $rows = $this->dispatchQuery(__FUNCTION__, func_get_args()); + assert(is_array($rows)); - return array_slice($rows, max(0, $page - 1) * $size, $size); + /** @var list<OrderEntity> $rows */ + return $rows; } } diff --git a/skeleton/app/Orders/OrderService.php b/skeleton/app/Orders/OrderService.php index 8e2466f..18da870 100644 --- a/skeleton/app/Orders/OrderService.php +++ b/skeleton/app/Orders/OrderService.php @@ -5,6 +5,7 @@ namespace App\Orders; use Firefly\Container\Attributes\Service; +use Firefly\Data\Repository\Pageable; use Firefly\Kernel\Exception\Business\ResourceNotFoundException; /** @@ -14,15 +15,20 @@ * * WHY "NOT FOUND" IS THROWN HERE AND NOT HANDLED IN THE CONTROLLER. ResourceNotFoundException is a * FireflyException carrying its own HTTP status (404), error code and category, and firefly/web registers an - * RFC-7807 renderable for the whole taxonomy at boot. Throwing it from the use case therefore produces a - * `application/problem+json` 404 with a stable `errorCode` — the same shape every other Firefly error takes - * — without a try/catch, an #[ExceptionHandler], or an `if (! $order) return response(..., 404)` in any of - * the three actions that need it. The controller stays a mapping layer; the domain decides what "missing" - * means. + * RFC-7807 renderable for the whole taxonomy at boot. Throwing it from the use case therefore produces an + * `application/problem+json` 404 with a stable `code` — the same shape every other Firefly error takes — + * without a try/catch, an #[ExceptionHandler], or an `if (! $order) return response(..., 404)` in any of the + * three actions that need it. The controller stays a mapping layer; the domain decides what "missing" means. * - * It takes and returns DOMAIN types only. The HTTP payloads live in App\Http and are translated by the - * controller, so this class could be driven from a console command, a queued job or a CQRS handler without - * dragging a request DTO along. + * IT TAKES AND RETURNS DOMAIN TYPES, NOT ROWS. OrderRepository returns OrderEntity — an Eloquent model, a + * persistence detail — and the translation to App\Orders\Order happens here, in the two private methods at + * the bottom. That is the same split Spring has when a @Repository returns @Entity types and the service + * layer speaks in domain objects, and it buys the property that nothing above this class knows the table + * exists: OrderController maps HTTP to Order and back, and could not name a column if it tried. + * + * TOTAL IS COMPUTED, NEVER ACCEPTED. `Order::total()` derives the value from the lines; toRow() writes what + * it computed into the column. A client that posts a `total` is ignored, because the request DTO has no such + * field — the strongest way to say a value is not the client's to set. */ #[Service] final class OrderService @@ -34,45 +40,125 @@ public function __construct(private readonly OrderRepository $orders) {} */ public function page(int $page, int $size): array { + // findPaged() runs the page fetch and the count as two queries and returns both in a Page, so the + // "total" a client pages against is the store's, not the length of the slice it was handed. + $found = $this->orders->findPaged(Pageable::of($page, $size)); + return [ - 'page' => $page, - 'size' => $size, - 'total' => $this->orders->count(), - 'items' => $this->orders->page($page, $size), + 'page' => $found->page, + 'size' => $found->size, + 'total' => $found->total, + 'items' => array_map($this->toDomain(...), $found->items), ]; } /** @throws ResourceNotFoundException when no order carries that id */ public function find(int $id): Order { - return $this->orders->find($id) ?? throw $this->missing($id); + return $this->toDomain($this->row($id)); } public function place(Order $order): Order { - return $this->orders->save($order); + $row = new OrderEntity; + $row->fill($this->toRow($order)); + $saved = $this->orders->save($row); + assert($saved instanceof OrderEntity); + + return $this->toDomain($saved); } /** @throws ResourceNotFoundException when no order carries that id */ public function replace(int $id, Order $order): Order { - return $this->orders->replace($id, $order) ?? throw $this->missing($id); + // PUT replaces the order wholesale but keeps its identity, so the existing row is refilled rather + // than deleted and re-inserted: the id in the client's URL stays valid and so does anything holding + // a foreign key to it. + $row = $this->row($id); + $row->fill($this->toRow($order)); + $saved = $this->orders->save($row); + assert($saved instanceof OrderEntity); + + return $this->toDomain($saved); } /** @throws ResourceNotFoundException when no order carries that id */ public function cancel(int $id): void { - if (! $this->orders->delete($id)) { - throw $this->missing($id); + // deleteById() returns void — deleting something absent is not an error to Eloquent — so the + // existence check is what turns "nothing happened" into the 404 the API promised. + $this->row($id); + $this->orders->deleteById($id); + } + + /** @throws ResourceNotFoundException when no order carries that id */ + private function row(int $id): OrderEntity + { + $row = $this->orders->findById($id); + + if (! $row instanceof OrderEntity) { + throw new ResourceNotFoundException(sprintf('Order %d does not exist.', $id), 'ORDER_NOT_FOUND'); } + + return $row; + } + + /** A row as the domain understands it. */ + private function toDomain(OrderEntity $row): Order + { + /** @var array{street?: string, city?: string, postcode?: string, country?: string} $shipTo */ + $shipTo = is_array($row->ship_to) ? $row->ship_to : []; + + /** @var list<array{sku?: string, quantity?: int|string, unitPrice?: float|int|string}> $lines */ + $lines = is_array($row->lines) ? array_values($row->lines) : []; + + return new Order( + (int) $row->getKey(), + (string) $row->customer, + (string) $row->email, + new Address( + (string) ($shipTo['street'] ?? ''), + (string) ($shipTo['city'] ?? ''), + (string) ($shipTo['postcode'] ?? ''), + (string) ($shipTo['country'] ?? ''), + ), + array_map( + static fn (array $line): OrderLine => new OrderLine( + (string) ($line['sku'] ?? ''), + (int) ($line['quantity'] ?? 0), + (float) ($line['unitPrice'] ?? 0), + ), + $lines, + ), + ); } /** - * One place builds the exception so the message and the error code cannot drift between the three - * callers — a 404 whose `errorCode` varies by endpoint is one a client cannot branch on. + * A domain order as columns. The id is absent on purpose — it belongs to the row, and `place()` must not + * be able to choose it. + * + * @return array<string, mixed> */ - private function missing(int $id): ResourceNotFoundException + private function toRow(Order $order): array { - return new ResourceNotFoundException(sprintf('Order %d does not exist.', $id), 'ORDER_NOT_FOUND'); + return [ + 'customer' => $order->customer, + 'email' => $order->email, + 'ship_to' => [ + 'street' => $order->shipTo->street, + 'city' => $order->shipTo->city, + 'postcode' => $order->shipTo->postcode, + 'country' => $order->shipTo->country, + ], + 'lines' => array_map( + static fn (OrderLine $line): array => [ + 'sku' => $line->sku, + 'quantity' => $line->quantity, + 'unitPrice' => $line->unitPrice, + ], + $order->lines, + ), + 'total' => $order->total(), + ]; } } diff --git a/skeleton/composer.json b/skeleton/composer.json index d352877..64412c4 100644 --- a/skeleton/composer.json +++ b/skeleton/composer.json @@ -41,6 +41,7 @@ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", "@php artisan key:generate --ansi", + "@php artisan migrate --force --ansi", "@php artisan firefly:cache" ], "test": "@php vendor/bin/phpunit" @@ -54,6 +55,7 @@ } }, "require-dev": { + "mockery/mockery": "^1.6", "phpunit/phpunit": "^12.0" }, "autoload-dev": { diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index f426a12..4906b61 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -420,6 +420,54 @@ // */ // 'exclude' => 'env,configprops', // ], + // + // /* + // | THE DATA BROWSER — /firefly/data + // | + // | A browsable, searchable, sortable view of the records behind your repositories, in the shape + // | Django's admin made familiar. It discovers every bean implementing CrudRepository — which you + // | get from `extends EloquentRepository` — and reads THROUGH the repository, so what it shows is + // | what your own data layer returns, not a raw table dump. Nothing to register: the sample + // | App\Orders\OrderRepository shows up as "Order" the moment this is switched on. + // | + // | IT HAS ITS OWN SWITCH, DEFAULTING TO FALSE, even though the dashboard around it already + // | defaults to app.debug. Beans, conditions and mappings describe the SHAPE of an application; + // | these are its customers' records. "Debug is on" is a fine reason to show the first and not the + // | second, so the browser is off until someone says otherwise — and when it is off, its pages + // | 404 rather than 403, because a 403 confirms the surface exists. + // */ + // 'data' => [ + // // Default: false. + // 'enabled' => env('FIREFLY_ADMIN_DATA_ENABLED', false), + // + // /* + // | Whether the browser may EDIT and DELETE records. Ineffective on its own — a write needs + // | this AND `enabled` — so switching the browser on never silently makes it writable. With + // | this off the edit form is not rendered and the write URLs refuse. + // | + // | There is deliberately no "create": a generic form cannot honour the constructor + // | invariants of an arbitrary entity, and one that quietly bypassed them would be worse than + // | not having it. Create records through your own use cases. + // | + // | Default: false. + // */ + // 'writable' => env('FIREFLY_ADMIN_DATA_WRITABLE', false), + // + // // Rows per page, and the ceiling a `?per-page=` in the URL may raise it to. Both are clamped + // // to a hard maximum of 1000 so no query string can ask for the whole table at once. + // // Defaults: 25 and 200. + // 'page-size' => 25, + // 'max-page-size' => 200, + // + // /* + // | CSV of resource slugs to REFUSE — same hard refusal as `pages.exclude` above: excluded + // | resources are absent from the menu AND their URLs 404. Use it for the tables you do not + // | want browsable even by someone who is allowed in at all. + // | + // | Default: '' (nothing excluded). + // */ + // 'exclude' => 'order', + // ], // ], /* diff --git a/skeleton/database/migrations/0001_01_01_000000_create_orders_table.php b/skeleton/database/migrations/0001_01_01_000000_create_orders_table.php new file mode 100644 index 0000000..c8e9a1e --- /dev/null +++ b/skeleton/database/migrations/0001_01_01_000000_create_orders_table.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +/** + * The one table the sample resource needs. + * + * `composer create-project` runs `artisan migrate` for you (see the skeleton's post-create-project-cmd), so + * `POST /orders` works against the bundled sqlite file the moment the installer finishes. Delete this file + * and app/Orders if you do not want the sample. + * + * SHIPPING ADDRESS AND LINES ARE JSON COLUMNS. An order's lines are worth a table of their own the moment + * anything queries across them — "how many WIDGET-1 did we sell" is a join, not a JSON scan. They are one + * column here because the sample's job is to show the framework's repository layer, and a second table would + * add a relation to explain without adding anything to that story. Firefly\Data\Repository\EloquentRepository + * is an Eloquent repository, so `hasMany` works exactly as it always does when you are ready for it. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('orders', function (Blueprint $table): void { + $table->id(); + $table->string('customer'); + $table->string('email'); + $table->json('ship_to'); + $table->json('lines'); + // Derived from the lines by the domain, stored so the column can be sorted, summed and reported + // on without decoding JSON — the ordinary reason a derived value is also persisted. Nothing + // accepts it from a client: OrderService writes what Order::total() computed. + $table->decimal('total', 12, 2)->default(0); + $table->timestamps(); + + $table->index('email'); + }); + } + + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/skeleton/tests/Feature/OrderTest.php b/skeleton/tests/Feature/OrderTest.php index a95d053..671910a 100644 --- a/skeleton/tests/Feature/OrderTest.php +++ b/skeleton/tests/Feature/OrderTest.php @@ -4,6 +4,10 @@ namespace Tests\Feature; +use App\Orders\OrderEntity; +use App\Orders\OrderRepository; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Tests\TestCase; /** @@ -14,13 +18,22 @@ * derived collection path, a validated request body with a nested DTO and a list of DTOs, declared 201/204 * statuses, and an RFC-7807 404 that no line of controller code produces. * - * The store is in memory (see App\Orders\OrderRepository for why a skeleton must not assume a migrated - * database), and the repository is a singleton, so state persists across the requests WITHIN one test. Each - * test creates whatever it needs rather than relying on another test's leftovers, because PHPUnit gives no - * ordering guarantee and a fresh application is booted per test. + * The store is the `orders` table, reached through App\Orders\OrderRepository — which is an + * EloquentRepository with a model name and no method bodies. RefreshDatabase migrates the in-memory sqlite + * configured in phpunit.xml and rolls each test back, so every case starts empty and none depends on + * another's leftovers. + * + * THE PERSISTENCE ASSERTIONS BELOW ARE NOT DECORATION. An earlier version of this sample kept orders in an + * array on a singleton repository, and this suite passed: Laravel reuses one application across the requests + * of a single test, so the array survived from the POST to the GET. Over real HTTP it does not — PHP shares + * nothing between requests, so `POST /orders` returned an id and the next `GET /orders` reported an empty + * store. A test that only ever asks the same process what it just remembered cannot tell the two apart, + * which is why these cases check the DATABASE as well as the response. */ final class OrderTest extends TestCase { + use RefreshDatabase; + /** * @param array<string, mixed> $overrides * @return array<string, mixed> @@ -59,6 +72,16 @@ public function test_it_creates_an_order_with_a_nested_address_and_a_list_of_lin ->assertJsonPath('total', 22.25); $this->assertIsInt($response->json('id')); + + // The row, not the response. `total` is a decimal column written from Order::total(), and the two + // json columns hold the nested payloads — read back here so a controller that answered correctly + // while storing nothing could not pass. + $this->assertDatabaseHas('orders', [ + 'id' => $response->json('id'), + 'customer' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'total' => 22.25, + ]); } public function test_it_reads_lists_replaces_and_deletes_an_order(): void @@ -82,9 +105,42 @@ public function test_it_reads_lists_replaces_and_deletes_an_order(): void ->assertOk() ->assertJsonPath('customer', 'Grace Hopper'); + // Replacement keeps the identity: the same row, refilled, rather than a delete and re-insert. + $this->assertDatabaseHas('orders', ['id' => $id, 'customer' => 'Grace Hopper']); + $this->assertDatabaseCount('orders', 1); + // A `void` action plus #[DeleteMapping(status: 204)] is how you say "no body". $this->deleteJson('/orders/'.$id)->assertNoContent(); $this->getJson('/orders/'.$id)->assertStatus(404); + $this->assertDatabaseMissing('orders', ['id' => $id]); + } + + /** + * The order left the process, and a reader that never saw the write can find it. + * + * This is the case the in-memory version could not have passed, and the reason it went unnoticed is that + * it never had to: `postJson()` followed by `getJson()` reuses one application, so an array on a + * singleton repository looked exactly like a database. Querying the connection directly — and reading + * back through a repository instance built after the write, which shares no state with the one that + * handled it — is what separates a store from a cache inside a single test process. + */ + public function test_an_order_is_written_to_the_database_and_not_to_process_memory(): void + { + $id = $this->postJson('/orders', $this->body())->json('id'); + + // The raw row. `lines` is a json column, so the store holds the payload, not a PHP object graph. + $row = DB::table('orders')->where('id', $id)->first(); + $this->assertNotNull($row); + $this->assertSame('ada@example.com', $row->email); + $this->assertCount(2, (array) json_decode((string) $row->lines, true)); + + // A repository built now, by hand, with no connection to the one that served the POST. + $found = (new OrderRepository)->findById($id); + $this->assertInstanceOf(OrderEntity::class, $found); + $this->assertSame('Ada Lovelace', $found->customer); + + // And the derived query, parsed from its own name, finds it by a column nothing indexed by hand. + $this->assertCount(1, (new OrderRepository)->findByEmailOrderByIdDesc('ada@example.com')); } public function test_it_defaults_both_paging_parameters_when_the_query_string_omits_them(): void diff --git a/tests/MetapackageCoverageTest.php b/tests/MetapackageCoverageTest.php new file mode 100644 index 0000000..34a2858 --- /dev/null +++ b/tests/MetapackageCoverageTest.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); +use Firefly\Installer\CapabilityCatalog; + +/** + * The runtime metapackage must reach every runtime package, and in particular every package the installer + * offers as a capability. + * + * WHAT THIS CAUGHT. firefly/admin and firefly/openapi were built, tested, documented in the book and offered + * by `firefly new --with admin,openapi` while NOTHING required them — so `composer create-project + * firefly/skeleton` produced a project with no dashboard and no API documentation, and the welcome page + * (which checks `class_exists()` before linking anything) simply omitted both cards. Every package suite + * passed the whole time, because a package suite runs from the monorepo, where the source is on disk. A + * composer manifest is the one artifact a monorepo cannot check by running its own tests. + * + * The second test below is the sharper one. CapabilityCatalog offers thirteen non-adapter capabilities, and + * for eleven of them `--with` only makes an ALREADY-INSTALLED package an explicit dependency — the code + * arrives with firefly/firefly either way. admin and openapi were the only two where `--with` decided + * whether the code existed at all, so the same flag meant two different things depending on which + * capability you named. That asymmetry is what actually broke the skeleton, and it is what this asserts + * against. + */ +it('requires every runtime package from the firefly/firefly metapackage', function () { + $excluded = [ + // The metapackage itself. + 'firefly/firefly', + + // Broker transports. Each binds the application to an infrastructure choice, and two of them cannot + // even install without a platform extension or a client library present: eda-postgres requires + // ext-pdo_pgsql and eda-rabbitmq pulls php-amqplib. An application picks its transport; the + // framework does not pick one for it. CapabilityCatalog marks all four `adapter: true` and leaves + // them out of --full for the same reason, so the two lists agree. + 'firefly/eda-kafka', + 'firefly/eda-postgres', + 'firefly/eda-rabbitmq', + + // A standalone Symfony Console binary, installed globally with `composer global require` to CREATE + // projects. Requiring it from the runtime would install a project generator into every deployment. + 'firefly/installer', + + // Test-only: it pulls orchestra/testbench, which belongs in require-dev or nowhere. + 'firefly/testing', + ]; + + $required = requiredBy(dirname(__DIR__).'/packages/firefly/composer.json'); + + $missing = []; + foreach (packageNames() as $name) { + if (! in_array($name, $excluded, true) && ! in_array($name, $required, true)) { + $missing[] = $name; + } + } + + sort($missing); + + expect($missing)->toBe([]); + + // An exclusion naming a package that no longer exists is a stale comment pretending to be a decision. + foreach ($excluded as $name) { + expect(packageNames())->toContain($name); + } +}); + +it('installs every non-adapter capability by default, so --with only ever makes a dependency explicit', function () { + $required = requiredBy(dirname(__DIR__).'/packages/firefly/composer.json'); + + $missing = []; + foreach (CapabilityCatalog::all() as $capability) { + if ($capability->adapter || $capability->dev) { + continue; + } + + if (! in_array($capability->package, $required, true)) { + $missing[] = $capability->id; + } + } + + sort($missing); + + expect($missing)->toBe([]); +}); + +/** @return list<string> */ +function requiredBy(string $composer): array +{ + /** @var mixed $json */ + $json = json_decode((string) file_get_contents($composer), true); + $declared = is_array($json) ? ($json['require'] ?? null) : null; + + return is_array($declared) ? array_map(strval(...), array_keys($declared)) : []; +} + +/** @return list<string> */ +function packageNames(): array +{ + $names = []; + foreach (glob(dirname(__DIR__).'/packages/*/composer.json') ?: [] as $composer) { + /** @var mixed $json */ + $json = json_decode((string) file_get_contents($composer), true); + if (is_array($json) && is_string($json['name'] ?? null)) { + $names[] = $json['name']; + } + } + + return $names; +} From 3a62fae15ecfe5f35ae4cb5fa9b5f78bb2e14f3b Mon Sep 17 00:00:00 2001 From: Andres Contreras <andres.contreras@soon.es> Date: Thu, 3 Sep 2026 18:27:02 -0700 Subject: [PATCH 19/31] fix(admin): the dashboard stops overflowing the viewport on narrow screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEASURED, NOT EYEBALLED. Every dashboard page was loaded into a sized iframe at thirteen widths from 2560px down to 320px and asked one question: is documentElement.scrollWidth wider than the viewport, and if so, which element is furthest past the edge — ignoring anything inside a deliberate overflow-x:auto container, because a table that scrolls in its own box is the design working, not failing. 182 combinations; two overflowed, both the overview page, by 39px at 430px and 78px at 320px. THE CULPRIT WAS THE CONTAINER, NOT THE CONTENT .topbar is a grid item, and a grid item defaults to min-width:auto — it refuses to size below its own min-content. So the bar simply GREW to 398px inside a 320px viewport and took the document's horizontal scrollbar with it, while its flex children sat at their natural widths and never shrank. Measuring the children is what showed this: .topchips already had min-width:0 and flex-shrink:1 and was still 134px wide, which only makes sense if nothing ever asked it to shrink. The container had absorbed the pressure instead of passing it on. Only the overview overflowed because only the overview yields two chips into the bar; every other page fitted by luck rather than by construction, which is why the fix is a rule about how the bar sizes and not a rule about those two chips. min-width:0 on .topbar lets the pressure through, and the page's own chips are now wrapped in a .topchips element that shrinks and scrolls — they are the one part of the bar whose width varies, so they are the one part allowed to give. The wordmark, Auto and Theme stay fixed and reachable at every width, with the wordmark's "admin" suffix dropping below 520px where two words plus two buttons genuinely do not fit. Both rules carry the reasoning in a comment, because `min-width:0` on its own reads like a tidy-up and is exactly the kind of line someone removes. AFTER: 182 of 182 combinations clean, 320px to 2560px. Content fills the full width at every size — max-width is none and the widest child is the column minus its own 56px of padding, so a 2560px screen gets 2280px of content rather than a centred ribbon. Both themes measure 14.4:1 (light) and 15.8:1 (dark) body contrast against explicit token backgrounds, and the bean graph's SVG fits its holder in both. 1998 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../admin/resources/views/layout.blade.php | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index caa89cf..cdd01d6 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -123,14 +123,37 @@ } .topbar{ - grid-column:1 / -1;display:flex;align-items:center;gap:14px; + /* + | min-width:0 is load-bearing, not tidiness. A grid item defaults to min-width:auto, which + | refuses to size below its own min-content — so at 320px the bar grew to 398px and took the + | document's horizontal scrollbar with it, while its flex children sat at their natural widths + | and never shrank, because the CONTAINER had absorbed the pressure instead of passing it on. + | With this, the pressure reaches .topchips, which is the child built to give. + */ + grid-column:1 / -1;min-width:0;display:flex;align-items:center;gap:14px; padding:0 16px;background:var(--shell);border-bottom:1px solid var(--line); } - .wordmark{display:flex;align-items:center;gap:9px;font-weight:650;letter-spacing:-.01em;white-space:nowrap} + .wordmark{display:flex;align-items:center;gap:9px;font-weight:650;letter-spacing:-.01em;white-space:nowrap;flex:none} .dot{width:9px;height:9px;border-radius:50%;background:var(--brand);flex:none;box-shadow:0 0 0 3px color-mix(in srgb, var(--brand) 18%, transparent)} .wordmark small{color:var(--ink-3);font-weight:400;font-size:11.5px;font-family:var(--mono)} .topbar .spacer{flex:1} + /* + | A page's own chips are the ONE part of the bar that varies in width, so they are the one part + | allowed to shrink and scroll. Everything else in here — the wordmark, Auto, Theme — is fixed and + | must stay reachable: a phone-width overview page pushed the whole bar 39px past the viewport and + | took the document's horizontal scrollbar with it, because every child was nowrap and none of them + | would give. min-width:0 is what actually lets a flex item shrink below its content. + */ + .topchips{display:flex;align-items:center;gap:10px;min-width:0;overflow-x:auto;scrollbar-width:none} + .topchips::-webkit-scrollbar{display:none} + + /* Below this the two words plus two buttons genuinely do not fit; the suffix is the redundant one. */ + @media(max-width:520px){ + .topbar{gap:10px;padding:0 12px} + .wordmark small{display:none} + } + .chip{ display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 9px;border-radius:999px; font-size:11.5px;font-weight:600;letter-spacing:.02em;white-space:nowrap; @@ -381,7 +404,7 @@ <div class="app"> <div class="topbar"> <span class="wordmark"><span class="dot" aria-hidden="true"></span>{{ $settings->title }}<small>admin</small></span> - @hasSection('topchips') @yield('topchips') @endif + @hasSection('topchips')<span class="topchips">@yield('topchips')</span>@endif <span class="spacer"></span> <button class="tool" type="button" id="refresh" aria-pressed="false" title="Reload this page every 10 seconds"> <span>Auto</span><span class="tick" id="tick"></span> From 093021b11b7edb1b839a9a8a51496890f9c35f76 Mon Sep 17 00:00:00 2001 From: Andres Contreras <andres.contreras@soon.es> Date: Thu, 3 Sep 2026 18:44:53 -0700 Subject: [PATCH 20/31] feat(openapi): document what an endpoint RETURNS, and the collections it accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every success response in every generated document was `{"type": "object"}` — an object with no members. A viewer renders that as a blank panel; openapi-generator turns it into `any`. So the most useful sentence an API document contains, "here is what you get back", was the one sentence missing from every endpoint of every application. THE SHAPE WAS NEVER UNAVAILABLE It was one line above the method. `@return array{page: int, size: int, total: int, items: list<Order>}` is already written, because PHPStan at level max requires it — which is also what makes reading it safe. An out-of-date type expression is a failing gate, not a silent lie. The old reasoning ("comment text nothing else in the framework treats as binding") had already stopped being true: RouteScanner reads `@param list<X>` to compile the table ArgumentResolver HYDRATES from, so a docblock type is exactly as binding as a declared one on the way in. Reading it needs a parser, not another regex. The package had two — `list<X>` and `X[]` — and they cannot reach `array{items: list<array<string, Order>>}`, where `<>` and `{}` nest and a comma separates only at the outer level. That is a grammar. DocType is a ~200-line recursive-descent compiler from a PHPDoc type expression to a JSON Schema fragment, against the four transitive dependencies phpstan/phpdoc-parser would put in every application that installs this package. A RESPONSE IS NOT A REQUEST, so it does not reuse DtoSchemaFactory That factory derives members from the CONSTRUCTOR and rules from the ConstraintManifest — the right two sources for a payload the server binds and validates, and the wrong two on the way out. A response is never validated, and its members are what `json_encode` emits: the PUBLIC PROPERTIES, or — for a JsonSerializable class — whatever `jsonSerialize()` returns, which may be neither. The skeleton's Order is exactly that case: it publishes a derived `total` that is a method, so reflection alone documents five of the six members the API actually sends. ResponseSchemaFactory reads the declared shape first and falls back to reflection, and accepts a declared shape only when it says MORE than the fallback would — `@return array<string, mixed>` parses fine and means nothing. Nullability is not requiredness here. `?int $id` is always PRESENT and sometimes null, so response members stay required and widen their type. The request side's rule would have told every client to expect its absence. THREE INPUT COLLECTIONS WERE ALSO WRONG, and the same parser fixes them `list<string>` and `list<list<int>>` published as a bare `type: array` — Array<any> again, for members whose element type was written down. Worse, `array<string, int>` published as `type: array` when it is a JSON OBJECT: not vague but the wrong type, so a generated client fails to decode what the server sends. ElementTypes answers only for a list of CLASSES, deliberately, because that table is the hydrator's own statement and a second implementation of it would drift. So the expression is read AFTER both of its paths have declined — one step, running identically whichever path was taken. NestedSchemaTest asserted the missing `items` as protection against exactly that drift; it now asserts the property it was protecting (the two paths agree) rather than the symptom, and they agree on `items: string`. A #[Size] on a map then had to stop emitting `minLength`, which is not a constraint on an object at all — a validator ignores it, so the document would silently drop a bound the server does enforce. `minProperties`/`maxProperties` is the third case lengthKeyword() now knows. VERIFIED BY VALIDATING LIVE RESPONSES AGAINST THE PUBLISHED SCHEMA. A real project's POST /orders, GET /orders/{id} and GET /orders were each checked member by member against the schema the generator wrote for them — types, required members, and `additionalProperties: false` — and all three conform. #[ApiResponse] now takes a full type expression (`'list<Shipment>'`) resolved through the controller's own imports, not only a bare class name. 2016 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- packages/openapi/src/Generator/DocBlock.php | 75 ++ .../src/Generator/OperationFactory.php | 164 ++++- packages/openapi/src/Schema/ClassNames.php | 132 ++++ packages/openapi/src/Schema/DocType.php | 679 ++++++++++++++++++ .../openapi/src/Schema/DtoSchemaFactory.php | 38 +- packages/openapi/src/Schema/ElementTypes.php | 67 +- packages/openapi/src/Schema/MapperState.php | 21 +- .../src/Schema/ResponseSchemaFactory.php | 246 +++++++ .../tests/Generator/NestedSchemaTest.php | 31 +- .../tests/Generator/ResponseSchemaTest.php | 119 +++ .../tests/NestedFixture/LineOptionRequest.php | 7 +- .../tests/ResponseFixture/Consignment.php | 57 ++ .../ResponseFixture/ConsignmentController.php | 85 +++ .../tests/ResponseFixture/Currency.php | 11 + .../openapi/tests/ResponseFixture/Money.php | 14 + .../tests/ResponseFixture/Shipment.php | 21 + packages/openapi/tests/Schema/DocTypeTest.php | 130 ++++ skeleton/app/Orders/Address.php | 18 +- skeleton/app/Orders/Order.php | 37 +- skeleton/app/Orders/OrderLine.php | 13 +- 20 files changed, 1826 insertions(+), 139 deletions(-) create mode 100644 packages/openapi/src/Schema/ClassNames.php create mode 100644 packages/openapi/src/Schema/DocType.php create mode 100644 packages/openapi/src/Schema/ResponseSchemaFactory.php create mode 100644 packages/openapi/tests/Generator/ResponseSchemaTest.php create mode 100644 packages/openapi/tests/ResponseFixture/Consignment.php create mode 100644 packages/openapi/tests/ResponseFixture/ConsignmentController.php create mode 100644 packages/openapi/tests/ResponseFixture/Currency.php create mode 100644 packages/openapi/tests/ResponseFixture/Money.php create mode 100644 packages/openapi/tests/ResponseFixture/Shipment.php create mode 100644 packages/openapi/tests/Schema/DocTypeTest.php diff --git a/packages/openapi/src/Generator/DocBlock.php b/packages/openapi/src/Generator/DocBlock.php index 2626073..e77cbb1 100644 --- a/packages/openapi/src/Generator/DocBlock.php +++ b/packages/openapi/src/Generator/DocBlock.php @@ -104,6 +104,81 @@ public function prose(): string return $this->body; } + /** + * Every occurrence of one tag, raw. + * + * @return list<string> + */ + public function tag(string $name): array + { + return $this->tags[$name] ?? []; + } + + /** + * `@param` lines as member name => TYPE EXPRESSION — the mirror of params(), which returns the same + * lines' prose. + * + * The two halves are split at the `$identifier`, because that is the one token whose position is fixed: + * everything before it is the type (which contains spaces of its own, `array<string, mixed>`), and + * everything after is English. A line with no type at all — `@param $id the id` — yields nothing rather + * than an empty expression, so a caller never has to distinguish "untyped" from "typed as nothing". + * + * @return array<string, string> + */ + public function paramTypes(): array + { + $types = []; + + foreach ($this->tags['param'] ?? [] as $line) { + if (preg_match('/^\s*(.*?)\s*(?:\.\.\.)?\$([A-Za-z_]\w*)/s', $line, $matches) !== 1) { + continue; + } + + $type = trim($matches[1]); + if ($type !== '') { + $types[$matches[2]] = $type; + } + } + + return $types; + } + + /** + * The first `@return` line, raw — type expression and any prose after it, for DocType::split() to cut. + * + * Returned whole rather than pre-split because splitting it requires PARSING the type expression, and + * this class deliberately interprets no types (see the class docblock). It hands the line to the one + * place that does. + */ + public function returnLine(): ?string + { + $lines = $this->tags['return'] ?? []; + + return $lines === [] ? null : trim($lines[0]); + } + + /** + * The first `@var` line's type expression, with any `$name` and trailing prose removed. + * + * `@var` is written three ways in the wild — bare (`@var list<Line>`), named (`@var list<Line> $lines`), + * and described (`@var list<Line> the lines`) — and only the first token group is the type in all three. + */ + public function varType(): ?string + { + $lines = $this->tags['var'] ?? []; + + if ($lines === []) { + return null; + } + + $line = trim($lines[0]); + if (preg_match('/^(.*?)\s+\$[A-Za-z_]\w*/s', $line, $matches) === 1) { + return trim($matches[1]); + } + + return $line === '' ? null : $line; + } + /** * `@param` lines as member name => description, dropping the type expression and any line with no prose. * diff --git a/packages/openapi/src/Generator/OperationFactory.php b/packages/openapi/src/Generator/OperationFactory.php index a22883c..d687d8e 100644 --- a/packages/openapi/src/Generator/OperationFactory.php +++ b/packages/openapi/src/Generator/OperationFactory.php @@ -6,12 +6,15 @@ use Firefly\OpenApi\Attributes\ApiParameter; use Firefly\OpenApi\Attributes\ApiResponse; +use Firefly\OpenApi\Schema\DocType; use Firefly\OpenApi\Schema\DtoSchemaFactory; use Firefly\OpenApi\Schema\ElementTypes; use Firefly\OpenApi\Schema\ProblemSchema; +use Firefly\OpenApi\Schema\ResponseSchemaFactory; use Firefly\OpenApi\Schema\SchemaRegistry; use Firefly\OpenApi\Schema\TypeSchema; use Firefly\Web\Route\RouteDescriptor; +use ReflectionClass; use ReflectionMethod; use ReflectionNamedType; @@ -38,7 +41,10 @@ */ final class OperationFactory { - public function __construct(private readonly DtoSchemaFactory $schemas) {} + public function __construct( + private readonly DtoSchemaFactory $schemas, + private readonly ResponseSchemaFactory $responses = new ResponseSchemaFactory, + ) {} /** * $docs is threaded in rather than injected, for the same reason SchemaRegistry is: both are per-DOCUMENT @@ -110,7 +116,7 @@ public function create(RouteDescriptor $route, string $operationId, SchemaRegist $operation['requestBody'] = $this->multipartBody($files); } - $operation['responses'] = $this->responses($route, $rejectable, $validated, $doc, $registry); + $operation['responses'] = $this->responseSet($route, $rejectable, $validated, $doc, $registry); return $operation; } @@ -252,9 +258,9 @@ private function multipartBody(array $files): array * * @return array<array-key, mixed> */ - private function responses(RouteDescriptor $route, bool $rejectable, bool $validated, OperationDoc $doc, SchemaRegistry $registry): array + private function responseSet(RouteDescriptor $route, bool $rejectable, bool $validated, OperationDoc $doc, SchemaRegistry $registry): array { - $responses = [(string) $route->status => $this->successResponse($route)]; + $responses = [(string) $route->status => $this->successResponse($route, $registry)]; if ($rejectable) { $responses['400'] = ['$ref' => ProblemSchema::RESPONSE_REF]; @@ -267,7 +273,7 @@ private function responses(RouteDescriptor $route, bool $rejectable, bool $valid $responses['default'] = ['$ref' => ProblemSchema::RESPONSE_REF]; foreach ($doc->responses as $declared) { - $responses[(string) $declared->status] = $this->declaredResponse($declared, $registry); + $responses[(string) $declared->status] = $this->declaredResponse($declared, $registry, $this->method($route)?->getDeclaringClass()); } return $this->sortStatuses($responses); @@ -283,15 +289,16 @@ private function responses(RouteDescriptor $route, bool $rejectable, bool $valid * common case for the error statuses this attribute mostly documents — those render through * ProblemDetailsRenderer, whose shape the shared problem component already states. * - * `array` is honoured here as `type: array`, where the DERIVED success response degrades the same PHP - * type to `type: object`. That is not an inconsistency: a controller's `array` return type genuinely does - * not say whether the payload is a list or a map (LaraFly controllers overwhelmingly return maps), so the - * derivation cannot know — whereas an author who typed `type: 'array'` into an attribute has said which - * one they meant. + * `type` is a full PHPDoc type EXPRESSION, not only a class or a scalar name: `'list<Shipment>'`, + * `'array<string, Money>'` and `'?Consignment'` all resolve, through the same parser that reads a + * `@return` line. A bare `'array'` is still honoured as `type: array`, where the DERIVED success + * response degrades the same PHP type to `type: object` — not an inconsistency, but the difference + * between a declared type that cannot say which it is and an author who has said. * + * @param ReflectionClass<object>|null $declaring * @return array<string, mixed> */ - private function declaredResponse(ApiResponse $declared, SchemaRegistry $registry): array + private function declaredResponse(ApiResponse $declared, SchemaRegistry $registry, ?ReflectionClass $declaring = null): array { $response = ['description' => $declared->description]; @@ -299,9 +306,13 @@ private function declaredResponse(ApiResponse $declared, SchemaRegistry $registr return $response; } - $schema = TypeSchema::isDto($declared->type) - ? ['$ref' => $this->schemas->ref($declared->type, $registry)] - : TypeSchema::for($declared->type) ?? ['type' => 'object']; + // The controller is the context a short name in the attribute was written in — `#[ApiResponse(type: + // 'list<Shipment>')]` means whatever `Shipment` means in that file's imports, exactly as it would in + // a docblock three lines below. Without it only a fully-qualified name would resolve, which is the + // one spelling nobody writes. + $schema = DocType::schema($declared->type, fn (string $class): array => $this->responses->schema($class, $registry), $declaring) + ?? TypeSchema::for($declared->type) + ?? ['type' => 'object']; $response['content'] = ['application/json' => ['schema' => $schema]]; @@ -353,22 +364,40 @@ private function sortStatuses(array $responses): array } /** - * The success body, from the controller method's declared RETURN type — the only place the shape of a - * successful response is stated anywhere in the framework, since RouteDescriptor records the status but - * not the payload. A `204` (or a `void`/`never` return) gets no content at all, because emitting a - * content map for a status that carries no body is exactly the sort of thing a strict client generator - * turns into a phantom return type. + * The success body — the shape of what the action actually returns. * - * `array` is the common LaraFly return and deliberately degrades to `type: object` rather than being - * expanded from the method's `@return array{...}` docblock: parsing a PHPDoc array shape here would make - * the generated document depend on comment text that nothing else in the framework treats as binding. - * A method's PROSE is now read (see ApiDocs) and its TYPES are still not, which is the line — prose has - * no other source and cannot mislead a client generator; a mistyped `@return` silently can. + * WHAT THIS USED TO SAY, AND WHY IT WAS WRONG. Every success response in every generated document was + * `{"type": "object"}`. A viewer renders that as an empty panel and a client generator turns it into + * `any`, so the single most useful thing an API document can state — what you get back — was the one + * thing this file did not state. The reasoning was that a `@return array{...}` is "comment text nothing + * else in the framework treats as binding", and that had already stopped being true: RouteScanner reads + * `@param list<X>` to compile the table ArgumentResolver HYDRATES from, so a docblock type expression is + * exactly as binding as a declared type on the way in. PHPStan at level max checks these expressions + * against the code on every build, which is what makes reading them safe: an out-of-date `@return` is a + * failing gate, not a silent lie. + * + * THREE SOURCES, most specific first. + * + * `@return` — the only place `array` can say what is IN it. `array{page: int, items: list<Order>}` + * becomes a real object schema with a `$ref` inside it. Prose after the type expression becomes the + * response description, which is the only response description an author ever actually writes. + * + * The DECLARED return type — a class becomes a component `$ref` built from its wire shape (see + * ResponseSchemaFactory), a scalar becomes itself, a backed enum becomes its value set. + * + * Neither — `type: object`, the old behaviour, kept for a bare `array` return with nothing said about + * it. That is a real state (`array` genuinely does not say list-or-map, and LaraFly actions + * overwhelmingly return maps) and it is now the FALLBACK rather than the answer. + * + * A 204, a `void`/`never` return and an HTML page keep their existing shapes: emitting a content map for + * a status that carries no body is exactly what a strict client generator turns into a phantom return + * type, and describing a rendered page as JSON would be a lie a generator would act on. * * @return array<string, mixed> */ - private function successResponse(RouteDescriptor $route): array + private function successResponse(RouteDescriptor $route, SchemaRegistry $registry): array { + $method = $this->method($route); $type = $this->returnType($route); if ($route->status === 204 || $type === 'void' || $type === 'never') { @@ -385,18 +414,75 @@ private function successResponse(RouteDescriptor $route): array ]; } - $schema = match (true) { - $type === null => [], - $type === 'array', $type === 'iterable' => ['type' => 'object'], - default => TypeSchema::for($type) ?? ['type' => 'object'], - }; + [$documented, $prose] = $this->documentedReturn($method, $registry); + + $schema = $documented ?? $this->declaredReturnSchema($type, $registry); return [ - 'description' => 'Successful response.', + 'description' => $prose === '' ? 'Successful response.' : $prose, 'content' => ['application/json' => ['schema' => $schema]], ]; } + /** + * The `@return` line as a schema plus its trailing prose. + * + * A parsed expression is used only when it says more than the declared type already would: a bare + * `@return array` or `@return array<string, mixed>` parses fine and means nothing, and letting it win + * would replace a `$ref` with an empty object for every action whose author wrote the loosest possible + * annotation. The prose is taken either way — it is a description of THIS response and does not depend + * on whether the type expression was informative. + * + * @return array{0: array<string, mixed>|null, 1: string} + */ + private function documentedReturn(?ReflectionMethod $method, SchemaRegistry $registry): array + { + $line = DocBlock::parse($method?->getDocComment())->returnLine(); + + if ($line === null || $method === null) { + return [null, '']; + } + + [$schema, $prose] = DocType::split( + $line, + fn (string $class): array => $this->responses->schema($class, $registry), + new ReflectionClass($method->getDeclaringClass()->getName()), + ); + + return [$this->informative($schema) ? $schema : null, $prose]; + } + + /** + * @param array<string, mixed>|null $schema + */ + private function informative(?array $schema): bool + { + if ($schema === null) { + return false; + } + + foreach (['properties', 'items', 'additionalProperties', '$ref', 'enum', 'anyOf', 'allOf', 'prefixItems'] as $key) { + if (array_key_exists($key, $schema)) { + return true; + } + } + + return false; + } + + /** + * @return array<string, mixed> + */ + private function declaredReturnSchema(?string $type, SchemaRegistry $registry): array + { + return match (true) { + $type === null => [], + $type === 'array', $type === 'iterable' => ['type' => 'object'], + TypeSchema::isDto($type) => $this->responses->schema($type, $registry), + default => TypeSchema::for($type) ?? ['type' => 'object'], + }; + } + /** * Whether this binding's value has to be CONVERTED out of the string the wire always carries — the * TYPE_CONVERSION_ERROR half of the 400 above. A `string` parameter needs no conversion and so cannot @@ -410,13 +496,23 @@ private function coercible(array $binding): bool } private function returnType(RouteDescriptor $route): ?string + { + $type = $this->method($route)?->getReturnType(); + + return $type instanceof ReflectionNamedType ? $type->getName() : null; + } + + /** + * The action, when this process can load it. A compiled route manifest outlives the class it names — a + * controller can be deleted between `firefly:cache` and a hit on the spec route — so every reflective + * read here is guarded rather than assumed, and an unloadable action simply documents less. + */ + private function method(RouteDescriptor $route): ?ReflectionMethod { if (! class_exists($route->controllerClass) || ! method_exists($route->controllerClass, $route->methodName)) { return null; } - $type = (new ReflectionMethod($route->controllerClass, $route->methodName))->getReturnType(); - - return $type instanceof ReflectionNamedType ? $type->getName() : null; + return new ReflectionMethod($route->controllerClass, $route->methodName); } } diff --git a/packages/openapi/src/Schema/ClassNames.php b/packages/openapi/src/Schema/ClassNames.php new file mode 100644 index 0000000..a4e0c89 --- /dev/null +++ b/packages/openapi/src/Schema/ClassNames.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Schema; + +use ReflectionClass; + +/** + * Resolves a class name AS WRITTEN IN A DOCBLOCK to a fully-qualified one. + * + * A comment says `list<OrderLine>`, and `OrderLine` is whatever that name meant in the file it was written + * in — a `use` alias, a class in the same namespace, or already fully qualified. Reflection does not expose a + * file's imports, so the last case is answered by reading the source for its `use` statements, which is the + * only way to resolve an alias at all. + * + * WHY IT IS ITS OWN CLASS. This resolution existed twice before it existed here: once in packages/web's + * RouteScanner, where it compiles the hydration table ArgumentResolver binds against, and once mirrored into + * ElementTypes so the generator could answer the same question for a class no route reaches. That mirror is + * deliberate and documented — RouteScanner's copy is private to another package and cannot be called — but a + * THIRD copy, for the return-type parser, would have been one too many: three implementations of one rule + * drift, and the drift shows up as a document describing a shape the server would refuse to build. The + * mirror is now factored out so the generator has one copy of it rather than one per consumer. + * + * The `imports()` read is memoised per file because a DTO graph asks about the same file once per member, + * and re-reading and re-scanning a source file per member turned a twelve-member payload into twelve + * identical file reads. + */ +final class ClassNames +{ + /** @var array<string, array<string, string>> file path => alias => FQCN */ + private static array $imports = []; + + /** + * The class $name denotes when written inside $declaring, or null when it denotes no loadable class. + * + * Null rather than the unresolved string on purpose: every caller turns a resolved name into a `$ref`, + * and a name that does not resolve must produce no `$ref` at all rather than a pointer into a component + * that will never be registered. A dangling `$ref` breaks a viewer and every client generator; an absent + * one merely describes the member as an untyped value, which is the truth. + * + * @param ReflectionClass<object>|null $declaring + */ + public static function resolve(string $name, ?ReflectionClass $declaring): ?string + { + $name = ltrim(trim($name), '\\'); + + if ($name === '') { + return null; + } + + if (class_exists($name) || interface_exists($name) || enum_exists($name)) { + return $name; + } + + if ($declaring === null) { + return null; + } + + $namespace = $declaring->getNamespaceName(); + if ($namespace !== '') { + $candidate = $namespace.'\\'.$name; + if (class_exists($candidate) || interface_exists($candidate) || enum_exists($candidate)) { + return $candidate; + } + } + + // An alias may be written for a nested name too — `Dto\Line` where `Dto` is the import — so the + // first segment is what is matched and the rest is re-attached. + $head = $name; + $tail = ''; + if (($slash = strpos($name, '\\')) !== false) { + $head = substr($name, 0, $slash); + $tail = substr($name, $slash); + } + + foreach (self::imports($declaring) as $alias => $fqcn) { + if ($alias !== $head) { + continue; + } + + $candidate = $fqcn.$tail; + if (class_exists($candidate) || interface_exists($candidate) || enum_exists($candidate)) { + return $candidate; + } + } + + return null; + } + + /** + * The file's `use` imports, alias => FQCN, read from the source because reflection does not expose them. + * + * Grouped (`use A\{B, C}`) and function/const imports are not handled: neither can name a class in a + * type expression in any codebase this reads, and a partial regex that appeared to handle them would be + * worse than one that visibly does not. + * + * @param ReflectionClass<object> $declaring + * @return array<string, string> + */ + public static function imports(ReflectionClass $declaring): array + { + $file = $declaring->getFileName(); + + if ($file === false || ! is_file($file)) { + return []; + } + + if (isset(self::$imports[$file])) { + return self::$imports[$file]; + } + + $source = (string) file_get_contents($file); + $imports = []; + + if (preg_match_all('/^use\s+([\w\\\\]+)(?:\s+as\s+(\w+))?\s*;/mi', $source, $matches, PREG_SET_ORDER) !== false) { + foreach ($matches as $match) { + $fqcn = $match[1]; + $alias = $match[2] ?? ''; + + if ($alias === '') { + $parts = explode('\\', $fqcn); + $alias = end($parts); + } + + $imports[$alias] = $fqcn; + } + } + + return self::$imports[$file] = $imports; + } +} diff --git a/packages/openapi/src/Schema/DocType.php b/packages/openapi/src/Schema/DocType.php new file mode 100644 index 0000000..9e07263 --- /dev/null +++ b/packages/openapi/src/Schema/DocType.php @@ -0,0 +1,679 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Schema; + +use Closure; +use ReflectionClass; + +/** + * Compiles a PHPDoc TYPE EXPRESSION into a JSON Schema fragment. + * + * WHAT IT BUYS. A controller action's declared PHP return type is `array`, and `array` says nothing: every + * success response in a generated document was `{"type": "object"}` — a body with no members, which a viewer + * renders as a blank panel and a client generator turns into `any`. The shape was never missing, it was + * written one line above the method: + * + * @return array{page: int, size: int, total: int, items: list<Order>} + * + * and the same is true of the members inside a returned object (`@var list<OrderLine> $lines`). PHP's type + * system cannot express either; PHPDoc's can, and every one of these codebases already writes it because + * PHPStan at level max requires it. So the type expression is not a comment to be trusted on faith — it is + * a statement the static analyser already enforces against the code, which is precisely what makes it safe + * to publish. + * + * WHY A PARSER AND NOT MORE REGULAR EXPRESSIONS. The generator already had two regexes for the one shape it + * handled (`list<X>` and `X[]`), and they cannot be extended to the rest: `array{items: list<array<string, + * Order>>}` needs balanced `<>` and `{}` and a comma that is only a separator at the outer level. That is a + * grammar, and a grammar wants a recursive-descent parser — about two hundred lines here, against the four + * transitive dependencies phpstan/phpdoc-parser would add to every application that installs this package + * (the same trade DocBlock's docblock explains for prose). + * + * WHAT IT DELIBERATELY DOES NOT DO. It does not verify that the expression matches the code — PHPStan does + * that, and doing it again here would be a second, weaker implementation of a job already done. It does not + * resolve generics over user classes (`Page<Order>` documents as `Page`), because a schema cannot express a + * type parameter and inventing `PageOfOrder` would mint component names no source file contains. And an + * expression it cannot make anything of yields NULL rather than a guess, so every caller falls back to the + * declared PHP type instead of publishing a shape derived from a misread comment. + * + * NULL VERSUS THE EMPTY SCHEMA is the distinction the whole class turns on. `[]` is JSON Schema's "any + * value", a real answer that `mixed` genuinely deserves. `null` here means "this expression told me + * nothing" — an unresolvable class, a `callable`, a syntax error — and is the signal for a caller to use + * what it knew before it asked. + */ +final class DocType +{ + private int $at = 0; + + /** + * @param Closure(string): (array<string, mixed>|null) $schemaForClass a resolved FQCN to the fragment + * that stands for it, normally a + * `$ref`; null when it has none + * @param ReflectionClass<object>|null $context the class the expression was written inside, which is + * what makes `OrderLine` resolvable at all + */ + private function __construct( + private readonly string $source, + private readonly Closure $schemaForClass, + private readonly ?ReflectionClass $context, + ) {} + + /** + * The schema for a type expression, or null when it says nothing useful. + * + * @param Closure(string): (array<string, mixed>|null) $schemaForClass + * @param ReflectionClass<object>|null $context + * @return array<string, mixed>|null + */ + public static function schema(string $expression, Closure $schemaForClass, ?ReflectionClass $context = null): ?array + { + return (new self($expression, $schemaForClass, $context))->parseAll(); + } + + /** + * The type expression at the head of a `@return`/`@var` line, split from the prose that follows it. + * + * A tag line is `array{a: int} The page of results.` — one type expression and then English. The split + * cannot be done on whitespace, because a type expression contains spaces of its own (`array{a: int, b: + * string}`), so it is done by PARSING: whatever the grammar consumed is the type, and the remainder is + * prose. That prose is worth recovering rather than discarding — it is the only description a response + * has that an author actually wrote. + * + * @param Closure(string): (array<string, mixed>|null) $schemaForClass + * @param ReflectionClass<object>|null $context + * @return array{0: array<string, mixed>|null, 1: string} + */ + public static function split(string $line, Closure $schemaForClass, ?ReflectionClass $context = null): array + { + $parser = new self($line, $schemaForClass, $context); + $schema = $parser->parseUnion(); + + return [$schema, trim(substr($line, $parser->at))]; + } + + /** @return array<string, mixed>|null */ + private function parseAll(): ?array + { + $schema = $this->parseUnion(); + $this->spaces(); + + // Trailing input means the expression was not what it claimed to be — `array{` with no close, a stray + // token — and a half-parsed shape is worse than none. + return $this->at >= strlen($this->source) ? $schema : null; + } + + /** @return array<string, mixed>|null */ + private function parseUnion(): ?array + { + $parts = []; + $unknown = false; + + while (true) { + $part = $this->parseIntersection(); + $part === null ? $unknown = true : $parts[] = $part; + + $this->spaces(); + if ($this->peek() !== '|') { + break; + } + $this->at++; + } + + if ($parts === []) { + return null; + } + + // One arm of a union that could not be read makes the whole union a lie by omission: `Order|Draft` + // where Draft does not resolve would publish "always an Order". The empty schema — any value — is + // the honest answer, and is exactly what an unconstrained union is. + return $unknown ? [] : $this->union($parts); + } + + /** @return array<string, mixed>|null */ + private function parseIntersection(): ?array + { + $parts = []; + + while (true) { + $part = $this->parseAtomic(); + if ($part === null) { + return null; + } + $parts[] = $part; + + $this->spaces(); + if ($this->peek() !== '&') { + break; + } + $this->at++; + } + + return count($parts) === 1 ? $parts[0] : ['allOf' => $parts]; + } + + /** @return array<string, mixed>|null */ + private function parseAtomic(): ?array + { + $this->spaces(); + + if ($this->peek() === '?') { + $this->at++; + $inner = $this->parseAtomic(); + + return $inner === null ? null : $this->nullable($inner); + } + + if ($this->peek() === '(') { + $this->at++; + $inner = $this->parseUnion(); + $this->spaces(); + if ($this->peek() !== ')') { + return null; + } + $this->at++; + + return $inner === null ? null : $this->suffixes($inner); + } + + if ($this->peek() === '\'' || $this->peek() === '"') { + $literal = $this->stringLiteral(); + + return $literal === null ? null : $this->suffixes(['const' => $literal]); + } + + if ($this->peek() !== null && (ctype_digit($this->peek()) || ($this->peek() === '-' && ctype_digit($this->at(1))))) { + return $this->suffixes(['const' => $this->intLiteral()]); + } + + $name = $this->name(); + if ($name === '') { + return null; + } + + $this->spaces(); + if ($this->peek() === '<') { + $this->at++; + $arguments = $this->arguments('>'); + if ($arguments === null) { + return null; + } + + $generic = $this->generic($name, $arguments); + + return $generic === null ? null : $this->suffixes($generic); + } + + if ($this->peek() === '{' && in_array(strtolower($name), ['array', 'list', 'object'], true)) { + $this->at++; + $shape = $this->shape(); + + return $shape === null ? null : $this->suffixes($shape); + } + + $atom = $this->named($name); + + return $atom === null ? null : $this->suffixes($atom); + } + + /** + * `Order[]`, and `Order[][]` for a list of lists. + * + * @param array<string, mixed> $base + * @return array<string, mixed> + */ + private function suffixes(array $base): array + { + while (true) { + $this->spaces(); + if ($this->peek() !== '[' || $this->at(1) !== ']') { + return $base; + } + $this->at += 2; + $base = $base === [] ? ['type' => 'array'] : ['type' => 'array', 'items' => $base]; + } + } + + /** + * A bare type name: a PHPDoc scalar pseudo-type, or a class. + * + * The pseudo-types are here because PHPStan-flavoured PHPDoc uses them constantly and each one carries + * real information a plain `string`/`int` would throw away — `non-empty-string` is `minLength: 1`, + * `positive-int` is `minimum: 1`. Publishing that costs nothing and is exactly what a client generator + * turns into a validated field. + * + * @return array<string, mixed>|null + */ + private function named(string $name): ?array + { + return match (strtolower($name)) { + 'int', 'integer' => ['type' => 'integer'], + 'positive-int' => ['type' => 'integer', 'minimum' => 1], + 'negative-int' => ['type' => 'integer', 'maximum' => -1], + 'non-negative-int' => ['type' => 'integer', 'minimum' => 0], + 'non-positive-int' => ['type' => 'integer', 'maximum' => 0], + 'float', 'double' => ['type' => 'number'], + 'numeric' => ['type' => ['integer', 'number']], + 'string', 'class-string', 'callable-string', 'literal-string', 'lowercase-string', 'trait-string', 'interface-string' => ['type' => 'string'], + 'non-empty-string', 'non-empty-lowercase-string' => ['type' => 'string', 'minLength' => 1], + 'numeric-string' => ['type' => 'string', 'pattern' => '^-?\d+(\.\d+)?$'], + 'bool', 'boolean' => ['type' => 'boolean'], + 'true' => ['const' => true], + 'false' => ['const' => false], + 'null', 'void' => ['type' => 'null'], + 'array-key' => ['type' => ['string', 'integer']], + 'scalar' => ['type' => ['string', 'integer', 'number', 'boolean']], + 'array', 'list', 'non-empty-array', 'non-empty-list', 'iterable' => ['type' => 'array'], + 'object', 'stdclass' => ['type' => 'object'], + 'mixed' => [], + // `never` is not "any value", it is "no value" — an action returning it produces no body at all, + // which is a fact the caller acts on and must not receive as an empty any-schema. + 'never', 'never-return', 'noreturn' => null, + 'callable', 'closure', 'resource' => null, + default => $this->classNamed($name), + }; + } + + /** @return array<string, mixed>|null */ + private function classNamed(string $name): ?array + { + $lower = strtolower($name); + + if ($lower === 'self' || $lower === 'static' || $lower === '$this') { + return $this->context === null ? null : ($this->schemaForClass)($this->context->getName()); + } + + $resolved = ClassNames::resolve($name, $this->context); + + return $resolved === null ? null : ($this->schemaForClass)($resolved); + } + + /** + * A generic: `list<T>`, `array<K, V>`, `iterable<T>`, and the collection types that behave like them. + * + * `array<K, V>` is the one that has to make a decision: with an INTEGER key it is a JSON array, and with + * any other key it is a JSON object whose members are not known in advance — `additionalProperties`. + * Getting that backwards produces a document in which every `array<string, Money>` is a list, which a + * generated client then fails to decode against the real payload. + * + * @param list<array<string, mixed>|null> $arguments + * @return array<string, mixed>|null + */ + private function generic(string $name, array $arguments): ?array + { + $lower = strtolower($name); + $any = static fn (?array $schema): array => $schema ?? []; + + if ($lower === 'list' || $lower === 'non-empty-list') { + $schema = ['type' => 'array']; + if (($items = $any($arguments[0] ?? null)) !== []) { + $schema['items'] = $items; + } + if ($lower === 'non-empty-list') { + $schema['minItems'] = 1; + } + + return $schema; + } + + if (in_array($lower, ['array', 'non-empty-array', 'iterable', 'traversable', 'generator', 'collection', 'arrayobject', 'arrayiterator'], true)) { + $keyed = count($arguments) >= 2; + $value = $any($arguments[$keyed ? 1 : 0] ?? null); + + if ($keyed && ! $this->isIntegerKey($arguments[0])) { + $schema = ['type' => 'object']; + if ($value !== []) { + $schema['additionalProperties'] = $value; + } + + return $schema; + } + + $schema = ['type' => 'array']; + if ($value !== []) { + $schema['items'] = $value; + } + if ($lower === 'non-empty-array') { + $schema['minItems'] = 1; + } + + return $schema; + } + + // A user generic (`Page<Order>`, `Collection<int, Order>` over an app's own class). The type + // parameters are dropped: a component schema has no way to say "Page of Order" without minting a + // name — `PageOfOrder` — that appears in no source file and would change the moment a second + // instantiation showed up. + return $this->classNamed($name); + } + + /** + * Whether an `array<K, V>` key argument denotes integer keys, i.e. a JSON array rather than an object. + * + * @param array<string, mixed>|null $key + */ + private function isIntegerKey(?array $key): bool + { + if ($key === null) { + return false; + } + + $type = $key['type'] ?? null; + + return $type === 'integer' || (is_array($type) && $type === ['integer']); + } + + /** + * An array shape: `array{a: int, b?: string}` for an object, `array{int, string}` for a tuple, and a + * trailing `...` for one that admits members it does not name. + * + * A `?` on the KEY is what PHPDoc uses for "may be absent", which is the same statement `required` makes + * in JSON Schema — and is a different thing from a `?` on the VALUE, which is nullability. Conflating + * the two documents an omissible member as one that must be present and may be null, and a client + * generator turns that into a field it always sends. + * + * @return array<string, mixed>|null + */ + private function shape(): ?array + { + $properties = []; + $required = []; + $tuple = []; + $open = false; + + $this->spaces(); + if ($this->peek() === '}') { + $this->at++; + + return ['type' => 'object']; + } + + while (true) { + $this->spaces(); + + if ($this->peek() === '.' && $this->at(1) === '.' && $this->at(2) === '.') { + $this->at += 3; + $open = true; + $this->spaces(); + if ($this->peek() === ',') { + $this->at++; + + continue; + } + break; + } + + $key = null; + $optional = false; + $mark = $this->at; + + if ($this->peek() === '\'' || $this->peek() === '"') { + $key = $this->stringLiteral(); + } else { + $candidate = $this->name(); + if ($candidate !== '') { + $key = $candidate; + } + } + + if ($key !== null) { + $this->spaces(); + if ($this->peek() === '?') { + $this->at++; + $optional = true; + $this->spaces(); + } + if ($this->peek() === ':') { + $this->at++; + } else { + // Not `key: value` after all — it was a bare type in a tuple, so rewind and read it as one. + $this->at = $mark; + $key = null; + $optional = false; + } + } + + $value = $this->parseUnion(); + if ($value === null) { + $value = []; + } + + if ($key === null) { + $tuple[] = $value; + } else { + $properties[$key] = $value; + if (! $optional) { + $required[] = $key; + } + } + + $this->spaces(); + if ($this->peek() === ',') { + $this->at++; + + continue; + } + break; + } + + $this->spaces(); + if ($this->peek() !== '}') { + return null; + } + $this->at++; + + if ($properties === [] && $tuple !== []) { + return ['type' => 'array', 'prefixItems' => $tuple, 'minItems' => count($tuple), 'maxItems' => count($tuple)]; + } + + $schema = ['type' => 'object', 'properties' => $properties]; + if ($required !== []) { + $schema['required'] = $required; + } + if (! $open) { + // A closed shape names every member it has. Saying so is what lets a client generator produce a + // struct rather than a struct plus a bag, and it is true by construction here — an author who + // meant otherwise writes the `...`. + $schema['additionalProperties'] = false; + } + + return $schema; + } + + /** + * The comma-separated arguments of a generic, up to $close. + * + * @return list<array<string, mixed>|null>|null + */ + private function arguments(string $close): ?array + { + $arguments = []; + + while (true) { + $this->spaces(); + if ($this->peek() === $close) { + $this->at++; + + return $arguments; + } + + $arguments[] = $this->parseUnion(); + + $this->spaces(); + if ($this->peek() === ',') { + $this->at++; + + continue; + } + + if ($this->peek() === $close) { + $this->at++; + + return $arguments; + } + + return null; + } + } + + /** + * Collapses a union into the narrowest legal spelling. + * + * Three cases, in order of how much they help a reader. All arms scalar (`int|string`) becomes a single + * schema with a type ARRAY, which is 2020-12's own spelling and what a generator turns into a union + * type. All arms literals (`'draft'|'sent'`) becomes an `enum`, which is the whole reason to write such + * a union. Anything else is an `anyOf`, which is always correct and never as readable. + * + * @param list<array<string, mixed>> $parts + * @return array<string, mixed> + */ + private function union(array $parts): array + { + $unique = []; + foreach ($parts as $part) { + $key = json_encode($part); + $unique[is_string($key) ? $key : count($unique)] = $part; + } + $parts = array_values($unique); + + if (count($parts) === 1) { + return $parts[0]; + } + + $constants = []; + foreach ($parts as $part) { + if (array_keys($part) === ['const']) { + $constants[] = $part['const']; + } + } + if (count($constants) === count($parts)) { + $types = array_values(array_unique(array_map( + static fn (mixed $v): string => match (true) { + is_int($v) => 'integer', + is_bool($v) => 'boolean', + is_float($v) => 'number', + default => 'string', + }, + $constants, + ))); + + return ['type' => count($types) === 1 ? $types[0] : $types, 'enum' => $constants]; + } + + $types = []; + foreach ($parts as $part) { + if (array_keys($part) !== ['type'] || ! is_string($part['type'])) { + $types = null; + break; + } + $types[] = $part['type']; + } + if ($types !== null) { + return ['type' => array_values(array_unique($types))]; + } + + return ['anyOf' => $parts]; + } + + /** + * @param array<string, mixed> $schema + * @return array<string, mixed> + */ + private function nullable(array $schema): array + { + if ($schema === []) { + return []; + } + + if (array_keys($schema) === ['type'] && is_string($schema['type'])) { + return ['type' => [$schema['type'], 'null']]; + } + + if (isset($schema['type']) && is_string($schema['type']) && ! isset($schema['$ref'])) { + $schema['type'] = [$schema['type'], 'null']; + + return $schema; + } + + // A `$ref` cannot be widened in place: sibling validation keywords are applied WITH the reference in + // 2020-12, so a `type: null` beside it would have to hold as well as the reference and never could. + return ['anyOf' => [$schema, ['type' => 'null']]]; + } + + private function name(): string + { + $this->spaces(); + $start = $this->at; + $length = strlen($this->source); + + while ($this->at < $length) { + $char = $this->source[$this->at]; + if (ctype_alnum($char) || $char === '_' || $char === '\\' || $char === '-' || $char === '$') { + $this->at++; + + continue; + } + break; + } + + return substr($this->source, $start, $this->at - $start); + } + + private function stringLiteral(): ?string + { + $quote = $this->peek(); + if ($quote !== '\'' && $quote !== '"') { + return null; + } + + $this->at++; + $start = $this->at; + $length = strlen($this->source); + + while ($this->at < $length && $this->source[$this->at] !== $quote) { + $this->at += $this->source[$this->at] === '\\' ? 2 : 1; + } + + if ($this->at >= $length) { + return null; + } + + $value = substr($this->source, $start, $this->at - $start); + $this->at++; + + return stripcslashes($value); + } + + private function intLiteral(): int + { + $start = $this->at; + if ($this->peek() === '-') { + $this->at++; + } + while ($this->peek() !== null && ctype_digit((string) $this->peek())) { + $this->at++; + } + + return (int) substr($this->source, $start, $this->at - $start); + } + + private function spaces(): void + { + $length = strlen($this->source); + while ($this->at < $length && ($this->source[$this->at] === ' ' || $this->source[$this->at] === "\t" || $this->source[$this->at] === "\n" || $this->source[$this->at] === "\r")) { + $this->at++; + } + } + + private function peek(): ?string + { + return $this->at(0); + } + + private function at(int $ahead): ?string + { + $index = $this->at + $ahead; + + return $index < strlen($this->source) ? $this->source[$index] : null; + } +} diff --git a/packages/openapi/src/Schema/DtoSchemaFactory.php b/packages/openapi/src/Schema/DtoSchemaFactory.php index bb5cc96..d76dd97 100644 --- a/packages/openapi/src/Schema/DtoSchemaFactory.php +++ b/packages/openapi/src/Schema/DtoSchemaFactory.php @@ -103,11 +103,16 @@ private function build(string $class, SchemaRegistry $registry, array $propertie // same doc comment once per `array` property. $lists = $elements->forClass($class); + // The constructor's own `@param` expressions, for the members PHP's `array` cannot describe. Read + // once per class for the same reason the element table is. + $reflection = $this->reflect($class); + $documented = DocBlock::parse($reflection?->getConstructor()?->getDocComment())->paramTypes(); + $fields = []; $required = []; foreach ($this->members($class, $properties, $own) as $name => $member) { - $property = $this->property($member, $own[$name] ?? [], $nested[$name] ?? [], $registry, $elements, $lists[$name] ?? null); + $property = $this->property($member, $own[$name] ?? [], $nested[$name] ?? [], $registry, $elements, $lists[$name] ?? null, $documented[$name] ?? null, $reflection); $fields[$name] = $property->schema; if ($property->required) { @@ -135,8 +140,10 @@ private function build(string $class, SchemaRegistry $registry, array $propertie * @param list<string|ValidationRule> $rules this member's own compiled rule list * @param array<string, list<string|ValidationRule>> $nestedRules rules cascaded from a parent #[Valid] * @param string|null $element the class this member's list holds, when it holds a list of one + * @param string|null $documented the constructor's `@param` type expression for this member + * @param ReflectionClass<object>|null $declaring the class the expression was written inside */ - private function property(MemberType $member, array $rules, array $nestedRules, SchemaRegistry $registry, ElementTypes $elements, ?string $element): PropertySchema + private function property(MemberType $member, array $rules, array $nestedRules, SchemaRegistry $registry, ElementTypes $elements, ?string $element, ?string $documented = null, ?ReflectionClass $declaring = null): PropertySchema { $type = $member->type; @@ -167,6 +174,18 @@ private function property(MemberType $member, array $rules, array $nestedRules, $items = $element === null ? null : $this->items($element, $registry, $elements); if ($items !== null && ($base['type'] ?? null) === 'array') { $base['items'] = $items; + } elseif ($element === null && ($base['type'] ?? null) === 'array' && $documented !== null) { + // The compiled table had nothing for this member, which means the hydrator does not treat it as a + // list of DTOs — and that is every collection PHP's `array` describes and the table does not: + // `list<string>`, `array<string, int>`, `list<list<int>>`. Each of those was published as a bare + // `type: array`, so a list of scalars became Array<any> and a MAP was documented as an array, + // which is not merely vague but the wrong JSON type. The expression is read only when the table + // declined, so the hydrator's answer still wins wherever it has one. + $shape = DocType::schema($documented, fn (string $c): array => $this->classSchema($c, $registry, $elements), $declaring); + + if ($shape !== null && isset($shape['type']) && in_array($shape['type'], ['array', 'object'], true)) { + $base = [...$shape, ...array_diff_key($base, ['type' => null])]; + } } $property = $this->mapper->apply($base, $rules, $member->nullable, $member->required()); @@ -179,6 +198,21 @@ private function property(MemberType $member, array $rules, array $nestedRules, return new PropertySchema($member->doc->apply($schema), $property->required); } + /** + * The fragment that stands for a class inside a documented type expression — the same choice items() + * makes, hoisted so DocType can call it for a class at any depth of a shape. + * + * @return array<string, mixed> + */ + private function classSchema(string $class, SchemaRegistry $registry, ElementTypes $elements): array + { + if (TypeSchema::isDto($class)) { + return ['$ref' => $this->ref($class, $registry, [], [], $elements)]; + } + + return TypeSchema::for($class) ?? []; + } + /** * The `items` subschema for a list member. * diff --git a/packages/openapi/src/Schema/ElementTypes.php b/packages/openapi/src/Schema/ElementTypes.php index 314bef1..bd31ff3 100644 --- a/packages/openapi/src/Schema/ElementTypes.php +++ b/packages/openapi/src/Schema/ElementTypes.php @@ -28,10 +28,10 @@ * is still sitting in the docblock, and the choice is between reading it and shipping `Array<any>` again. * RouteScanner's resolution is private to packages/web and reachable only through a compiled binding, so it * cannot be called; it is therefore MIRRORED here, rule for rule — the same two `@param` spellings, the same - * name resolution (qualified name, then the declaring class's namespace, then the file's `use` imports), and - * the same restriction to a parameter DECLARED `array`, so a member the hydrator would leave alone is never - * given `items` here either. Two implementations of one rule is a real cost; the alternative was a generator - * whose output silently depended on whether a route happened to reach the class. + * name resolution (now factored into ClassNames, so this package holds one copy of it rather than one per + * consumer), and the same restriction to a parameter DECLARED `array`, so a member the hydrator would leave + * alone is never given `items` here either. Two implementations of one rule is a real cost; the alternative + * was a generator whose output silently depended on whether a route happened to reach the class. * * The mirror is deliberately not consulted when the table HAS a row for the class. A row is complete — the * scanner walked every constructor parameter to build it — so a member missing from it is a member the @@ -136,7 +136,7 @@ private function docblockParamTypes(string $docComment, ReflectionClass $declari } foreach ($matches as $match) { - $resolved = $this->resolveClassName(trim($match[1]), $declaring); + $resolved = ClassNames::resolve(trim($match[1]), $declaring); if ($resolved !== null) { $types[$match[2]] = $resolved; } @@ -149,61 +149,4 @@ private function docblockParamTypes(string $docComment, ReflectionClass $declari /** * @param ReflectionClass<object> $declaring */ - private function resolveClassName(string $name, ReflectionClass $declaring): ?string - { - $name = ltrim($name, '\\'); - - if (class_exists($name)) { - return $name; - } - - $namespace = $declaring->getNamespaceName(); - if ($namespace !== '' && class_exists($candidate = $namespace.'\\'.$name)) { - return $candidate; - } - - foreach ($this->imports($declaring) as $alias => $fqcn) { - if ($alias === $name && class_exists($fqcn)) { - return $fqcn; - } - } - - return null; - } - - /** - * The file's `use` imports, alias => FQCN, read from the source because reflection does not expose them. - * - * @param ReflectionClass<object> $declaring - * @return array<string, string> - */ - private function imports(ReflectionClass $declaring): array - { - $file = $declaring->getFileName(); - - if ($file === false || ! is_file($file)) { - return []; - } - - $source = (string) file_get_contents($file); - - if (preg_match_all('/^use\s+([\w\\\\]+)(?:\s+as\s+(\w+))?\s*;/mi', $source, $matches, PREG_SET_ORDER) === false) { - return []; - } - - $imports = []; - foreach ($matches as $match) { - $fqcn = $match[1]; - $alias = $match[2] ?? ''; - - if ($alias === '') { - $parts = explode('\\', $fqcn); - $alias = end($parts); - } - - $imports[$alias] = $fqcn; - } - - return $imports; - } } diff --git a/packages/openapi/src/Schema/MapperState.php b/packages/openapi/src/Schema/MapperState.php index 7f78806..2ff5cc4 100644 --- a/packages/openapi/src/Schema/MapperState.php +++ b/packages/openapi/src/Schema/MapperState.php @@ -219,15 +219,22 @@ private function isNumericType(): bool return in_array($this->schema['type'] ?? null, ['integer', 'number'], true); } + /** + * Which JSON Schema keyword a #[Size] means, which depends entirely on what the property IS. + * + * Three shapes, three keyword pairs: a list is bounded with `minItems`/`maxItems`, a MAP with + * `minProperties`/`maxProperties`, and a string with `minLength`/`maxLength`. The map case exists because + * a documented `array<string, int>` is a JSON object, not a JSON array — before the type expression was + * read, every such member was typed `array` and the distinction could not arise. Leaving it out would + * have put `minLength` on an object, which is not a constraint on an object at all: a validator ignores + * it, so the document would silently stop stating a bound the server does enforce. + */ private function lengthKeyword(bool $min): string { - $array = ($this->schema['type'] ?? null) === 'array'; - - return match (true) { - $array && $min => 'minItems', - $array => 'maxItems', - $min => 'minLength', - default => 'maxLength', + return match ($this->schema['type'] ?? null) { + 'array' => $min ? 'minItems' : 'maxItems', + 'object' => $min ? 'minProperties' : 'maxProperties', + default => $min ? 'minLength' : 'maxLength', }; } diff --git a/packages/openapi/src/Schema/ResponseSchemaFactory.php b/packages/openapi/src/Schema/ResponseSchemaFactory.php new file mode 100644 index 0000000..d539ad7 --- /dev/null +++ b/packages/openapi/src/Schema/ResponseSchemaFactory.php @@ -0,0 +1,246 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Schema; + +use Firefly\OpenApi\Generator\DocBlock; +use JsonSerializable; +use ReflectionClass; +use ReflectionNamedType; +use ReflectionParameter; +use ReflectionProperty; + +/** + * Builds the `components/schemas` entry for a class an action RETURNS. + * + * WHY IT IS NOT DtoSchemaFactory. That factory documents a request body, and it derives the member list from + * the CONSTRUCTOR and the member rules from the compiled ConstraintManifest — the right two sources for a + * payload the server binds and validates. Neither applies on the way out. A response is never validated, so + * there are no rules; and a response object's members are what `json_encode` emits, which is its PUBLIC + * PROPERTIES and not its constructor parameters. The two coincide for a promoted-property DTO and diverge + * the moment a class takes a collaborator it does not expose, or exposes a value it did not take. Reusing + * the request factory here would have documented every response as its own constructor — including the + * private dependencies. + * + * THE WIRE SHAPE IS WHAT `json_encode` PRODUCES, and PHP gives that two spellings: + * + * - A class implementing JsonSerializable serialises as whatever `jsonSerialize()` RETURNS, which may look + * nothing like its properties. App\Orders\Order is the case that matters: it publishes a `total` that is + * a derived method, not a property, so reflecting properties alone documents five of the six members + * that actually appear on the wire. The method's own `@return array{...}` states the shape exactly, and + * that is read first. + * - Everything else serialises as its public properties, which is what reflection reads. + * + * A DECLARED SHAPE ONLY WINS WHEN IT SAYS SOMETHING. `@return array<string, mixed>` parses fine and means + * "an object, members unknown" — strictly less than the property list it would have suppressed. So a parsed + * shape is accepted only when it carries members (`properties`), an element type (`items`), a value type + * (`additionalProperties`) or a reference; otherwise the reflection path runs and the comment is ignored. + * That is the difference between reading a docblock and obeying one. + * + * PROPERTY TYPES come from the declared PHP type, refined by PHPDoc where PHP cannot speak: `array` with a + * `@var list<OrderLine>` on the property, or — for a promoted property, whose docblock PHP attaches to the + * constructor parameter rather than the property — the constructor's `@param` line for it. Without that + * refinement every collection member in every response is `Array<any>`, which is the same hole this package + * already closed on the request side. + * + * NULLABILITY IS NOT REQUIREDNESS. A response member is present or it is not, and a `?int $id` is always + * PRESENT — it is simply sometimes null. So every public property is `required` and nullable ones widen + * their type, which is the opposite of the request side, where a nullable member is usually omissible. A + * document that marked `id` optional would tell a generated client to expect its absence, and it never is. + */ +final class ResponseSchemaFactory +{ + /** + * The fragment that stands for $class in a response position: an inline one for the types that have a + * scalar spelling (a backed enum, a DateTimeInterface), a `$ref` for anything reflectable, and the + * any-value schema for a class this process cannot look at. + * + * @return array<string, mixed> + */ + public function schema(string $class, SchemaRegistry $registry): array + { + $inline = TypeSchema::for($class); + + if ($inline !== null) { + return $inline; + } + + return ['$ref' => $this->ref($class, $registry)]; + } + + /** @return string the `$ref` pointer to this class's component schema */ + public function ref(string $class, SchemaRegistry $registry): string + { + return $registry->ref($class, fn (): array => $this->build($class, $registry)); + } + + /** + * @return array<string, mixed> + */ + private function build(string $class, SchemaRegistry $registry): array + { + /** @var class-string $class */ + $reflection = new ReflectionClass($class); + + $schema = $this->declaredShape($reflection, $registry) ?? $this->reflectedShape($reflection, $registry); + + $description = DocBlock::parse($reflection->getDocComment())->prose(); + + return [ + 'title' => $reflection->getShortName(), + 'description' => $description === '' ? 'Response payload serialised from '.$class.'.' : $description, + ...$schema, + ]; + } + + /** + * The shape a JsonSerializable class states for itself, when it states one worth having. + * + * @param ReflectionClass<object> $class + * @return array<string, mixed>|null + */ + private function declaredShape(ReflectionClass $class, SchemaRegistry $registry): ?array + { + if (! $class->implementsInterface(JsonSerializable::class) || ! $class->hasMethod('jsonSerialize')) { + return null; + } + + $line = DocBlock::parse($class->getMethod('jsonSerialize')->getDocComment())->returnLine(); + + if ($line === null) { + return null; + } + + [$schema] = DocType::split($line, fn (string $c): array => $this->schema($c, $registry), $class); + + return $this->informative($schema) ? $schema : null; + } + + /** + * Whether a parsed schema says more than "some object" — see the class docblock on why a shape that does + * not is discarded in favour of reflection. + * + * @param array<string, mixed>|null $schema + */ + private function informative(?array $schema): bool + { + if ($schema === null) { + return false; + } + + foreach (['properties', 'items', 'additionalProperties', '$ref', 'enum', 'anyOf', 'allOf', 'prefixItems'] as $key) { + if (array_key_exists($key, $schema)) { + return true; + } + } + + return false; + } + + /** + * The public properties, which is exactly what `json_encode` walks for a plain object. + * + * @param ReflectionClass<object> $class + * @return array<string, mixed> + */ + private function reflectedShape(ReflectionClass $class, SchemaRegistry $registry): array + { + $constructor = $class->getConstructor(); + $constructorDoc = DocBlock::parse($constructor?->getDocComment()); + $promotedTypes = $constructorDoc->paramTypes(); + + /** @var array<string, ReflectionParameter> $parameters */ + $parameters = []; + foreach ($constructor?->getParameters() ?? [] as $parameter) { + $parameters[$parameter->getName()] = $parameter; + } + + $properties = []; + $required = []; + + foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic()) { + continue; + } + + $name = $property->getName(); + + // A promoted property's prose lives on the CONSTRUCTOR's `@param` line, not on the property — + // PHP attaches no docblock to the property it synthesises — so the parameter is the richer + // source whenever there is one. + $doc = isset($parameters[$name]) + ? MemberDoc::forParameter($parameters[$name], $constructorDoc) + : MemberDoc::forProperty($property); + + $properties[$name] = $doc->apply($this->property($property, $promotedTypes[$name] ?? null, $class, $registry)); + $required[] = $name; + } + + $schema = ['type' => 'object', 'properties' => $properties]; + + if ($required !== []) { + $schema['required'] = $required; + } + + return $schema; + } + + /** + * One property's schema: the declared type, refined by PHPDoc. #[ApiProperty] and the prose are layered + * on by the caller through MemberDoc, which already owns that precedence for the request side. + * + * The PHPDoc refinement is applied only when it is at least as specific as the declared type — a + * `@var list<Line>` on an `array` property is a strict improvement, whereas a stale `@var string` on an + * `int` property is a comment that has drifted from the code, and the code is what serialises. + * + * @param ReflectionClass<object> $declaring + * @return array<string, mixed> + */ + private function property(ReflectionProperty $property, ?string $promotedType, ReflectionClass $declaring, SchemaRegistry $registry): array + { + $type = $property->getType(); + $declared = $type instanceof ReflectionNamedType ? $type->getName() : null; + $nullable = $type?->allowsNull() ?? true; + + $expression = DocBlock::parse($property->getDocComment())->varType() ?? $promotedType; + + $schema = null; + if ($expression !== null) { + $schema = DocType::schema($expression, fn (string $c): array => $this->schema($c, $registry), $declaring); + } + + if (! $this->informative($schema)) { + $schema = $declared === null + ? [] + : (TypeSchema::for($declared) ?? ['$ref' => $this->ref($declared, $registry)]); + + if ($nullable) { + $schema = $this->nullable($schema); + } + } + + return $schema ?? []; + } + + /** + * @param array<string, mixed> $schema + * @return array<string, mixed> + */ + private function nullable(array $schema): array + { + if ($schema === []) { + return []; + } + + if (isset($schema['$ref'])) { + return ['anyOf' => [$schema, ['type' => 'null']]]; + } + + if (isset($schema['type']) && is_string($schema['type'])) { + $schema['type'] = [$schema['type'], 'null']; + } + + return $schema; + } +} diff --git a/packages/openapi/tests/Generator/NestedSchemaTest.php b/packages/openapi/tests/Generator/NestedSchemaTest.php index 5c42adf..ef57ec2 100644 --- a/packages/openapi/tests/Generator/NestedSchemaTest.php +++ b/packages/openapi/tests/Generator/NestedSchemaTest.php @@ -183,15 +183,30 @@ ->toBe(['CategoryNode', 'CreateOrderRequest', 'LineOptionRequest', 'OrderLineRequest']); }); -it('does not invent items for a list whose element type is not a class', function () { - $document = FixtureDocument::generatorFor('NestedFixture')->generate(); +it('types a list of scalars identically down the compiled path and the reflected one', function () { + // `list<string>` names no class, so it is absent from RouteScanner's hydration table AND from + // ElementTypes' reflection mirror of that table. It used to be published as a bare `type: array` for + // exactly that reason, and the stated justification was drift: an `items` that only one of the two paths + // could produce would be two implementations of one rule disagreeing about the same member. + // + // The type expression is therefore read where that cannot happen — AFTER both element-type paths have + // declined, in DtoSchemaFactory, so the same step runs whichever path was taken. This asserts the + // property the old test was protecting, rather than the missing `items` it was protecting it with: the + // two paths agree. They now agree on `items: string` instead of on nothing. + $compiled = FixtureDocument::resolve( + FixtureDocument::generatorFor('NestedFixture')->generate(), + '#/components/schemas/LineOptionRequest/properties/notes', + ); + + $registry = new SchemaRegistry; + FixtureDocument::schemas('NestedFixture')->ref(CreateOrderRequest::class, $registry); + $reflected = FixtureDocument::resolve( + ['components' => ['schemas' => $registry->all()]], + '#/components/schemas/LineOptionRequest/properties/notes', + ); - // `list<string>` names no class, so RouteScanner leaves it out of the hydration table and the generator - // leaves `items` off. Widening the rule to cover scalars here would make the fallback path emit an - // `items` the compiled-table path does not — two implementations of one rule, disagreeing about the same - // member, which is precisely the drift that must not happen. - expect(FixtureDocument::resolve($document, '#/components/schemas/LineOptionRequest/properties/notes')) - ->toBe(['type' => 'array', 'default' => []]); + expect($compiled)->toBe(['type' => 'array', 'items' => ['type' => 'string'], 'default' => []]) + ->and($reflected)->toBe($compiled); }); it('reads element types out of a manifest that has been through the compiled array form', function () { diff --git a/packages/openapi/tests/Generator/ResponseSchemaTest.php b/packages/openapi/tests/Generator/ResponseSchemaTest.php new file mode 100644 index 0000000..692e52a --- /dev/null +++ b/packages/openapi/tests/Generator/ResponseSchemaTest.php @@ -0,0 +1,119 @@ +<?php + +declare(strict_types=1); + +use Firefly\OpenApi\Tests\Support\FixtureDocument; + +/** + * What an operation RETURNS, which the generated document did not state. + * + * Every success response in every document this package produced was `{"type": "object"}` — an object with + * no members. A viewer renders that as a blank panel; `openapi-generator` turns it into `any`. So the most + * useful sentence an API document contains ("here is what you get back") was the one sentence missing, for + * every endpoint, in every application. + * + * The shape was never unavailable. It was in the `@return` one line above the method, where PHPStan at level + * max already checks it against the code on every build — which is exactly what makes reading it safe, and + * is the same reason RouteScanner reads `@param` to compile the table ArgumentResolver hydrates from. + */ +$document = static fn (): array => FixtureDocument::generatorFor('ResponseFixture')->generate(); + +it('expands an array-shape return into a real object schema', function () use ($document) { + $schema = FixtureDocument::resolve($document(), '#/paths/~1api~1consignments/get/responses/200/content/application~1json/schema'); + + expect($schema)->toBe([ + 'type' => 'object', + 'properties' => [ + 'page' => ['type' => 'integer', 'minimum' => 1], + 'size' => ['type' => 'integer', 'minimum' => 1], + 'total' => ['type' => 'integer'], + 'items' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Consignment']], + ], + 'required' => ['page', 'size', 'total', 'items'], + // A shape names every member it has, and saying so is what lets a generator emit a struct rather + // than a struct plus a bag. An author who means otherwise writes the `...`. + 'additionalProperties' => false, + ]); +}); + +it('refs a class return rather than flattening it', function () use ($document) { + expect(FixtureDocument::resolve($document(), '#/paths/~1api~1consignments~1{reference}/get/responses/200/content/application~1json/schema')) + ->toBe(['$ref' => '#/components/schemas/Consignment']); +}); + +it('builds a response component from the wire shape, not from the property list', function () use ($document) { + /** @var array{properties: array<string, mixed>, description: string} $schema */ + $schema = FixtureDocument::resolve($document(), '#/components/schemas/Consignment'); + + // Consignment is JsonSerializable, so `json_encode` emits what jsonSerialize() RETURNS. That is neither + // the constructor's parameters nor the public properties: `weightGrams` is a derived method that IS + // published, and `$auditTrail`/`$parcelGrams` are private and are NOT. Reflecting properties would get + // both halves wrong in the same schema. + expect(array_keys($schema['properties'])) + ->toBe(['reference', 'declaredValue', 'shipments', 'weightGrams']) + ->and($schema['properties']['weightGrams'])->toBe(['type' => 'integer', 'minimum' => 1]) + ->and($schema['properties']['declaredValue'])->toBe(['$ref' => '#/components/schemas/Money']) + ->and($schema['properties']['shipments'])->toBe(['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Shipment']]) + ->and($schema['description'])->toStartWith('A consignment as the API publishes it.'); +}); + +it('reflects public properties for a class that declares no shape', function () use ($document) { + /** @var array{properties: array<string, mixed>, required: list<string>} $schema */ + $schema = FixtureDocument::resolve($document(), '#/components/schemas/Shipment'); + + // No JsonSerializable, so the wire shape IS the public property list — which is what json_encode walks. + // A nullable member stays REQUIRED and widens its type instead: a response member is present or absent, + // and `?string $tracking` is always present and sometimes null. Marking it optional would tell a client + // to expect its absence, and it never is. + expect($schema['properties']['carrier'])->toBe(['type' => 'string']) + ->and($schema['properties']['tracking'])->toBe(['type' => ['string', 'null']]) + ->and($schema['properties']['expectedAt'])->toBe(['type' => ['string', 'null'], 'format' => 'date-time']) + ->and($schema['properties']['checkpoints'])->toBe([ + 'description' => 'where it has been scanned, oldest first', + 'type' => 'array', + 'items' => ['type' => 'string'], + ]) + ->and($schema['required'])->toBe(['carrier', 'tracking', 'checkpoints', 'expectedAt']); +}); + +it('types a bare list return and takes its description from the same line', function () use ($document) { + /** @var array{description: string, content: array<string, array<string, mixed>>} $response */ + $response = FixtureDocument::resolve($document(), '#/paths/~1api~1consignments~1{reference}~1shipments/get/responses/200'); + + // The prose after a type expression is the only response description an author ever actually writes, so + // it is split off the same line rather than discarded. + expect($response['description'])->toBe('newest first') + ->and($response['content']['application/json']['schema']) + ->toBe(['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Shipment']]); +}); + +it('documents a string-keyed map as an object, not as an array', function () use ($document) { + // `array<string, Money>` is a JSON object. Publishing it as `type: array` — which is what a bare PHP + // `array` degrades to — is not merely vague, it is the wrong JSON type, and a generated client fails to + // decode the payload the server actually sends. + expect(FixtureDocument::resolve($document(), '#/paths/~1api~1consignments~1totals/get/responses/200/content/application~1json/schema')) + ->toBe(['type' => 'object', 'additionalProperties' => ['$ref' => '#/components/schemas/Money']]); +}); + +it('keeps type: object for an array return that says nothing about itself', function () use ($document) { + // `@return array<string, mixed>` parses fine and means "an object, members unknown" — strictly less than + // nothing, since accepting it would suppress whatever the declared type knew. The old behaviour is the + // FALLBACK now rather than the answer, and this is the case it is still correct for. + expect(FixtureDocument::resolve($document(), '#/paths/~1api~1consignments/post/responses/201/content/application~1json/schema')) + ->toBe(['type' => 'object']); +}); + +it('lets #[ApiResponse] name a class or a full type expression', function () use ($document) { + /** @var array<array-key, array{content: array<string, array<string, mixed>>}> $responses */ + $responses = FixtureDocument::operation($document(), '/api/consignments', 'post')['responses']; + + expect($responses[409]['content']['application/json']['schema']) + ->toBe(['$ref' => '#/components/schemas/Consignment']) + ->and($responses[202]['content']['application/json']['schema']) + ->toBe(['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Shipment']]); +}); + +it('still writes no content for a 204', function () use ($document) { + expect(FixtureDocument::resolve($document(), '#/paths/~1api~1consignments~1{reference}/delete/responses/204')) + ->toBe(['description' => 'No content.']); +}); diff --git a/packages/openapi/tests/NestedFixture/LineOptionRequest.php b/packages/openapi/tests/NestedFixture/LineOptionRequest.php index 2467b79..faca4ff 100644 --- a/packages/openapi/tests/NestedFixture/LineOptionRequest.php +++ b/packages/openapi/tests/NestedFixture/LineOptionRequest.php @@ -16,9 +16,10 @@ final class LineOptionRequest { /** - * `$notes` holds a list of SCALARS, which is the case both element-type paths deliberately decline to - * answer: `string` is not a class, so RouteScanner leaves the member out of its hydration table and the - * generator leaves `items` off rather than emit one only the reflection path could produce. + * `$notes` holds a list of SCALARS. Neither element-type path answers for it — `string` is not a class, + * so RouteScanner leaves the member out of its hydration table and ElementTypes' reflection mirror + * declines it too — which is exactly why the type expression is read afterwards, by the one step that + * runs after BOTH paths have declined and so cannot disagree with either. * * @param list<string> $notes */ diff --git a/packages/openapi/tests/ResponseFixture/Consignment.php b/packages/openapi/tests/ResponseFixture/Consignment.php new file mode 100644 index 0000000..95dd123 --- /dev/null +++ b/packages/openapi/tests/ResponseFixture/Consignment.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\ResponseFixture; + +use JsonSerializable; + +/** + * A consignment as the API publishes it. + * + * The wire shape is NOT the property list: `weightGrams` is derived and published, and the private + * `$auditTrail` is not published at all. That divergence is the whole reason a response schema is built from + * the declared shape rather than from reflection. + */ +final readonly class Consignment implements JsonSerializable +{ + /** + * @param non-empty-string $reference + * @param list<Shipment> $shipments + * @param list<positive-int> $parcelGrams + */ + public function __construct( + public string $reference, + public Money $declaredValue, + public array $shipments, + private array $parcelGrams = [], + private string $auditTrail = '', + ) {} + + /** @return positive-int */ + public function weightGrams(): int + { + return max(1, array_sum($this->parcelGrams)); + } + + /** + * Public, and deliberately NOT part of the wire shape — a class may expose more to PHP than it publishes + * over HTTP, which is the second half of why a response schema is built from jsonSerialize() rather than + * from what happens to be reachable. + */ + public function auditTrail(): string + { + return $this->auditTrail; + } + + /** @return array{reference: non-empty-string, declaredValue: Money, shipments: list<Shipment>, weightGrams: positive-int} */ + public function jsonSerialize(): array + { + return [ + 'reference' => $this->reference, + 'declaredValue' => $this->declaredValue, + 'shipments' => $this->shipments, + 'weightGrams' => $this->weightGrams(), + ]; + } +} diff --git a/packages/openapi/tests/ResponseFixture/ConsignmentController.php b/packages/openapi/tests/ResponseFixture/ConsignmentController.php new file mode 100644 index 0000000..c3efaef --- /dev/null +++ b/packages/openapi/tests/ResponseFixture/ConsignmentController.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\ResponseFixture; + +use Firefly\OpenApi\Attributes\ApiResponse; +use Firefly\Web\Attributes\DeleteMapping; +use Firefly\Web\Attributes\GetMapping; +use Firefly\Web\Attributes\PathVariable; +use Firefly\Web\Attributes\PostMapping; +use Firefly\Web\Attributes\RequestMapping; +use Firefly\Web\Attributes\RestController; + +/** + * The fixture for RESPONSE shapes: one action per way a LaraFly action states what it returns. + * + * Every one of these used to document itself as `{"type": "object"}` — a body with no members — because the + * generator read the declared PHP return type and stopped. `array` is the declared type of five of the six + * actions below, and `array` says nothing at all. + */ +#[RestController] +#[RequestMapping('/api/consignments')] +final class ConsignmentController +{ + /** + * A page of consignments. + * + * @return array{page: positive-int, size: positive-int, total: int, items: list<Consignment>} + */ + #[GetMapping] + public function index(): array + { + return ['page' => 1, 'size' => 20, 'total' => 0, 'items' => []]; + } + + /** + * One consignment. + * + * @param non-empty-string $reference + */ + #[GetMapping('/{reference}')] + public function show(#[PathVariable] string $reference): Consignment + { + return new Consignment($reference, new Money(0, Currency::Eur), []); + } + + /** + * The shipments on a consignment. + * + * @return list<Shipment> newest first + */ + #[GetMapping('/{reference}/shipments')] + public function shipments(#[PathVariable] string $reference): array + { + return []; + } + + /** + * Total declared value per currency. + * + * @return array<string, Money> + */ + #[GetMapping('/totals')] + public function totals(): array + { + return []; + } + + /** + * Books a consignment. + * + * @return array<string, mixed> + */ + #[PostMapping(status: 201)] + #[ApiResponse(status: 409, description: 'A consignment with that reference already exists.', type: Consignment::class)] + #[ApiResponse(status: 202, description: 'Accepted for later booking.', type: 'list<Shipment>')] + public function book(): array + { + return []; + } + + #[DeleteMapping('/{reference}', status: 204)] + public function cancel(#[PathVariable] string $reference): void {} +} diff --git a/packages/openapi/tests/ResponseFixture/Currency.php b/packages/openapi/tests/ResponseFixture/Currency.php new file mode 100644 index 0000000..4bbf670 --- /dev/null +++ b/packages/openapi/tests/ResponseFixture/Currency.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\ResponseFixture; + +enum Currency: string +{ + case Eur = 'EUR'; + case Gbp = 'GBP'; +} diff --git a/packages/openapi/tests/ResponseFixture/Money.php b/packages/openapi/tests/ResponseFixture/Money.php new file mode 100644 index 0000000..91e27e7 --- /dev/null +++ b/packages/openapi/tests/ResponseFixture/Money.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\ResponseFixture; + +/** An amount in minor units, with the currency it is denominated in. */ +final readonly class Money +{ + public function __construct( + public int $minor, + public Currency $currency, + ) {} +} diff --git a/packages/openapi/tests/ResponseFixture/Shipment.php b/packages/openapi/tests/ResponseFixture/Shipment.php new file mode 100644 index 0000000..a9f3af2 --- /dev/null +++ b/packages/openapi/tests/ResponseFixture/Shipment.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\OpenApi\Tests\ResponseFixture; + +use DateTimeImmutable; + +/** Where a consignment is, and when it is expected. */ +final readonly class Shipment +{ + /** + * @param list<string> $checkpoints where it has been scanned, oldest first + */ + public function __construct( + public string $carrier, + public ?string $tracking, + public array $checkpoints, + public ?DateTimeImmutable $expectedAt, + ) {} +} diff --git a/packages/openapi/tests/Schema/DocTypeTest.php b/packages/openapi/tests/Schema/DocTypeTest.php new file mode 100644 index 0000000..302c6c1 --- /dev/null +++ b/packages/openapi/tests/Schema/DocTypeTest.php @@ -0,0 +1,130 @@ +<?php + +declare(strict_types=1); + +use Firefly\OpenApi\Schema\DocType; +use Firefly\OpenApi\Tests\ResponseFixture\Consignment; +use Firefly\OpenApi\Tests\ResponseFixture\ConsignmentController; +use Firefly\OpenApi\Tests\ResponseFixture\Money; + +/** + * The PHPDoc type-expression parser, exercised directly rather than only through a generated document. + * + * The grammar is small but it is a grammar — balanced `<>` and `{}`, a comma that separates only at the + * outer level, `?` meaning two different things depending on which side of a shape key it sits — and the + * generator's output is downstream of every one of those decisions. A table here says what each spelling + * means in one place, where a wrong answer reads as a wrong answer instead of as a puzzling schema. + */ +$ref = static fn (string $class): array => ['$ref' => '#/components/schemas/'.(str_contains($class, '\\') ? substr((string) strrchr($class, '\\'), 1) : $class)]; + +it('compiles scalars, unions and nullability', function () use ($ref) { + expect(DocType::schema('int', $ref))->toBe(['type' => 'integer']) + ->and(DocType::schema('?int', $ref))->toBe(['type' => ['integer', 'null']]) + ->and(DocType::schema('int|null', $ref))->toBe(['type' => ['integer', 'null']]) + ->and(DocType::schema('string|int', $ref))->toBe(['type' => ['string', 'integer']]) + ->and(DocType::schema('bool', $ref))->toBe(['type' => 'boolean']) + // `mixed` is a real answer — JSON Schema's "any value" — and is the empty schema, not null. + ->and(DocType::schema('mixed', $ref))->toBe([]); +}); + +it('keeps the extra information PHPStan pseudo-types carry', function () use ($ref) { + expect(DocType::schema('non-empty-string', $ref))->toBe(['type' => 'string', 'minLength' => 1]) + ->and(DocType::schema('positive-int', $ref))->toBe(['type' => 'integer', 'minimum' => 1]) + ->and(DocType::schema('negative-int', $ref))->toBe(['type' => 'integer', 'maximum' => -1]) + ->and(DocType::schema('array-key', $ref))->toBe(['type' => ['string', 'integer']]); +}); + +it('collapses a union of literals into an enum', function () use ($ref) { + expect(DocType::schema("'draft'|'sent'|'paid'", $ref)) + ->toBe(['type' => 'string', 'enum' => ['draft', 'sent', 'paid']]) + ->and(DocType::schema('1|2|3', $ref)) + ->toBe(['type' => 'integer', 'enum' => [1, 2, 3]]); +}); + +it('tells a list from a map, which a bare PHP array cannot', function () use ($ref) { + // The decisive case. `array<int, T>` is a JSON array and `array<string, T>` is a JSON object; publishing + // the second as an array is not vague but WRONG, and a generated client fails to decode the real payload. + expect(DocType::schema('list<int>', $ref))->toBe(['type' => 'array', 'items' => ['type' => 'integer']]) + ->and(DocType::schema('array<int, int>', $ref))->toBe(['type' => 'array', 'items' => ['type' => 'integer']]) + ->and(DocType::schema('array<string, int>', $ref))->toBe(['type' => 'object', 'additionalProperties' => ['type' => 'integer']]) + ->and(DocType::schema('list<list<int>>', $ref))->toBe([ + 'type' => 'array', + 'items' => ['type' => 'array', 'items' => ['type' => 'integer']], + ]) + ->and(DocType::schema('non-empty-list<int>', $ref)) + ->toBe(['type' => 'array', 'items' => ['type' => 'integer'], 'minItems' => 1]); +}); + +it('reads an array shape, including which members are optional', function () use ($ref) { + // A `?` on the KEY is "may be absent" and a `?` on the VALUE is "may be null". Conflating them documents + // an omissible member as one a client must always send. + expect(DocType::schema('array{a: int, b?: string, c: ?int}', $ref))->toBe([ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'integer'], + 'b' => ['type' => 'string'], + 'c' => ['type' => ['integer', 'null']], + ], + 'required' => ['a', 'c'], + 'additionalProperties' => false, + ]); + + // A trailing `...` is the author saying the shape is open, and is the only thing that lifts + // `additionalProperties: false`. + expect(DocType::schema('array{a: int, ...}', $ref) ?? [])->not->toHaveKey('additionalProperties'); +}); + +it('reads a tuple shape as a positional array', function () use ($ref) { + expect(DocType::schema('array{int, string}', $ref))->toBe([ + 'type' => 'array', + 'prefixItems' => [['type' => 'integer'], ['type' => 'string']], + 'minItems' => 2, + 'maxItems' => 2, + ]); +}); + +it('resolves a class through the imports of the file the expression was written in', function () use ($ref) { + // `Money` is only a name; it means something because ConsignmentController imports it. Reflection does + // not expose a file's `use` statements, so this is read from the source — and without it only a + // fully-qualified name would resolve, which is the one spelling nobody writes. + /** @var ReflectionClass<object> $context */ + $context = new ReflectionClass(ConsignmentController::class); + + expect(DocType::schema('Money', $ref, $context))->toBe(['$ref' => '#/components/schemas/Money']) + ->and(DocType::schema('list<Consignment>', $ref, $context)) + ->toBe(['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Consignment']]) + ->and(DocType::schema('\\'.Money::class, $ref, $context))->toBe(['$ref' => '#/components/schemas/Money']) + // A nullable reference cannot be widened in place: sibling validation keywords are applied WITH a + // `$ref` in 2020-12, so a `type: null` beside it would have to hold as well and never could. + ->and(DocType::schema('?Consignment', $ref, $context)) + ->toBe(['anyOf' => [['$ref' => '#/components/schemas/Consignment'], ['type' => 'null']]]); +}); + +it('says nothing rather than guessing when it cannot read the expression', function () use ($ref) { + // Null is the signal for a caller to fall back to what it knew before it asked. It is a different answer + // from the empty schema, which is a real "any value". + expect(DocType::schema('NoSuchClassAnywhere', $ref))->toBeNull() + ->and(DocType::schema('callable(int): string', $ref))->toBeNull() + ->and(DocType::schema('array{unterminated: int', $ref))->toBeNull() + ->and(DocType::schema('never', $ref))->toBeNull() + // A list whose element does not resolve is still a list — the container is known even when the + // contents are not. + ->and(DocType::schema('list<NoSuchClass>', $ref))->toBe(['type' => 'array']); +}); + +it('splits a tag line into its type expression and the prose after it', function () use ($ref) { + // Splitting on whitespace cannot work: a type expression contains spaces of its own. Whatever the + // grammar consumed is the type; the rest is English. + [$schema, $prose] = DocType::split('array{page: int, size: int} one page of results', $ref); + + /** @var array{properties: array<string, mixed>} $schema */ + expect($prose)->toBe('one page of results') + ->and($schema['properties'])->toHaveKeys(['page', 'size']); + + /** @var ReflectionClass<object> $consignment */ + $consignment = new ReflectionClass(Consignment::class); + [$schema, $prose] = DocType::split('Consignment', $ref, $consignment); + + expect($prose)->toBe('') + ->and($schema)->toBe(['$ref' => '#/components/schemas/Consignment']); +}); diff --git a/skeleton/app/Orders/Address.php b/skeleton/app/Orders/Address.php index bd71025..e733aba 100644 --- a/skeleton/app/Orders/Address.php +++ b/skeleton/app/Orders/Address.php @@ -5,16 +5,20 @@ namespace App\Orders; /** - * A postal address, as the domain understands it. - * - * It is deliberately a DIFFERENT class from App\Http\AddressPayload, which is the same four fields as they - * arrive over HTTP. The duplication is the point: the payload carries validation attributes and is shaped by - * the wire format, this one is shaped by the domain and is free to change without breaking a published API. - * App\Http\OrderController owns the translation between them, which is why nothing under App\Orders imports - * anything from App\Http — a dependency direction worth keeping as the application grows. + * A postal address: where an order ships. */ final readonly class Address { + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED as this component's `description` in /openapi.json; this comment + // is not. See App\Orders\Order for the rule. + // + // It is deliberately a DIFFERENT class from App\Http\AddressPayload, which is the same four fields as + // they arrive over HTTP. The duplication is the point: the payload carries validation attributes and is + // shaped by the wire format, this one is shaped by the domain and is free to change without breaking a + // published API. App\Http\OrderController owns the translation between them, which is why nothing under + // App\Orders imports anything from App\Http — a dependency direction worth keeping as the application + // grows. + public function __construct( public string $street, public string $city, diff --git a/skeleton/app/Orders/Order.php b/skeleton/app/Orders/Order.php index f642cfb..dfc5816 100644 --- a/skeleton/app/Orders/Order.php +++ b/skeleton/app/Orders/Order.php @@ -7,21 +7,34 @@ use JsonSerializable; /** - * An order: who placed it, where it ships, and what is on it. + * An order: who placed it, where it ships, what is on it, and what it comes to. * - * IMPLEMENTS JsonSerializable ON PURPOSE. A controller action may return any value; the ResponseFactory - * hands it to the negotiated MessageConverter, and the JSON converter honours JsonSerializable natively. So - * the wire shape of an order is declared ONCE, here, next to the data — every action that returns an order - * gets the same representation, and adding a field cannot leave one endpoint out of step with another. Note - * that `total` is part of that shape even though it is a method: json_encode() only sees public properties, - * so a derived value has to be published deliberately. - * - * The id is nullable because an Order exists before it is stored — `OrderController::store()` builds one - * with no id, and the id is assigned by the database when OrderService writes the row. Modelling "not yet - * persisted" as a null id rather than as a second class keeps one type in play across the whole slice. + * `total` is derived from the lines rather than stored beside them, and is never accepted from a client. */ final readonly class Order implements JsonSerializable { + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED, THIS COMMENT IS NOT. firefly/openapi uses a class docblock as + // the `description` of the component schema it generates, so what is written up there is read by whoever + // consumes this API. Notes about how the framework treats this class belong here, where they are + // invisible to the generator. + // + // IMPLEMENTS JsonSerializable ON PURPOSE. A controller action may return any value; the ResponseFactory + // hands it to the negotiated MessageConverter, and the JSON converter honours JsonSerializable natively. + // So the wire shape of an order is declared ONCE, below, next to the data — every action that returns an + // order gets the same representation, and adding a field cannot leave one endpoint out of step with + // another. + // + // WHICH IS ALSO WHY jsonSerialize() CARRIES AN `@return array{...}`. firefly/openapi documents a returned + // class from its wire shape, and for a JsonSerializable class the wire shape is what that method returns + // — which here is NOT the property list, because `total` is a derived method. Reflection alone would + // publish five of the six members that actually appear in the response. The array shape states all six, + // PHPStan checks it against the method on every build, and the generator reads it. Delete it and `total` + // silently disappears from /openapi.json while the API keeps sending it. + // + // The id is nullable because an Order exists before it is stored — `OrderController::store()` builds one + // with no id, and the id is assigned by the database when OrderService writes the row. Modelling "not yet + // persisted" as a null id rather than as a second class keeps one type in play across the whole slice. + /** * @param list<OrderLine> $lines */ @@ -39,7 +52,7 @@ public function total(): float return round(array_sum(array_map(static fn (OrderLine $line): float => $line->subtotal(), $this->lines)), 2); } - /** @return array<string, mixed> */ + /** @return array{id: int|null, customer: string, email: string, shipTo: Address, lines: list<OrderLine>, total: float} */ public function jsonSerialize(): array { return [ diff --git a/skeleton/app/Orders/OrderLine.php b/skeleton/app/Orders/OrderLine.php index 100c279..6d07f2e 100644 --- a/skeleton/app/Orders/OrderLine.php +++ b/skeleton/app/Orders/OrderLine.php @@ -6,13 +6,18 @@ /** * One line of an order: what was bought, how many, and at what unit price. - * - * `subtotal()` is here rather than in the controller or the service because it is a fact about a line, not - * about a request or a use case — the small habit that keeps a domain model from decaying into a bag of - * public properties with all the behaviour somewhere else. */ final readonly class OrderLine { + // NOTE — THE DOCBLOCK ABOVE IS PUBLISHED as this component's `description` in /openapi.json; this comment + // is not. See App\Orders\Order for the rule. + // + // `subtotal()` is here rather than in the controller or the service because it is a fact about a line, + // not about a request or a use case — the small habit that keeps a domain model from decaying into a bag + // of public properties with all the behaviour somewhere else. It is deliberately NOT part of the wire + // shape: this class is not JsonSerializable, so json_encode() emits its public properties and a client + // computes its own line totals from them. + public function __construct( public string $sku, public int $quantity, From df37e4030b5e0036519f6ca6b389dfd4c181ba2d Mon Sep 17 00:00:00 2001 From: Andres Contreras <andres.contreras@soon.es> Date: Thu, 3 Sep 2026 18:55:49 -0700 Subject: [PATCH 21/31] feat(web): a LaraFly error page for browsers, in the framework's own design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO BUGS, ONE CAUSE. The renderable read `$e instanceof FireflyException || $request->expectsJson()`, so EVERY FireflyException rendered as problem+json regardless of who asked — a person clicking a stale link to /orders/999999 in a browser was shown a raw JSON blob. The framework's exception taxonomy, the thing that makes its errors consistent for clients, was what made them unreadable for people. Meanwhile a URL matching no route at all throws Symfony's HttpException, missed that branch entirely, and fell through to Laravel's stock page — so one application produced two unrelated-looking 404s depending on which kind of 404 it was. NEGOTIATION IS EXPLICIT, because the obvious rule is wrong `expectsJson()`'s negation is not "wants HTML": a bare `curl` sends a wildcard Accept, which `acceptsHtml()` answers true for, so keying off it would have turned every unadorned command-line request against an API into an HTML page — a worse regression than the bug. The page is served only when the client NAMED text/html or application/xhtml+xml, which every browser does and no API client does by accident. An XMLHttpRequest is excluded even when it does, because its caller is JavaScript reading a body. Verified across all five: browser to page, Accept: application/json to problem+json, bare curl to problem+json, XHR to problem+json, unrouted URL to page with its REAL 404 rather than a 500. THE DISCLOSURE RULE IS ENFORCED WHERE THE DATA IS GATHERED, not where it is printed With `trace` off, ErrorReport never walks the stack, never opens a source file and never copies the exception message — so there is nothing assembled for a template mistake to leak. Production shows the status, the reason and the stable error code: enough to quote into a ticket and grep in a log, and nothing that names a class, a file or a row. The footer's own advice about turning traces on is gated on environment too, because naming the framework and a config key to an anonymous visitor is a free hint. Asserted against the REPORT as well as the HTML, which is the property worth pinning rather than the rendering that happens to follow from it. WHY IT IS NOT A BLADE VIEW. This page renders when the application is already failing, and a view is the one thing that cannot be relied on then — the failure may BE a view, or a container too half-built to resolve a factory. So it has no dependencies at all: no container lookups, no view layer, no network font. Every source read is guarded, size-capped and falls back to an empty excerpt, because an error page that throws while explaining a throw is the worst possible outcome. THE TRACE IS THE POINT, so it is the signature. A raw PHP trace is 52 frames of which 13 are yours; here the application's frames carry the accent rail and open with their source excerpt and a highlighted throwing line, and the 39 vendor frames collapse to one dim row each. Done with <details> and CSS — no JavaScript. PHP's getTrace() starts at the CALLER of the throwing frame, so the throw site appears nowhere in it and is prepended; the frame a reader wants first was the one frame missing. ProblemMapper is now shared by both renderers. Two copies of "what does this throwable become" would eventually disagree about the status of an HttpException, and the symptom would be a browser and a client being told different things about one failure. Measured at seven widths from 320px to 1920px (no overflow at any) and every text pair computed against both themes: the lowest is now 4.71:1 on the 64px status numeral, where the threshold for large text is 3.0. Three were below before — the uppercase fact labels, the source gutter, and the exception class, which was drawn in the brand orange: #e07a17 is 3.01:1 as a foreground on white, fine for a 9px dot and unreadable as text, so shapes and text now take different oranges. 2025 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- packages/web/src/Error/ErrorFrame.php | 33 ++ packages/web/src/Error/ErrorPage.php | 315 ++++++++++++++++++ packages/web/src/Error/ErrorPageRenderer.php | 74 ++++ packages/web/src/Error/ErrorPageSettings.php | 59 ++++ packages/web/src/Error/ErrorReport.php | 195 +++++++++++ packages/web/src/Error/ProblemMapper.php | 69 ++++ .../src/Exception/ProblemDetailsRenderer.php | 56 +--- packages/web/src/WebServiceProvider.php | 34 ++ packages/web/tests/Error/ErrorPageTest.php | 152 +++++++++ skeleton/config/firefly.php | 47 +++ 10 files changed, 986 insertions(+), 48 deletions(-) create mode 100644 packages/web/src/Error/ErrorFrame.php create mode 100644 packages/web/src/Error/ErrorPage.php create mode 100644 packages/web/src/Error/ErrorPageRenderer.php create mode 100644 packages/web/src/Error/ErrorPageSettings.php create mode 100644 packages/web/src/Error/ErrorReport.php create mode 100644 packages/web/src/Error/ProblemMapper.php create mode 100644 packages/web/tests/Error/ErrorPageTest.php diff --git a/packages/web/src/Error/ErrorFrame.php b/packages/web/src/Error/ErrorFrame.php new file mode 100644 index 0000000..236706c --- /dev/null +++ b/packages/web/src/Error/ErrorFrame.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Web\Error; + +/** + * One stack frame, with the two things that make a trace readable: whether it is YOURS, and what the code + * around it says. + * + * A raw PHP trace is forty frames of which perhaps four are the application's, and the rest are the + * framework walking its own dispatch. Marking the application's frames is what turns scrolling into + * reading, and it is decided by path — a frame under `vendor/` belongs to a dependency — which is crude, + * correct, and needs nothing installed. + * + * `$excerpt` is only ever populated for an application frame. Reading source for forty vendor frames would + * open forty files to render code nobody is going to look at, and the page has to stay cheap: it renders + * when things are already going wrong. + */ +final readonly class ErrorFrame +{ + /** + * @param array<int, string> $excerpt line number => source text, empty for a vendor frame + */ + public function __construct( + public string $file, + public string $shortFile, + public ?int $line, + public string $call, + public bool $vendor, + public array $excerpt = [], + ) {} +} diff --git a/packages/web/src/Error/ErrorPage.php b/packages/web/src/Error/ErrorPage.php new file mode 100644 index 0000000..4735a98 --- /dev/null +++ b/packages/web/src/Error/ErrorPage.php @@ -0,0 +1,315 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Web\Error; + +/** + * The HTML error page, built as a string. + * + * WHY NOT BLADE. This page renders when the application is already failing, and a Blade view is the one + * thing that cannot be relied on then: the failure may BE a view — a compile error, a missing path, a view + * factory that never got bound because the container is half-built — and rendering a second view to explain + * the first produces a white screen and no clue. So the page has no dependencies at all: no container + * lookups, no view factory, no filesystem read of its own, and no CSS or font from a network the box may not + * have. String concatenation is not the elegant choice; it is the one that still works when nothing else + * does. + * + * THE DESIGN IS THE FRAMEWORK'S, not a fourth one. The palette, the type stack and the radii are the admin + * dashboard's — this is a diagnostic surface and it should read as one — while the composition is the + * welcome page's: centred, generous, a single column. A person meets this page in the same session in which + * they meet those two, and a third visual language would just be noise. + * + * THE SIGNATURE IS THE TRACE, because that is what the page is FOR. A raw PHP trace is forty frames of which + * four are yours; here the application's frames carry the accent rail and open by default with their source + * excerpt, and the vendor frames collapse to one dim line each. That distinction is the entire difference + * between scrolling a trace and reading one, and it is drawn with `<details>` and CSS — no JavaScript, so it + * works with scripts disabled and in whatever a container's minimal browser turns out to be. + */ +final class ErrorPage +{ + public static function render(ErrorReport $report, ErrorPageSettings $settings): string + { + $title = self::e($report->status.' '.$report->reason.' · '.$settings->title); + + return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">' + .'<meta name="viewport" content="width=device-width, initial-scale=1">' + .'<title>'.$title.'' + .self::favicon() + .'' + .'
' + .self::header($report, $settings) + .self::facts($report) + .self::detail($report) + .self::footer($report, $settings) + .'
'; + } + + private static function header(ErrorReport $report, ErrorPageSettings $settings): string + { + $tone = match (true) { + $report->status >= 500 => 'down', + $report->status >= 400 => 'warn', + default => 'idle', + }; + + $html = '
' + .''.self::e($settings->title).'' + .'

'.self::e((string) $report->status).''.self::e($report->reason).'

'; + + // The stable error code is the one thing worth carrying off this page — into a support ticket, into + // a log search — so it is the largest thing under the status rather than a detail in a table. + $html .= '

'.self::e($report->code).'

'; + + if ($report->detailed && $report->message !== '') { + $html .= '

'.self::e($report->message).'

'; + } elseif (! $report->detailed) { + $html .= '

'.self::e(self::reassurance($report->status)).'

'; + } + + return $html.'
'; + } + + private static function facts(ErrorReport $report): string + { + $rows = [ + 'Request' => $report->method.' '.$report->path, + 'Code' => $report->code, + 'Category' => $report->category, + 'Severity' => $report->severity, + 'When' => $report->timestamp, + ]; + + if ($report->detailed) { + $rows['Exception'] = $report->exceptionClass; + $rows['Thrown at'] = $report->location; + } + + $html = '
'; + foreach ($rows as $label => $value) { + if ($value === '') { + continue; + } + $html .= '
'.self::e($label).'
'.self::e($value).'
'; + } + + return $html.'
'; + } + + private static function detail(ErrorReport $report): string + { + if (! $report->detailed) { + return ''; + } + + return self::previous($report).self::frames($report); + } + + private static function previous(ErrorReport $report): string + { + if ($report->previous === []) { + return ''; + } + + // The outermost message is usually the least specific one — firefly/web wraps a binding failure, the + // container wraps a constructor throw — so the chain is shown in full and near the top rather than + // buried under forty frames. + $html = '

Caused by

    '; + foreach ($report->previous as $link) { + $html .= '
  1. '.self::e($link['class']).'

    ' + .'

    '.self::e($link['message']).'

    ' + .'

    '.self::e($link['location']).'

  2. '; + } + + return $html.'
'; + } + + private static function frames(ErrorReport $report): string + { + if ($report->frames === []) { + return ''; + } + + $app = 0; + foreach ($report->frames as $frame) { + if (! $frame->vendor) { + $app++; + } + } + + $html = '

Stack trace ' + .self::e((string) $app).' of '.self::e((string) count($report->frames)).' in your code

    '; + + $opened = 0; + foreach ($report->frames as $frame) { + $html .= self::frame($frame, $opened); + } + + return $html.'
'; + } + + private static function frame(ErrorFrame $frame, int &$opened): string + { + $where = $frame->shortFile.($frame->line === null ? '' : ':'.$frame->line); + $summary = ''.self::e($where).'' + .''.self::e($frame->call).''; + + if ($frame->excerpt === []) { + // No body to expand into, so it renders as a plain row rather than as a control that does + // nothing when clicked. + return '
  • ' + .''.self::e($where).'' + .''.self::e($frame->call).'
  • '; + } + + // The first two frames with source are opened; past that the page becomes a wall of code and the + // reader loses the shape of the stack. + $open = $opened < 2 ? ' open' : ''; + $opened++; + + return '
  • '.$summary.self::excerpt($frame).'
  • '; + } + + private static function excerpt(ErrorFrame $frame): string + { + $html = '
    BeanDepends onWired by
    BeanDepends onWired by
    {{ Firefly\Admin\Format::shortClass($edge['from']) }}{{ rtrim(Firefly\Admin\Format::namespaceOf($edge['from']), '\\') }}{{ Firefly\Admin\Format::shortClass($edge['to']) }}{{ rtrim(Firefly\Admin\Format::namespaceOf($edge['to']), '\\') }}{{ $edge['via'] !== null ? Firefly\Admin\Format::shortClass($edge['via']) : 'class' }}{{ Format::shortClass($edge['from']) }}{{ rtrim(Format::namespaceOf($edge['from']), '\\') }}{{ $edge['type'] === 'produces' ? 'produces' : '→' }}{{ Format::shortClass($edge['to']) }}{{ rtrim(Format::namespaceOf($edge['to']), '\\') }}{{ $edge['via'] !== null ? Format::shortClass($edge['via']) : '—' }}
    {{ $indicator['name'] }} {{ $indicator['status'] }} + @if ($indicator['details'] === []) — @else @@ -83,7 +83,7 @@ @foreach ($info as $key => $value)
    {{ $key }}{{ $value }}{{ $value }}
    {{ $metric['name'] }}{{ $metric['rows'][0]['display'] ?? '—' }}{{ $metric['rows'][0]['display'] ?? '—' }}
    '; + foreach ($frame->excerpt as $number => $text) { + $hit = $number === $frame->line ? ' class="hit"' : ''; + $html .= ''; + } + + return $html.'
    '.self::e((string) $number).''.self::e($text).'
    '; + } + + /** + * The footer explains the page itself, and only where that is a safe thing to explain: on a + * non-production environment. In production it says nothing — naming the framework and a config key to + * an anonymous visitor is a free hint about the stack, and the error code above is the only thing that + * page's reader actually needs. + */ + private static function footer(ErrorReport $report, ErrorPageSettings $settings): string + { + if (! $settings->hints) { + return ''; + } + + $note = $report->detailed + ? 'Details are shown because firefly.web.error-page.trace is on (it follows app.debug). Turn either off and this page shows only the status and the code.' + : 'Set APP_DEBUG=true, or firefly.web.error-page.trace, to see the exception and its stack trace here.'; + + return '

    '.$note.'

    ' + .'

    The same failure is served as application/problem+json to a client that asks for JSON.

    '; + } + + /** A short, honest sentence for a production page — no message, no internals. */ + private static function reassurance(int $status): string + { + return match (true) { + $status === 404 => 'That page does not exist.', + $status === 403 => 'You do not have access to that.', + $status === 401 => 'You need to sign in to see that.', + $status === 405 => 'That address does not accept this kind of request.', + $status >= 500 => 'Something went wrong on our side. The error has been logged.', + default => 'That request could not be completed.', + }; + } + + private static function favicon(): string + { + return ''; + } + + private static function e(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private static function css(): string + { + return <<<'CSS' +:root{ + color-scheme:light; + --bg:#f7f6f3; --panel:#fff; --panel-2:#faf9f6; --line:#e7e3db; --line-2:#d6d0c4; + --ink:#20242a; --ink-2:#5f6672; --ink-3:#8d95a1; + --brand:#e07a17; + /* The brand as TEXT. #e07a17 is a 3.01:1 foreground on white — fine for a 9px dot or a 3px rail, and + unreadable for the exception class it was being used on. Shapes and text need different oranges. */ + --brand-ink:#a1520a; + --down:#c02717; --down-bg:#fbe9e7; --warn:#9a6206; --warn-bg:#fdf1dd; + --idle:#6b7280; --idle-bg:#f0f0f2; + --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; + --sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; + --r:12px; +} +@media (prefers-color-scheme: dark){ + :root{ + color-scheme:dark; + --bg:#0f1214; --panel:#15191c; --panel-2:#181d21; --line:#252c32; --line-2:#333c44; + --ink:#e8ecef; --ink-2:#9aa5af; --ink-3:#6c7883; + --brand:#ff9d3c; + --brand-ink:#ff9d3c; + --down:#ff8a7a; --down-bg:#2a1614; --warn:#ffc266; --warn-bg:#2a2114; + --idle:#9aa5af; --idle-bg:#1c2226; + } +} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 var(--sans);-webkit-font-smoothing:antialiased} +code{font-family:var(--mono);font-size:.92em;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:1px 5px} +.sheet{max-width:960px;margin:0 auto;padding:56px 20px 72px;display:flex;flex-direction:column;gap:22px;min-width:0} +.head{display:flex;flex-direction:column;gap:10px} +.mark{display:inline-flex;align-items:center;gap:9px;font-weight:650;letter-spacing:-.01em;color:var(--ink-2);margin-bottom:14px} +.dot{width:9px;height:9px;border-radius:50%;background:var(--brand);flex:none;box-shadow:0 0 0 3px color-mix(in srgb, var(--brand) 18%, transparent)} +.status{display:flex;align-items:baseline;gap:12px;margin:0;flex-wrap:wrap} +.status b{font-size:64px;line-height:1;letter-spacing:-.04em;font-variant-numeric:tabular-nums} +.status span{font-size:19px;font-weight:600;color:var(--ink-2)} +.status.down b{color:var(--down)} .status.warn b{color:var(--warn)} .status.idle b{color:var(--idle)} +.code{margin:0;font-family:var(--mono);font-size:13px;letter-spacing:.04em;color:var(--ink-2)} +.message{margin:6px 0 0;font-size:16px;line-height:1.5;color:var(--ink);overflow-wrap:anywhere} +.muted{color:var(--ink-2)} +.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:1px;margin:0;background:var(--line);border:1px solid var(--line);border-radius:var(--r);overflow:hidden} +.facts>div{background:var(--panel);padding:11px 14px;min-width:0} +.facts dt{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--ink-2);margin:0 0 3px} +.facts dd{margin:0;font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere} +.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;min-width:0} +.panel h2{margin:0;padding:12px 16px;font-size:13px;font-weight:650;border-bottom:1px solid var(--line);background:var(--panel-2);display:flex;justify-content:space-between;gap:12px;align-items:baseline} +.panel h2 .n{font-weight:400;font-size:11.5px;color:var(--ink-3);font-family:var(--mono)} +.chain{list-style:none;margin:0;padding:0} +.chain li{padding:12px 16px;border-bottom:1px solid var(--line)} +.chain li:last-child{border-bottom:0} +.chain .cls{margin:0;font-family:var(--mono);font-size:12.5px;color:var(--brand-ink);overflow-wrap:anywhere} +.chain .msg{margin:3px 0 0;overflow-wrap:anywhere} +.chain .loc{margin:3px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-3);overflow-wrap:anywhere} +.frames{list-style:none;margin:0;padding:0;counter-reset:f} +.frames li{border-bottom:1px solid var(--line)} +.frames li:last-child{border-bottom:0} +.frames .row,.frames summary{display:flex;gap:14px;align-items:baseline;padding:8px 16px;min-width:0;flex-wrap:wrap} +.frames summary{cursor:pointer;list-style:none} +.frames summary::-webkit-details-marker{display:none} +.frames summary::before{content:"▸";color:var(--ink-3);font-size:10px;margin-right:-6px} +.frames details[open] summary::before{content:"▾"} +.frames .where{font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere} +.frames .call{font-family:var(--mono);font-size:12px;color:var(--ink-3);overflow-wrap:anywhere;margin-left:auto} +/* The application's own frames are the point of the page; the vendor ones are context. */ +.frames li.own{border-left:3px solid var(--brand);background:var(--panel)} +.frames li.own .where{color:var(--ink);font-weight:600} +.frames li.vendor{border-left:3px solid transparent;background:var(--panel-2)} +/* De-emphasised by weight, ground and the missing rail — NOT by fading the text below readable contrast. + A vendor frame's path is still the thing a reader came for once they have ruled their own code out. */ +.frames li.vendor .where{color:var(--ink-2);font-weight:400} +.src{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:12.5px;background:var(--panel-2);border-top:1px solid var(--line);display:block;overflow-x:auto} +.src tr{display:table;width:100%;table-layout:fixed} +.src td{padding:2px 10px;white-space:pre;vertical-align:top} +.src .ln{width:56px;text-align:right;color:var(--ink-2);user-select:none;border-right:1px solid var(--line)} +.src .ln-src{overflow-wrap:normal} +.src tr.hit{background:color-mix(in srgb, var(--brand) 14%, transparent)} +.src tr.hit .ln{color:var(--brand-ink);font-weight:700} +.foot{color:var(--ink-2);font-size:12.5px;display:flex;flex-direction:column;gap:5px} +.foot p{margin:0} +@media (max-width:560px){ + .sheet{padding:32px 14px 48px} + .status b{font-size:48px} + .frames .call{margin-left:0;width:100%} +} +CSS; + } +} diff --git a/packages/web/src/Error/ErrorPageRenderer.php b/packages/web/src/Error/ErrorPageRenderer.php new file mode 100644 index 0000000..9fedd73 --- /dev/null +++ b/packages/web/src/Error/ErrorPageRenderer.php @@ -0,0 +1,74 @@ +expectsJson()`, so + * ANY FireflyException rendered as problem+json regardless of who asked — which meant a person clicking a + * stale link to /orders/999999 in a browser was shown a raw JSON blob. The framework's own exception + * taxonomy, the thing that makes its errors consistent for clients, was what made them unreadable for + * people. Meanwhile a URL matching no route at all threw a Symfony HttpException, missed that branch, and + * fell through to Laravel's stock error page — so one application produced two unrelated-looking 404s + * depending on which kind of 404 it was. + * + * WHY NOT `$request->expectsJson()` FOR THE DECISION. Its negation is not "wants HTML". A bare `curl` sends + * a WILDCARD Accept header, which `acceptsHtml()` answers true for, so keying off it would have turned every + * unadorned command-line request against an API into an HTML page — a worse regression than the bug. The + * rule is therefore explicit: the page is served only when the client NAMED `text/html` (or + * `application/xhtml+xml`) in its Accept header, which every browser does and no API client does by + * accident. A wildcard alone is not an opinion, and is answered with the machine-readable form. + * + * An XMLHttpRequest is excluded even when it names text/html, because its caller is JavaScript that is going + * to read a body, not a person who is going to read a page. + */ +final class ErrorPageRenderer +{ + public function __construct( + private readonly ErrorPageSettings $settings, + private readonly string $basePath = '', + ) {} + + /** Whether this request should be answered with the HTML page rather than with problem+json. */ + public function handles(Request $request): bool + { + if (! $this->settings->enabled || $request->ajax() || $request->wantsJson()) { + return false; + } + + $accept = (string) $request->headers->get('Accept', ''); + + return str_contains($accept, 'text/html') || str_contains($accept, 'application/xhtml+xml'); + } + + public function render(Throwable $e, Request $request): Response + { + $exception = ProblemMapper::toFireflyException($e); + $status = $exception->httpStatus(); + + $report = ErrorReport::of( + $e, + $request, + $this->settings, + $this->basePath, + $status, + ProblemMapper::statusText($status), + (new DateTimeImmutable)->format(DateTimeInterface::ATOM), + ); + + return new Response( + ErrorPage::render($report, $this->settings), + $status, + ['Content-Type' => 'text/html; charset=UTF-8'], + ); + } +} diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php new file mode 100644 index 0000000..2a5d4f0 --- /dev/null +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -0,0 +1,59 @@ +bool('firefly.web.error-page.enabled', true), + // The debug flag is the framework-wide statement of "this is a place where internals may be + // shown". Following it means an application already configured correctly needs no new key, and + // one that sets this key explicitly wins in both directions. + trace: $config->bool('firefly.web.error-page.trace', $config->bool('app.debug', false)), + title: $config->string('firefly.web.error-page.title', $config->string('app.name', 'LaraFly')), + // Clamped rather than trusted: this is a radius around the throwing line, and a huge one turns + // an error page into a source-code dump of the whole file. + excerptLines: max(0, min(40, $config->int('firefly.web.error-page.excerpt-lines', 7))), + // Whether the page may explain ITSELF — "set APP_DEBUG to see the trace". That sentence is + // guidance for a developer on a box with debug off, and an unnecessary disclosure on a public + // one: it names the framework and a config key to an anonymous visitor who asked for a page. + // Environment is the right gate rather than `trace`, because a staging box legitimately runs + // with debug off and is not the public internet. + hints: $config->string('app.env', 'production') !== 'production', + ); + } +} diff --git a/packages/web/src/Error/ErrorReport.php b/packages/web/src/Error/ErrorReport.php new file mode 100644 index 0000000..01c9938 --- /dev/null +++ b/packages/web/src/Error/ErrorReport.php @@ -0,0 +1,195 @@ + $frames + * @param list $previous + */ + private function __construct( + public int $status, + public string $reason, + public string $code, + public string $category, + public string $severity, + public string $method, + public string $path, + public string $timestamp, + public bool $detailed, + public string $exceptionClass = '', + public string $message = '', + public string $location = '', + public array $frames = [], + public array $previous = [], + ) {} + + public static function of(Throwable $e, Request $request, ErrorPageSettings $settings, string $basePath, int $status, string $reason, string $timestamp): self + { + $payload = ErrorResponse::fromException(ProblemMapper::toFireflyException($e), instance: $request->path(), timestamp: $timestamp)->toArray(); + + $public = new self( + status: $status, + reason: $reason, + code: is_string($payload['code'] ?? null) ? $payload['code'] : 'INTERNAL_ERROR', + category: is_string($payload['category'] ?? null) ? $payload['category'] : '', + severity: is_string($payload['severity'] ?? null) ? $payload['severity'] : '', + method: $request->getMethod(), + path: '/'.ltrim($request->path(), '/'), + timestamp: $timestamp, + detailed: false, + ); + + if (! $settings->trace) { + return $public; + } + + return new self( + status: $public->status, + reason: $public->reason, + code: $public->code, + category: $public->category, + severity: $public->severity, + method: $public->method, + path: $public->path, + timestamp: $public->timestamp, + detailed: true, + exceptionClass: $e::class, + message: $e->getMessage(), + location: self::shorten($e->getFile(), $basePath).':'.$e->getLine(), + frames: self::frames($e, $basePath, $settings->excerptLines), + previous: self::previous($e, $basePath), + ); + } + + /** + * The throw site first, then the call stack — which is the order a reader wants and the opposite of the + * order `getTrace()` returns it in relative to `getFile()`. PHP's trace starts at the CALLER of the + * throwing frame, so the throwing line itself appears nowhere in it and has to be prepended. + * + * @return list + */ + private static function frames(Throwable $e, string $basePath, int $excerptLines): array + { + $frames = [self::frame($e->getFile(), $e->getLine(), 'throw', $basePath, $excerptLines)]; + + foreach ($e->getTrace() as $entry) { + $file = is_string($entry['file'] ?? null) ? $entry['file'] : ''; + $line = is_int($entry['line'] ?? null) ? $entry['line'] : null; + + $class = $entry['class'] ?? ''; + $type = $entry['type'] ?? ''; + $function = $entry['function']; + + $frames[] = self::frame($file, $line, $class.$type.$function.'()', $basePath, $excerptLines); + } + + return $frames; + } + + private static function frame(string $file, ?int $line, string $call, string $basePath, int $excerptLines): ErrorFrame + { + $vendor = $file === '' || str_contains($file, '/vendor/') || str_contains($file, '\\vendor\\'); + + return new ErrorFrame( + file: $file, + shortFile: $file === '' ? '[internal function]' : self::shorten($file, $basePath), + line: $line, + call: $call, + vendor: $vendor, + excerpt: $vendor ? [] : self::excerpt($file, $line, $excerptLines), + ); + } + + /** + * The lines around $line, as line number => text. + * + * Guarded at every step because this runs while the application is ALREADY failing: the file may have + * been deleted since the trace was captured, may be unreadable, or may be an eval()'d fragment with no + * path at all. An error page that throws while explaining a throw is the worst possible outcome, so + * every branch here answers with an empty excerpt rather than an exception. + * + * @return array + */ + private static function excerpt(string $file, ?int $line, int $radius): array + { + if ($line === null || $radius === 0 || $file === '' || ! is_file($file) || ! is_readable($file)) { + return []; + } + + // A generated proxy or a minified vendor bundle can be one enormous line; reading it whole to show + // seven lines around a fault is not a trade worth making on a page that renders under duress. + if ((filesize($file) ?: 0) > 2 * 1024 * 1024) { + return []; + } + + $lines = @file($file, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return []; + } + + $from = max(1, $line - intdiv($radius, 2)); + $to = min(count($lines), $from + $radius - 1); + + $excerpt = []; + for ($n = $from; $n <= $to; $n++) { + $excerpt[$n] = $lines[$n - 1] ?? ''; + } + + return $excerpt; + } + + /** + * The `previous` chain, which is where the real cause usually is: firefly/web wraps a binding failure in + * an InvalidRequestException, the container wraps a constructor throw, and the message on the outermost + * exception is the least specific one in the chain. + * + * @return list + */ + private static function previous(Throwable $e, string $basePath): array + { + $chain = []; + $seen = 0; + + while (($e = $e->getPrevious()) !== null && $seen < 8) { + $seen++; + $chain[] = [ + 'class' => $e::class, + 'message' => $e->getMessage(), + 'location' => self::shorten($e->getFile(), $basePath).':'.$e->getLine(), + ]; + } + + return $chain; + } + + private static function shorten(string $file, string $basePath): string + { + if ($basePath !== '' && str_starts_with($file, $basePath)) { + return ltrim(substr($file, strlen($basePath)), '/\\'); + } + + return $file; + } +} diff --git a/packages/web/src/Error/ProblemMapper.php b/packages/web/src/Error/ProblemMapper.php new file mode 100644 index 0000000..6b11f15 --- /dev/null +++ b/packages/web/src/Error/ProblemMapper.php @@ -0,0 +1,69 @@ + $e, + $e instanceof HttpExceptionInterface => new FireflyException( + $e->getMessage() !== '' ? $e->getMessage() : self::statusText($e->getStatusCode()), + self::errorCode($e->getStatusCode()), + $e->getStatusCode(), + ErrorCategory::Framework, + ErrorSeverity::Warning, + $e, + ), + default => new FireflyException( + $e->getMessage() !== '' ? $e->getMessage() : 'Internal Server Error', + 'INTERNAL_ERROR', + 500, + ErrorCategory::Internal, + ErrorSeverity::Error, + $e, + ), + }; + } + + public static function statusText(int $status): string + { + /** @var array $texts */ + $texts = Response::$statusTexts; + + return $texts[$status] ?? 'HTTP Error'; + } + + private static function errorCode(int $status): string + { + return match ($status) { + 404 => 'RESOURCE_NOT_FOUND', + 405 => 'METHOD_NOT_ALLOWED', + default => 'HTTP_'.$status, + }; + } +} diff --git a/packages/web/src/Exception/ProblemDetailsRenderer.php b/packages/web/src/Exception/ProblemDetailsRenderer.php index 28e94f5..3c90311 100644 --- a/packages/web/src/Exception/ProblemDetailsRenderer.php +++ b/packages/web/src/Exception/ProblemDetailsRenderer.php @@ -6,48 +6,25 @@ use DateTimeImmutable; use DateTimeInterface; -use Firefly\Kernel\Error\ErrorCategory; use Firefly\Kernel\Error\ErrorResponse; -use Firefly\Kernel\Error\ErrorSeverity; -use Firefly\Kernel\Exception\FireflyException; +use Firefly\Web\Error\ProblemMapper; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Throwable; /** - * Maps any FireflyException to an application/problem+json response via ErrorResponse::fromException (a thin - * map — status/category/severity live on the exception). A Symfony/Illuminate HttpExceptionInterface (e.g. the - * router's own NotFoundHttpException for a URL with NO matching route at all — distinct from a Firefly - * ResourceNotFoundException thrown by a MATCHED route's handler) is converted preserving its REAL status code - * (bug fix, T12/actuator-T10: this branch was missing, so every unmatched route rendered as a 500 INTERNAL_ERROR - * for any JSON client — caught by the actuator HTTP capstone's master-gate-off assertion, which hits a - * genuinely unrouted /actuator/health). Any OTHER Throwable is converted to a generic 500 FireflyException. Does - * NOT redefine the error shape (that is kernel/M1's ErrorResponse). + * Renders any throwable as an application/problem+json response via ErrorResponse::fromException (a thin map + * — status/category/severity live on the exception). Does NOT redefine the error shape (that is kernel/M1's + * ErrorResponse), and no longer decides what a non-Firefly throwable BECOMES either: that rule moved to + * ProblemMapper when the HTML error page started needing the same answer, because two copies of it would + * eventually disagree about the same exception and hand a browser and a client different error codes for + * one failure. */ final class ProblemDetailsRenderer { public function render(Throwable $e, Request $request): Response { - $exception = match (true) { - $e instanceof FireflyException => $e, - $e instanceof HttpExceptionInterface => new FireflyException( - $e->getMessage() !== '' ? $e->getMessage() : self::statusText($e->getStatusCode()), - self::errorCode($e->getStatusCode()), - $e->getStatusCode(), - ErrorCategory::Framework, - ErrorSeverity::Warning, - $e, - ), - default => new FireflyException( - $e->getMessage() !== '' ? $e->getMessage() : 'Internal Server Error', - 'INTERNAL_ERROR', - 500, - ErrorCategory::Internal, - ErrorSeverity::Error, - $e, - ), - }; + $exception = ProblemMapper::toFireflyException($e); $payload = ErrorResponse::fromException( $exception, @@ -61,21 +38,4 @@ public function render(Throwable $e, Request $request): Response ['Content-Type' => 'application/problem+json'], ); } - - private static function errorCode(int $status): string - { - return match ($status) { - 404 => 'RESOURCE_NOT_FOUND', - 405 => 'METHOD_NOT_ALLOWED', - default => 'HTTP_'.$status, - }; - } - - private static function statusText(int $status): string - { - /** @var array $texts */ - $texts = Response::$statusTexts; - - return $texts[$status] ?? 'HTTP Error'; - } } diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index 7430eb6..f2e6aea 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -4,6 +4,7 @@ namespace Firefly\Web; +use Firefly\Config\Config; use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; use Firefly\Context\Scan\AppScan; @@ -16,6 +17,8 @@ use Firefly\Web\Dispatch\ControllerDispatcher; use Firefly\Web\Dispatch\ResponseFactory; use Firefly\Web\Dispatch\RouteWiringPass; +use Firefly\Web\Error\ErrorPageRenderer; +use Firefly\Web\Error\ErrorPageSettings; use Firefly\Web\Exception\ExceptionHandlerRegistry; use Firefly\Web\Exception\ProblemDetailsRenderer; use Firefly\Web\Filter\FilterChainRegistrar; @@ -58,6 +61,21 @@ public function passes(): array private function registerBindings(): void { + if (! $this->app->bound(ErrorPageSettings::class)) { + $this->app->singleton(ErrorPageSettings::class, static fn (Container $app): ErrorPageSettings => ErrorPageSettings::fromConfig($app->make(Config::class))); + } + + if (! $this->app->bound(ErrorPageRenderer::class)) { + $this->app->singleton(ErrorPageRenderer::class, static function (Container $app): ErrorPageRenderer { + // base_path() is what turns an absolute file name into `app/Http/OrderController.php` in the + // trace. Resolved through the container rather than through the global helper so the + // renderer stays constructible in a test that never booted a Laravel application. + $base = $app instanceof Application ? $app->basePath() : ''; + + return new ErrorPageRenderer($app->make(ErrorPageSettings::class), $base); + }); + } + if (! $this->app->bound(MessageConverterRegistry::class)) { $this->app->singleton(MessageConverterRegistry::class, static fn (): MessageConverterRegistry => new MessageConverterRegistry([new JsonMessageConverter])); } @@ -153,6 +171,16 @@ private function registerBindings(): void } } + /** + * One renderable answering in two shapes: a page for a browser, a problem document for everything else. + * + * The order is the whole of it. A browser that names `text/html` gets the HTML page — which is what + * fixes a person clicking a stale link and being shown a raw JSON blob, the behaviour every + * FireflyException had. Everything else keeps the previous rule exactly: a FireflyException, or a + * request that wants JSON, renders as problem+json. A throwable that is NEITHER — an unrouted URL hit by + * a client that asked for neither — still falls through to Laravel's handler, because inventing a + * response shape for a caller that expressed no preference is not this package's decision to make. + */ private function registerProblemDetailsRenderable(): void { $this->app->afterResolving(ExceptionHandlerContract::class, function (object $handler): void { @@ -161,6 +189,12 @@ private function registerProblemDetailsRenderable(): void } $handler->renderable(function (Throwable $e, Request $request) { + $page = $this->app->make(ErrorPageRenderer::class); + + if ($page->handles($request)) { + return $page->render($e, $request); + } + if ($e instanceof FireflyException || $request->expectsJson()) { return $this->app->make(ProblemDetailsRenderer::class)->render($e, $request); } diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php new file mode 100644 index 0000000..aa08491 --- /dev/null +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -0,0 +1,152 @@ + Request::create( + '/orders/42', + 'GET', + server: ['HTTP_ACCEPT' => $accept, ...$server], +); + +$report = static fn (Throwable $e, ErrorPageSettings $settings, ?Request $request = null): ErrorReport => ErrorReport::of( + $e, + $request ?? Request::create('/orders/42'), + $settings, + dirname(__DIR__, 4), + 404, + 'Not Found', + '2026-01-01T00:00:00+00:00', +); + +it('answers a browser with a page and everything else with a problem document', function () use ($request) { + $renderer = new ErrorPageRenderer(new ErrorPageSettings(enabled: true)); + + expect($renderer->handles($request('text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')))->toBeTrue() + ->and($renderer->handles($request('application/xhtml+xml')))->toBeTrue() + // A bare curl sends a wildcard. `acceptsHtml()` says yes to it, which is exactly why the rule is + // "NAMED text/html" instead — otherwise every unadorned command-line request against an API would + // start returning HTML, a worse regression than the bug this page fixes. + ->and($renderer->handles($request('*/*')))->toBeFalse() + ->and($renderer->handles($request('application/json')))->toBeFalse() + // JavaScript is going to read a body, not look at a page, even when the browser's Accept says HTML. + ->and($renderer->handles($request('text/html', ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'])))->toBeFalse(); +}); + +it('is switched off by configuration, and then never claims a request', function () use ($request) { + $renderer = new ErrorPageRenderer(new ErrorPageSettings(enabled: false)); + + expect($renderer->handles($request('text/html')))->toBeFalse(); +}); + +it('gathers nothing to leak when the trace is off', function () use ($report) { + $error = $report(new ResourceNotFoundException('Order 42 does not exist.', 'ORDER_NOT_FOUND'), new ErrorPageSettings(trace: false)); + + expect($error->detailed)->toBeFalse() + ->and($error->message)->toBe('') + ->and($error->exceptionClass)->toBe('') + ->and($error->location)->toBe('') + ->and($error->frames)->toBe([]) + ->and($error->previous)->toBe([]) + // The stable code IS published, on purpose: it is what a user quotes into a support ticket and what + // an operator greps the log for, and it names nothing internal. + ->and($error->code)->toBe('ORDER_NOT_FOUND'); +}); + +it('keeps the exception out of the rendered production page', function () use ($report) { + $settings = new ErrorPageSettings(trace: false, hints: false); + $html = ErrorPage::render($report(new ResourceNotFoundException('Order 42 does not exist.', 'ORDER_NOT_FOUND'), $settings), $settings); + + expect($html)->not->toContain('Order 42 does not exist.') + ->not->toContain('ResourceNotFoundException') + ->not->toContain('Stack trace') + // Nor the page's own advice about how to turn the trace on, which names the framework and a config + // key to an anonymous visitor. + ->not->toContain('APP_DEBUG') + ->toContain('ORDER_NOT_FOUND') + ->toContain('404'); +}); + +it('shows the throw site, its source and the caller chain when the trace is on', function () use ($report) { + $settings = new ErrorPageSettings(trace: true, hints: true); + $error = $report(new ResourceNotFoundException('Order 42 does not exist.', 'ORDER_NOT_FOUND'), $settings); + + expect($error->detailed)->toBeTrue() + ->and($error->message)->toBe('Order 42 does not exist.') + ->and($error->frames)->not->toBeEmpty() + // PHP's getTrace() starts at the CALLER of the throwing frame, so the throwing line appears nowhere + // in it and has to be prepended — otherwise the one frame a reader wants first is the one missing. + ->and($error->frames[0]->call)->toBe('throw') + ->and($error->frames[0]->line)->toBeGreaterThan(0) + ->and($error->frames[0]->vendor)->toBeFalse() + ->and($error->frames[0]->excerpt)->not->toBeEmpty() + ->and($error->frames[0]->excerpt)->toHaveKey((int) $error->frames[0]->line); + + $html = ErrorPage::render($error, $settings); + + expect($html)->toContain('Order 42 does not exist.') + ->toContain('Stack trace') + ->toContain('ErrorPageTest.php'); +}); + +it('reads no source for a vendor frame', function () use ($report) { + $error = $report(new ResourceNotFoundException('boom', 'X'), new ErrorPageSettings(trace: true)); + + // A trace is forty frames of which a handful are the application's. Opening forty files to render code + // nobody will read is work this page cannot afford — it runs when things are already going wrong. + foreach ($error->frames as $frame) { + if ($frame->vendor) { + expect($frame->excerpt)->toBe([]); + } + } + + expect(array_filter($error->frames, static fn ($f): bool => $f->vendor))->not->toBeEmpty(); +}); + +it('follows the previous chain, where the real cause usually is', function () use ($report) { + $cause = new RuntimeException('the connection was refused'); + $error = $report(new ResourceNotFoundException('Order 42 does not exist.', 'ORDER_NOT_FOUND', previous: $cause), new ErrorPageSettings(trace: true)); + + expect($error->previous)->toHaveCount(1) + ->and($error->previous[0]['class'])->toBe(RuntimeException::class) + ->and($error->previous[0]['message'])->toBe('the connection was refused'); +}); + +it('keeps the real status of a routing miss rather than calling it a 500', function () { + // A URL matching no route at all throws Symfony's NotFoundHttpException, not a FireflyException. Before + // ProblemMapper was shared, only the JSON renderer knew that; a page built from a second copy of the + // rule would eventually disagree, and the browser and the client would be told different things about + // one failure. + $settings = new ErrorPageSettings(trace: false); + $renderer = new ErrorPageRenderer($settings); + $response = $renderer->render(new NotFoundHttpException, Request::create('/nope', 'GET', server: ['HTTP_ACCEPT' => 'text/html'])); + + expect($response->getStatusCode())->toBe(404) + ->and($response->headers->get('Content-Type'))->toBe('text/html; charset=UTF-8') + ->and((string) $response->getContent())->toContain('RESOURCE_NOT_FOUND'); +}); + +it('escapes an exception message rather than rendering it as markup', function () use ($report) { + $settings = new ErrorPageSettings(trace: true); + $html = ErrorPage::render($report(new ResourceNotFoundException('', 'X'), $settings), $settings); + + expect($html)->not->toContain('') + ->toContain('<script>'); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 4906b61..7f0e5c6 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -361,6 +361,53 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Error page — firefly/web + |-------------------------------------------------------------------------- + | + | The HTML page a BROWSER gets when a request fails, in the same visual language as the welcome page and + | the admin dashboard, with the exception, its `previous` chain and a stack trace whose frames are split + | into yours and your dependencies'. + | + | WHO GETS IT. Only a client that NAMED `text/html` in its Accept header — which every browser does and + | no API client does by accident. A request that wants JSON, an XMLHttpRequest, and a bare `curl` (whose + | wildcard Accept expresses no preference) all still receive the RFC-7807 `application/problem+json` + | document, with the same status and the same `code` the page shows. One failure, two renderings, one + | vocabulary. + | + | Defaults: enabled true, trace = app.debug, title = app.name, excerpt-lines 7. + | + */ + + // 'web' => [ + // 'error-page' => [ + // // Turn this off to fall back to Laravel's own error page. Default: true. + // 'enabled' => true, + // + // /* + // | Whether the page carries the exception MESSAGE, its file and line, a source excerpt and the + // | stack trace. Follows `app.debug`, and setting it wins over that in both directions. + // | + // | It is enforced when the report is BUILT, not when it is rendered: with this off the + // | framework never walks the trace, never opens a source file and never copies the message, so + // | there is nothing assembled for a template mistake to leak. What production shows instead is + // | the status, the reason and the stable error code — enough for a user to quote into a ticket + // | and an operator to grep for, and nothing that names a class, a file or a row. + // | + // | Default: the value of `app.debug`. + // */ + // 'trace' => env('APP_DEBUG', false), + // + // // The name in the page's wordmark and title. Default: `app.name`. + // 'title' => env('APP_NAME', 'LaraFly'), + // + // // How many source lines to show around a throwing line, clamped to 0-40. 0 shows none. + // // Default: 7. + // 'excerpt-lines' => 7, + // ], + // ], + /* |-------------------------------------------------------------------------- | Admin dashboard — firefly/admin From 6f37a22b6c4ec13f5a9e72bbd0f6bd63a7e63b4c Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 19:15:16 -0700 Subject: [PATCH 22/31] feat(admin): a datasource page, entity relations you can walk, and a numeric column type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE DATASOURCE PAGE answers four questions an operator asks at 3am that this dashboard could not: Which database am I talking to — driver, host, port, database, per connection, with `password` masked by the same masker the actuator's env endpoint uses. Is it UP: one connection is PROBED per page load rather than all of them, because opening a socket can hang against a firewalled host and a page that opened every configured connection would take the slowest one's timeout to render — on the page you opened precisely because something is wrong. The default is probed on arrival, any other by name. What does pooling mean here: PHP has no connection pool, and a "pool size" gauge would be an invented number. What exists is PDO's ATTR_PERSISTENT, reported as what it is, with the note that under php-fpm the effective pool size is your worker count and that real pooling in front of Postgres is pgbouncer's job. What did #[Transactional] compile to — the manifest, one row per proxied method with its propagation, isolation, timeout and connection. It existed as a compiled artifact nobody could read without opening bootstrap/cache. ENTITY RELATIONS, IN BOTH DIRECTIONS Discovery CALLS the model's method, because that is the only way to learn which columns it joins on: a name says nothing and a return type says only the kind. Which makes "what is safe to call" the load-bearing question, and the answer is the declared return type — a method announcing `: HasMany` is a relation definition by construction, the same signal Laravel's own tooling relies on. A fixture entity declares a plain accessor and a method that mutates a static counter alongside its real relation, and a test asserts the counter never moves. A to-one opens one record; a to-many opens the child listing filtered to this row's key, which needed a filter the browser did not have. It is one column equal to one value and nothing more — every "children of this row" link is exactly that shape, and a richer filter builder in a hand-editable URL is a query surface with a very different security review. A column the schema does not have is DROPPED rather than passed to the driver, so an edited URL cannot probe for column names, and the filter ANDs with the search so narrowing a relation's listing cannot escape it. A NUMERIC COLUMN TYPE, because the vocabulary had a hole where it was most used. Every non-integer number was TYPE_STRING, so a `decimal(12,2)` total read as a string in the explorer, was offered to a LIKE search, and let the editor save "abc" into it. Verified by driving the editor: junk is refused and the row is unchanged; 99.5 saves. THE SKELETON NOW HAS TWO TABLES, which is what makes any of the above demonstrable — a relations feature no shipped example exercises is a feature nobody has run, which is the same lesson as firefly/admin not being required by anything. The split is the lesson too: an address is a VALUE and stays an embedded json column; a line is an ENTITY, so it gets a table, a foreign key and a repository. Placing an order is therefore two statements across two tables, which is what earns the sample its first #[Transactional] — and the compile now emits a proxy, asserted, because an annotation nothing compiled is a comment. Tertiary ink was 2.8:1 on the dashboard's own background and 3.1:1 on the welcome page — below AA, and not decorative: it painted table cells, every panel's explanatory note, the uppercase stat labels and the namespace half of every class name. Now 4.95 and 4.76 in light, 5.6 and 5.3 in dark. The hierarchy is carried by size, weight and position, which is where it belonged. 2035 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../admin/resources/views/data-list.blade.php | 23 +- .../resources/views/data-record.blade.php | 55 +++ .../resources/views/datasource.blade.php | 145 ++++++++ .../admin/resources/views/layout.blade.php | 23 +- .../admin/src/Boot/AdminRouteRegistrar.php | 6 + packages/admin/src/Data/DataBrowser.php | 69 +++- .../admin/src/Data/DataBrowserSettings.php | 7 + packages/admin/src/Data/DataColumn.php | 10 +- packages/admin/src/Data/DataFilter.php | 32 ++ packages/admin/src/Data/DataListing.php | 1 + packages/admin/src/Data/DataQueryEngine.php | 61 +++- packages/admin/src/Data/DataRelation.php | 46 +++ packages/admin/src/Data/DataSchemaFactory.php | 5 +- packages/admin/src/Data/DatasourceReport.php | 318 ++++++++++++++++++ .../admin/src/Data/RelationIntrospector.php | 171 ++++++++++ .../admin/src/Data/RepositoryIntrospector.php | 1 + packages/admin/src/Web/AdminAction.php | 52 +++ packages/admin/src/Web/AdminPage.php | 7 +- .../admin/tests/Data/DataRelationsTest.php | 121 +++++++ .../admin/tests/Data/Fixtures/AdminEntry.php | 57 ++++ .../Data/Fixtures/AdminEntryRepository.php | 15 + .../admin/tests/Data/Fixtures/AdminRecord.php | 10 + .../Data/Support/DataBrowserTestCase.php | 29 ++ .../tests/Skeleton/SkeletonExampleTest.php | 64 ++++ skeleton/app/Orders/OrderEntity.php | 26 +- skeleton/app/Orders/OrderLineEntity.php | 47 +++ skeleton/app/Orders/OrderLineRepository.php | 41 +++ skeleton/app/Orders/OrderService.php | 92 +++-- .../0001_01_01_000000_create_orders_table.php | 33 +- skeleton/resources/views/welcome.blade.php | 6 +- skeleton/tests/Feature/OrderTest.php | 58 +++- 31 files changed, 1568 insertions(+), 63 deletions(-) create mode 100644 packages/admin/resources/views/datasource.blade.php create mode 100644 packages/admin/src/Data/DataFilter.php create mode 100644 packages/admin/src/Data/DataRelation.php create mode 100644 packages/admin/src/Data/DatasourceReport.php create mode 100644 packages/admin/src/Data/RelationIntrospector.php create mode 100644 packages/admin/tests/Data/DataRelationsTest.php create mode 100644 packages/admin/tests/Data/Fixtures/AdminEntry.php create mode 100644 packages/admin/tests/Data/Fixtures/AdminEntryRepository.php create mode 100644 skeleton/app/Orders/OrderLineEntity.php create mode 100644 skeleton/app/Orders/OrderLineRepository.php diff --git a/packages/admin/resources/views/data-list.blade.php b/packages/admin/resources/views/data-list.blade.php index 1408c0c..cb96660 100644 --- a/packages/admin/resources/views/data-list.blade.php +++ b/packages/admin/resources/views/data-list.blade.php @@ -11,6 +11,10 @@ $identifier = $schema?->identifierColumn(); $base = $settings->url('data').'?resource='.urlencode($resource?->slug ?? ''); + // Carried onto every sort link and both pager links. A filter that survived neither would widen the + // listing back to every row the moment someone sorted it, which reads as rows appearing from nowhere. + $keepFilter = $listing->filter !== null ? '&'.$listing->filter->toQuery() : ''; + /** * Values arrive RAW: an Eloquent-backed row holds the driver's value, so a bool column can be int 1 * and a json column a string. The column TYPE is the rendering hint — never the value's PHP type. @@ -36,6 +40,14 @@

    + @if ($listing->filter !== null) +

    + Showing only rows where {{ $listing->filter->column }} is + {{ $listing->filter->value }}. + Show all {{ strtolower($resource?->label ?? 'records') }} +

    + @endif + @if ($listing->failed())
    @include('firefly-admin::_empty', ['title' => 'The listing failed', 'body' => e($listing->error)]) @@ -57,6 +69,12 @@ @if ($listing->sort)@endif + {{-- Searching inside a relation's listing NARROWS it; without these the search box + would silently drop the relation and search the whole table. --}} + @if ($listing->filter !== null) + + + @endif @endif @@ -82,7 +100,7 @@ @endphp
    @if ($sortable) - + {{ $column->label() }}@if ($isSorted) {{ $listing->direction === 'asc' ? '↑' : '↓' }}@endif @else @@ -121,7 +139,8 @@ @php $keep = ($listing->sort !== null ? '&sort='.urlencode($listing->sort).'&dir='.$listing->direction : '') - .($listing->search !== null ? '&q='.urlencode($listing->search) : ''); + .($listing->search !== null ? '&q='.urlencode($listing->search) : '') + .$keepFilter; @endphp @if ($listing->hasPrevious()) Previous diff --git a/packages/admin/resources/views/data-record.blade.php b/packages/admin/resources/views/data-record.blade.php index 46e93f3..f3c94f7 100644 --- a/packages/admin/resources/views/data-record.blade.php +++ b/packages/admin/resources/views/data-record.blade.php @@ -48,6 +48,61 @@ + @if ($relations !== []) +
    + @include('firefly-admin::_panel-head', ['title' => 'Related', 'count' => count($relations)]) +
    + + + + @foreach ($relations as $relation) + @php + // A to-one carries the key on THIS row and opens one record; a to-many carries it + // on the other table and opens that listing filtered by this row's key. The two + // produce different URLs from the same relation, which is why the direction is + // recorded rather than inferred in the template. + $value = $relation->toMany + ? ($record->fields[$relation->target] ?? $record->id) + : ($record->fields[$relation->column] ?? null); + + $href = null; + if ($relation->navigable() && $value !== null && $value !== '') { + $href = $settings->url('data').'?resource='.urlencode($relation->relatedSlug) + .($relation->toMany + ? '&fk='.urlencode($relation->column).'&fv='.urlencode((string) $value) + : '&id='.urlencode((string) $value)); + } + @endphp + + + + + + + + @endforeach + +
    RelationKindEntityJoined on
    {{ $relation->label }}{{ $relation->kind }}{{ $relation->shortRelated() ?: '—' }} + @if ($relation->column !== '') + {{ $relation->toMany ? $relation->shortRelated().'.'.$relation->column : $relation->column }} + @if ($relation->target !== '') → {{ $relation->target }} @endif + @else + — + @endif + + @if ($href !== null) + {{ $relation->toMany ? 'Browse' : 'Open' }} → + @endif +
    +
    + @if (collect($relations)->every(fn ($r) => ! $r->navigable())) +

    None of these can be opened here: the entity on the other end is not exposed by + a repository this application declared, or the join runs through a pivot or a type column + that a single-column filter cannot express.

    + @endif +
    + @endif + @if ($writable)
    @include('firefly-admin::_panel-head', ['title' => 'Edit', 'count' => count($record->schema->columns) - 1]) diff --git a/packages/admin/resources/views/datasource.blade.php b/packages/admin/resources/views/datasource.blade.php new file mode 100644 index 0000000..1128f51 --- /dev/null +++ b/packages/admin/resources/views/datasource.blade.php @@ -0,0 +1,145 @@ +@extends('firefly-admin::layout') +@section('title', 'Datasource') +@section('body') +
    +

    Datasource

    +

    Where this application's data lives, how it holds the connection open, and what + #[Transactional] compiled to. Connection settings come from Laravel's + config/database.php; secrets are masked with the same rule the actuator's + env endpoint uses.

    +
    + + @if (! $available) +
    + @include('firefly-admin::_empty', [ + 'title' => 'No database manager is bound', + 'body' => 'This application never resolved illuminate/database, which is a legal + LaraFly application — the container, the web layer and the actuator do not need one. + Install it and configure a connection to see anything here.', + ]) +
    + @else + + {{-- Whether the default connection actually answers is the first thing anyone wants, so it leads. --}} + @isset($probe) +
    +
    +

    Connectivity

    + + {{ $probe['up'] ? 'UP' : 'DOWN' }} +
    +
    +
    Connection
    {{ $probe['name'] }}
    +
    Server
    {{ $probe['version'] !== '' ? $probe['version'] : '—' }}
    +
    +

    {{ $probe['detail'] }}

    +
    + @endisset + +
    + @include('firefly-admin::_panel-head', [ + 'title' => 'Connections', 'count' => count($connections), + 'filter' => 'conn-body', 'placeholder' => 'Filter connections…', + ]) + @if ($connections === []) + @include('firefly-admin::_empty', [ + 'title' => 'No connections configured', + 'body' => 'config/database.php defines no connections at all.', + ]) + @else +
    + + + + @foreach ($connections as $connection) + + + + + + + + @endforeach + +
    NameDriverTargetSettings
    + {{ $connection['name'] }} + @if ($connection['default'])default@endif + {{ $connection['driver'] }}{{ $connection['target'] }} + @foreach ($connection['summary'] as $key => $value) + {{ $key }}{{ $value }} + @endforeach + @foreach ($connection['options'] as $key => $value) + {{ $key }}{{ $value }} + @endforeach + + @if ($probeEnabled) + Test + @endif +
    +
    + @endif +
    + +
    + @include('firefly-admin::_panel-head', ['title' => 'Connection reuse', 'count' => count($pooling)]) +
    + + + + @foreach ($pooling as $row) + + + + + + @endforeach + +
    ConnectionPersistentWhat that means
    {{ $row['name'] }}{{ $row['persistent'] ? 'on' : 'off' }}{{ $row['note'] }}
    +
    + {{-- Said plainly rather than dressed up as a pool gauge: inventing a number here would be worse + than the absence it would be covering for. --}} +

    + PHP has no connection pool. What exists is PDO's ATTR_PERSISTENT, which keeps a + connection open on the worker between requests — so under php-fpm the effective pool size is your + worker count, decided by the process manager rather than by this framework. Under Octane the + connection lives for the life of the worker either way. If you need real pooling in front of + Postgres, that is pgbouncer's job, and it sits between this application and the server. +

    +
    + +
    + @include('firefly-admin::_panel-head', [ + 'title' => 'Transactional methods', 'count' => count($transactional), + 'filter' => 'tx-body', 'placeholder' => 'Filter methods…', + ]) + @if ($transactional === []) + @include('firefly-admin::_empty', [ + 'title' => 'Nothing is proxied', + 'body' => 'No method carries #[Transactional], or firefly:cache has + not run since one was added. The manifest is compiled, so a new annotation is + invisible until it is recompiled.', + ]) + @else +
    + + + + @foreach ($transactional as $row) + + + + + + + + + + @endforeach + +
    ClassMethodPropagationIsolationRead onlyTimeoutConnection
    {{ $row['class'] }}{{ $row['method'] }}(){{ $row['propagation'] }}{{ $row['isolation'] }}@if ($row['readOnly'])yes@endif{{ $row['timeout'] }}{{ $row['connection'] }}
    +
    + @endif +
    + + @endif +@endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index cdd01d6..4134eef 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -31,7 +31,15 @@ --ink:#20242a; --ink-2:#5f6672; - --ink-3:#8d95a1; + /* + | THE TERTIARY INK IS A READABLE COLOUR, not a faded one. It was #8d95a1, which is 2.8:1 on + | this page's own background — below WCAG AA for normal text — and it is not decorative: it + | paints table cells (.dim), the explanatory notes under every panel, the uppercase stat + | labels and the namespace half of every class name. Those are content. The hierarchy between + | --ink-2 and --ink-3 is now carried by size, weight and position, which is where it belonged; + | you cannot buy hierarchy with illegibility. + */ + --ink-3:#667181; --brand:#e07a17; --accent:#0f62c9; @@ -64,7 +72,7 @@ --ink:#e8ecef; --ink-2:#9aa5af; - --ink-3:#6c7883; + --ink-3:#8592a0; --brand:#ff9d3c; --accent:#5da2ff; @@ -90,7 +98,7 @@ --ink:#e8ecef; --ink-2:#9aa5af; - --ink-3:#6c7883; + --ink-3:#8592a0; --brand:#ff9d3c; --accent:#5da2ff; @@ -303,6 +311,15 @@ .empty strong{display:block;font-size:14px;color:var(--ink);margin-bottom:4px;font-weight:600} .empty p{margin:0;font-size:13px;max-width:56ch;margin-inline:auto} .note{margin:12px 0 0;font-size:12.5px;color:var(--ink-3);max-width:80ch} + .note{padding:0 16px 14px} + .panel>.note:first-child{padding-top:14px} + + /* A settings row is a bag of small key/value facts, not a table of its own — a nested table for + `charset: utf8mb4` would be four times the markup to say the same thing, and would not wrap. */ + .pair{display:inline-flex;align-items:baseline;gap:5px;margin:0 8px 4px 0;font-family:var(--mono);font-size:11.5px;white-space:nowrap} + .pair b{font-weight:600;color:var(--ink-3)} + .pair.opt b{color:var(--accent)} + .stat dd.sm{font-size:14px;word-break:break-all} code{font-family:var(--mono);font-size:.9em;background:var(--hover);color:var(--ink-2); padding:1px 5px;border-radius:4px;border:1px solid var(--line)} diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php index 6ea4029..b6d62f1 100644 --- a/packages/admin/src/Boot/AdminRouteRegistrar.php +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -15,6 +15,7 @@ use Firefly\Admin\Data\DataQueryEngine; use Firefly\Admin\Data\DataResourceRegistry; use Firefly\Admin\Data\DataSchemaFactory; +use Firefly\Admin\Data\DatasourceReport; use Firefly\Admin\Data\RepositoryIntrospector; use Firefly\Admin\Web\AdminAction; use Firefly\Context\Boot\BootContext; @@ -74,6 +75,10 @@ public function run(BootContext $context): void // it is switched OFF: the dashboard asks it whether it is enabled, and a page that cannot ask has to // guess. Its own settings answer false by default, so building it costs a few objects and grants // nothing. + // Assembled here for the same reason DataBrowser is: it must exist even when there is no database + // manager to describe, because the page's job in that case is to say so. + $container->singleton(DatasourceReport::class, static fn (): DatasourceReport => DatasourceReport::forContainer($container)); + $container->singleton(DataBrowser::class, static function () use ($container, $context): DataBrowser { $settings = DataBrowserSettings::fromConfig($context->config); $introspector = new RepositoryIntrospector; @@ -102,6 +107,7 @@ public function run(BootContext $context): void // actuator's own wiring has bound one: the settings come from the same config keys either way. new ManagementPortGuard(ManagementServerSettings::fromConfig($context->config)), $container->make(DataBrowser::class), + $container->make(DatasourceReport::class), )); /** @var Router $router */ diff --git a/packages/admin/src/Data/DataBrowser.php b/packages/admin/src/Data/DataBrowser.php index ee102b7..9df7bd6 100644 --- a/packages/admin/src/Data/DataBrowser.php +++ b/packages/admin/src/Data/DataBrowser.php @@ -52,6 +52,7 @@ public function __construct( private readonly DataSchemaFactory $schemas, private readonly DataQueryEngine $engine, private readonly Container $container, + private readonly RelationIntrospector $relations = new RelationIntrospector, ) {} /** @@ -138,6 +139,7 @@ public function list( ?string $sort = null, string $direction = 'asc', ?string $search = null, + ?DataFilter $filter = null, ): DataListing { $perPage = $this->settings->clampPageSize($perPage); $page = max(1, $page); @@ -157,7 +159,68 @@ public function list( return DataListing::failure(self::UNRESOLVABLE, $resource, $schema, $page, $perPage); } - return $this->engine->list($repository, $resource, $schema, $page, $perPage, $sort, $direction, $search); + // A filter naming a column the resource does not have is DROPPED rather than passed to the + // database. The column arrives in a URL an operator can hand-edit, and a query that reached the + // driver with an arbitrary identifier in it is a column-name oracle at best. + if ($filter !== null && ! in_array($filter->column, array_map(static fn (DataColumn $c): string => $c->name, $schema->columns), true)) { + $filter = null; + } + + return $this->engine->list($repository, $resource, $schema, $page, $perPage, $sort, $direction, $search, $filter); + } + + /** + * The relations this resource's entity declares, each already matched to a browsable resource where one + * exists. + * + * MATCHING HAPPENS HERE and not in RelationIntrospector because it needs the REGISTRY: whether the other + * end of a relation is browsable depends on whether some repository declares it and whether that + * resource is excluded, neither of which is a fact about the model. Keeping the two apart means the + * introspector answers "what does this model relate to" once per class, and this method answers "and can + * I open it" against whatever the registry currently offers. + * + * @return list + */ + public function relationsFor(string $slug): array + { + if (! $this->settings->enabled || ! $this->settings->relations) { + return []; + } + + $resource = $this->registry->get($slug); + if ($resource === null || $resource->entityClass === null) { + return []; + } + + $bySlugForClass = []; + foreach ($this->registry->all() as $candidate) { + if ($candidate->entityClass !== null && ! isset($bySlugForClass[$candidate->entityClass])) { + $bySlugForClass[$candidate->entityClass] = $candidate->slug; + } + } + + $relations = []; + foreach ($this->relations->forEntity($resource->entityClass) as $found) { + $relations[] = new DataRelation( + name: $found['name'], + label: $this->humanise($found['name']), + kind: $found['kind'], + relatedClass: $found['related'], + relatedSlug: $bySlugForClass[$found['related']] ?? null, + column: $found['column'], + target: $found['target'], + toMany: $found['toMany'], + ); + } + + return $relations; + } + + private function humanise(string $name): string + { + $spaced = trim((string) preg_replace('/(?type) { DataColumn::TYPE_INT => preg_match('/^-?\d+$/', $string) === 1 ? [(int) $string] : false, + // is_numeric rather than a regex: it already accepts every spelling a number field can produce + // — a leading sign, a decimal point, exponent notation — and rejects the ones a decimal column + // would otherwise silently store as 0. + DataColumn::TYPE_FLOAT => is_numeric($string) ? [(float) $string] : false, DataColumn::TYPE_BOOL => $this->coerceBool($string), DataColumn::TYPE_DATETIME => strtotime($string) === false ? false : [$string], DataColumn::TYPE_JSON => $this->coerceJson($entity, $column, $string), diff --git a/packages/admin/src/Data/DataBrowserSettings.php b/packages/admin/src/Data/DataBrowserSettings.php index f92cec9..7734f88 100644 --- a/packages/admin/src/Data/DataBrowserSettings.php +++ b/packages/admin/src/Data/DataBrowserSettings.php @@ -46,6 +46,7 @@ public function __construct( public int $pageSize = 25, public int $maxPageSize = 200, public array $excluded = [], + public bool $relations = true, ) {} public static function fromConfig(Config $config): self @@ -58,6 +59,12 @@ public static function fromConfig(Config $config): self pageSize: min($max, max(1, $config->int('firefly.admin.data.page-size', 25))), maxPageSize: $max, excluded: self::csv($config->string('firefly.admin.data.exclude', '')), + // Relation discovery CONSTRUCTS each entity and CALLS the methods that declare a relation, which + // is more than reading configuration — see RelationIntrospector for why only a method whose + // declared return type is a Relation subclass is ever called. It defaults on because a record + // with no way to reach the rows it points at is half a browser, and it is a key so that an + // application with an unusual model base can switch it off without losing the rest. + relations: $config->bool('firefly.admin.data.relations', true), ); } diff --git a/packages/admin/src/Data/DataColumn.php b/packages/admin/src/Data/DataColumn.php index ecae68c..e38adfa 100644 --- a/packages/admin/src/Data/DataColumn.php +++ b/packages/admin/src/Data/DataColumn.php @@ -37,6 +37,14 @@ public const string TYPE_INT = 'int'; + /* + | Every non-integer number used to be TYPE_STRING, which made a `decimal(12,2)` total read as a string + | in the explorer, offered it to a LIKE search, and let the editor save "abc" into it. A money column is + | the single most common non-integer column in an application, so the vocabulary had a hole exactly + | where it was most used. + */ + public const string TYPE_FLOAT = 'float'; + public const string TYPE_BOOL = 'bool'; public const string TYPE_DATETIME = 'datetime'; @@ -92,7 +100,7 @@ public function label(): string /** Any type name outside the closed vocabulary degrades to `string` rather than reaching the view. */ private static function normalizeType(string $type): string { - return in_array($type, [self::TYPE_STRING, self::TYPE_INT, self::TYPE_BOOL, self::TYPE_DATETIME, self::TYPE_JSON], true) + return in_array($type, [self::TYPE_STRING, self::TYPE_INT, self::TYPE_FLOAT, self::TYPE_BOOL, self::TYPE_DATETIME, self::TYPE_JSON], true) ? $type : self::TYPE_STRING; } diff --git a/packages/admin/src/Data/DataFilter.php b/packages/admin/src/Data/DataFilter.php new file mode 100644 index 0000000..677b508 --- /dev/null +++ b/packages/admin/src/Data/DataFilter.php @@ -0,0 +1,32 @@ +column).'&fv='.urlencode($this->value); + } +} diff --git a/packages/admin/src/Data/DataListing.php b/packages/admin/src/Data/DataListing.php index dd98b5a..2ade9a9 100644 --- a/packages/admin/src/Data/DataListing.php +++ b/packages/admin/src/Data/DataListing.php @@ -37,6 +37,7 @@ public function __construct( public string $direction = 'asc', public ?string $search = null, public ?string $error = null, + public ?DataFilter $filter = null, ) {} /** diff --git a/packages/admin/src/Data/DataQueryEngine.php b/packages/admin/src/Data/DataQueryEngine.php index 75bdaa2..b391642 100644 --- a/packages/admin/src/Data/DataQueryEngine.php +++ b/packages/admin/src/Data/DataQueryEngine.php @@ -106,6 +106,7 @@ public function list( ?string $sort, string $direction, ?string $search, + ?DataFilter $filter = null, ): DataListing { $sort = $this->sortColumn($schema, $sort); $direction = strtolower($direction) === 'desc' ? 'desc' : 'asc'; @@ -115,7 +116,7 @@ public function list( // and stringifies whatever it finds, which is not obviously fallible until a model's accessor or a // value object's __toString throws — and a half-rendered page is exactly as broken as a failed query. try { - [$entities, $total] = $this->fetch($repository, $schema, $page, $perPage, $sort, $direction, $term); + [$entities, $total] = $this->fetch($repository, $schema, $page, $perPage, $sort, $direction, $term, $filter); $rows = []; foreach ($entities as $entity) { @@ -125,7 +126,7 @@ public function list( return DataListing::failure($this->safeReason('The listing query failed', $e), $resource, $schema, $page, $perPage); } - return new DataListing($resource, $schema, $rows, $total, $page, $perPage, $sort, $direction, $term); + return new DataListing($resource, $schema, $rows, $total, $page, $perPage, $sort, $direction, $term, null, $filter); } /** @@ -169,29 +170,59 @@ private function fetch( ?string $sort, string $direction, ?string $term, + ?DataFilter $filter = null, ): array { $pageable = new Pageable($page, $perPage, $this->sort($sort, $direction)); - if ($term !== null && $repository instanceof EloquentRepository) { - $columns = $this->searchColumns($schema); - if ($columns === []) { - return [[], 0]; + if (($term !== null || $filter !== null) && $repository instanceof EloquentRepository) { + $specifications = []; + + if ($term !== null) { + $columns = $this->searchColumns($schema); + if ($columns === []) { + return [[], 0]; + } + $specifications[] = $this->searchSpecification($columns, $term); } + if ($filter !== null) { + $specifications[] = $this->filterSpecification($filter); + } + + // AND, so a search inside a relation's listing narrows that relation rather than escaping it — + // the same reasoning that keeps the search's OR group nested. /** @var Page $result */ - $result = $repository->findBySpecificationPaged($this->searchSpecification($columns, $term), $pageable); + $result = $repository->findBySpecificationPaged(Specifications::allOf(...$specifications), $pageable); return [$result->items, $result->total]; } - if ($term === null && $repository instanceof PagingAndSortingRepository) { + if ($term === null && $filter === null && $repository instanceof PagingAndSortingRepository) { /** @var Page $result */ $result = $repository->findPaged($pageable); return [$result->items, $result->total]; } - return $this->fetchInPhp($repository, $schema, $page, $perPage, $sort, $direction, $term); + return $this->fetchInPhp($repository, $schema, $page, $perPage, $sort, $direction, $term, $filter); + } + + /** + * `column = value`, applied through the repository's own builder so anything its `query()` seam already + * constrained still holds. + * + * The comparison is a LOOSE string one, because the value arrives from a URL and is therefore always a + * string while the column may be an integer key. Binding it as-is lets the database do the coercion it + * would do for `where id = '7'` anyway, and keeps the value a bound parameter rather than anything + * concatenated. + * + * @return Specification + */ + private function filterSpecification(DataFilter $filter): Specification + { + return Specifications::where(static function (Builder $query) use ($filter): void { + $query->where($filter->column, '=', $filter->value); + }); } /** @@ -210,6 +241,7 @@ private function fetchInPhp( ?string $sort, string $direction, ?string $term, + ?DataFilter $filter = null, ): array { $needle = $term === null ? null : mb_strtolower($term); $columns = $this->searchColumns($schema); @@ -222,6 +254,12 @@ private function fetchInPhp( continue; } + // Loose, for the same reason the SQL path binds a string: the value came from a URL and the + // column is as likely to be an int key as a string. + if ($filter !== null && ! $this->equals($values[$filter->column] ?? null, $filter->value)) { + continue; + } + $matched[] = ['entity' => $entity, 'values' => $values]; } @@ -262,6 +300,11 @@ private function searchSpecification(array $columns, string $term): Specificatio }); } + private function equals(mixed $value, string $expected): bool + { + return is_scalar($value) && (string) $value === $expected; + } + /** * @param array $values * @param list $columns diff --git a/packages/admin/src/Data/DataRelation.php b/packages/admin/src/Data/DataRelation.php new file mode 100644 index 0000000..5f2a48f --- /dev/null +++ b/packages/admin/src/Data/DataRelation.php @@ -0,0 +1,46 @@ +relatedSlug !== null && $this->column !== '' && $this->target !== ''; + } + + public function shortRelated(): string + { + return str_contains($this->relatedClass, '\\') + ? substr($this->relatedClass, strrpos($this->relatedClass, '\\') + 1) + : $this->relatedClass; + } +} diff --git a/packages/admin/src/Data/DataSchemaFactory.php b/packages/admin/src/Data/DataSchemaFactory.php index 488e591..43983b0 100644 --- a/packages/admin/src/Data/DataSchemaFactory.php +++ b/packages/admin/src/Data/DataSchemaFactory.php @@ -177,7 +177,8 @@ private function castType(mixed $cast): ?string 'int', 'integer' => DataColumn::TYPE_INT, 'date', 'datetime', 'immutable_date', 'immutable_datetime', 'custom_datetime', 'immutable_custom_datetime', 'timestamp' => DataColumn::TYPE_DATETIME, - 'real', 'float', 'double', 'decimal', 'string' => DataColumn::TYPE_STRING, + 'real', 'float', 'double', 'decimal' => DataColumn::TYPE_FLOAT, + 'string' => DataColumn::TYPE_STRING, default => null, }; } @@ -202,6 +203,8 @@ private function columnType(string $typeName, string $fullType): string 'bool', 'boolean' => DataColumn::TYPE_BOOL, 'int', 'integer', 'bigint', 'smallint', 'mediumint', 'int2', 'int4', 'int8', 'serial', 'bigserial', 'smallserial' => DataColumn::TYPE_INT, + 'decimal', 'numeric', 'float', 'float4', 'float8', 'double', 'double precision', 'real', + 'money', 'smallmoney' => DataColumn::TYPE_FLOAT, 'json', 'jsonb' => DataColumn::TYPE_JSON, 'date', 'datetime', 'datetime2', 'smalldatetime', 'datetimeoffset', 'timestamp', 'timestamptz', 'datetimetz' => DataColumn::TYPE_DATETIME, diff --git a/packages/admin/src/Data/DatasourceReport.php b/packages/admin/src/Data/DatasourceReport.php new file mode 100644 index 0000000..38722cb --- /dev/null +++ b/packages/admin/src/Data/DatasourceReport.php @@ -0,0 +1,318 @@ + $database Laravel's `database` config, as written + */ + public function __construct( + private readonly ?ConnectionResolverInterface $connections, + private readonly ?TransactionalManifest $manifest, + private readonly array $database, + private readonly bool $probeEnabled = true, + ) {} + + public static function forContainer(Container $container): self + { + $resolver = null; + try { + $resolver = $container->make(DatabaseManager::class); + } catch (Throwable) { + // No database manager bound at all — a perfectly legal LaraFly application that never installed + // illuminate/database. The page then says so rather than failing to render. + } + + $manifest = null; + try { + $manifest = $container->make(TransactionalManifest::class); + } catch (Throwable) { + } + + $config = $container->make(Config::class); + + /** @var array $database */ + $database = $config->array('database', []); + + return new self($resolver, $manifest, $database, $config->bool('firefly.admin.datasource.probe', true)); + } + + /** Whether opening a connection to ask what it is, is permitted at all. */ + public function probeEnabled(): bool + { + return $this->probeEnabled; + } + + public function available(): bool + { + return $this->connections !== null; + } + + public function defaultConnection(): string + { + $default = $this->database['default'] ?? null; + + return is_string($default) ? $default : ''; + } + + /** + * Every configured connection, masked, with the handful of settings that actually matter pulled to the + * front and the rest kept underneath. + * + * @return list, options: array}> + */ + public function connections(): array + { + $configured = $this->database['connections'] ?? null; + + if (! is_array($configured)) { + return []; + } + + $rows = []; + foreach ($configured as $name => $settings) { + if (! is_array($settings)) { + continue; + } + + /** @var array $masked */ + $masked = SensitiveValueMasker::mask($settings); + $driver = is_string($masked['driver'] ?? null) ? $masked['driver'] : 'unknown'; + + $rows[] = [ + 'name' => (string) $name, + 'default' => (string) $name === $this->defaultConnection(), + 'driver' => $driver, + 'target' => $this->target($driver, $masked), + 'summary' => $this->summary($masked), + 'options' => $this->options($masked), + ]; + } + + return $rows; + } + + /** + * Opens a connection and asks it what it is. + * + * Deliberately separate from connections(): reading configuration is free and cannot fail, while opening + * a socket can hang against a firewalled host. Keeping them apart means the page renders its + * configuration half even when a connection is down — which is precisely the moment someone is looking + * at it. + * + * @return array{up: bool, detail: string, version: string} + */ + public function probe(string $name): array + { + if ($this->connections === null) { + return ['up' => false, 'detail' => 'No database manager is bound.', 'version' => '']; + } + + if (! $this->probeEnabled) { + return ['up' => false, 'detail' => 'Probing is switched off (firefly.admin.datasource.probe).', 'version' => '']; + } + + try { + $connection = $this->connections->connection($name); + + // Typed as the narrow ConnectionInterface, which does not promise a PDO — a connection may be a + // driver with none. `selectOne` is on the interface and forces the socket open either way, so it + // is the honest way to ask "does this answer"; the PDO version string is a bonus taken only when + // there is a PDO to take it from. + $connection->selectOne('select 1'); + + $version = ''; + if ($connection instanceof Connection) { + $attribute = $connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + $version = is_scalar($attribute) ? (string) $attribute : ''; + } + + return ['up' => true, 'detail' => 'Connected.', 'version' => $version]; + } catch (Throwable $e) { + // The message is shown as-is: this page is already behind the dashboard's gate, and a connection + // error whose text is withheld ("could not connect") is the single least useful thing an + // operator can be told. + return ['up' => false, 'detail' => $e->getMessage(), 'version' => '']; + } + } + + /** + * The compiled #[Transactional] manifest, flattened to one row per proxied METHOD. + * + * @return list + */ + public function transactionalMethods(): array + { + if ($this->manifest === null) { + return []; + } + + $rows = []; + foreach ($this->manifest->all() as $class => $proxy) { + foreach ($proxy['methods'] as $method => $descriptor) { + $rows[] = [ + 'class' => $class, + 'method' => $method, + 'propagation' => $descriptor['propagation'], + 'isolation' => $descriptor['isolation'], + 'readOnly' => $descriptor['readOnly'], + 'timeout' => $descriptor['timeout'] === null ? '—' : $descriptor['timeout'].'s', + 'connection' => $descriptor['connection'] ?? '(default)', + ]; + } + } + + usort($rows, static fn (array $a, array $b): int => [$a['class'], $a['method']] <=> [$b['class'], $b['method']]); + + return $rows; + } + + /** + * Whether PDO is told to keep connections open between requests, per connection. + * + * @return list + */ + public function pooling(): array + { + $rows = []; + + foreach ($this->connections() as $connection) { + $persistent = ($connection['options']['ATTR_PERSISTENT'] ?? 'false') === 'true'; + + $rows[] = [ + 'name' => $connection['name'], + 'persistent' => $persistent, + 'note' => $persistent + ? 'PDO keeps this connection open on the worker between requests.' + : 'A new connection is opened per request.', + ]; + } + + return $rows; + } + + /** + * @param array $settings + */ + private function target(string $driver, array $settings): string + { + $string = static fn (string $key): string => is_scalar($settings[$key] ?? null) ? (string) $settings[$key] : ''; + + if ($driver === 'sqlite') { + $database = $string('database'); + + return $database === '' ? '(unset)' : $database; + } + + $host = $string('host'); + $port = $string('port'); + $database = $string('database'); + + $target = $host === '' ? '' : $host.($port === '' ? '' : ':'.$port); + + return trim($target.($database === '' ? '' : '/'.$database), '/') ?: '(unset)'; + } + + /** + * @param array $settings + * @return array + */ + private function summary(array $settings): array + { + $summary = []; + + foreach (['host', 'port', 'database', 'username', 'password', 'charset', 'collation', 'prefix', 'search_path', 'schema', 'sslmode'] as $key) { + if (! array_key_exists($key, $settings)) { + continue; + } + $summary[$key] = $this->scalar($settings[$key]); + } + + return $summary; + } + + /** + * The PDO attribute options, with the numeric PDO:: constants translated back into the names a person + * wrote in their config file. A raw `{"12": true}` is technically the truth and tells nobody anything. + * + * @param array $settings + * @return array + */ + private function options(array $settings): array + { + $options = $settings['options'] ?? null; + + if (! is_array($options)) { + return []; + } + + $names = [ + PDO::ATTR_PERSISTENT => 'ATTR_PERSISTENT', + PDO::ATTR_TIMEOUT => 'ATTR_TIMEOUT', + PDO::ATTR_EMULATE_PREPARES => 'ATTR_EMULATE_PREPARES', + PDO::ATTR_ERRMODE => 'ATTR_ERRMODE', + PDO::ATTR_CASE => 'ATTR_CASE', + PDO::ATTR_STRINGIFY_FETCHES => 'ATTR_STRINGIFY_FETCHES', + PDO::ATTR_DEFAULT_FETCH_MODE => 'ATTR_DEFAULT_FETCH_MODE', + ]; + + $translated = []; + foreach ($options as $key => $value) { + $name = is_int($key) && isset($names[$key]) ? $names[$key] : (string) $key; + $translated[$name] = $this->scalar($value); + } + + ksort($translated); + + return $translated; + } + + private function scalar(mixed $value): string + { + return match (true) { + $value === null => 'null', + is_bool($value) => $value ? 'true' : 'false', + is_scalar($value) => (string) $value, + default => json_encode($value) ?: '(unencodable)', + }; + } +} diff --git a/packages/admin/src/Data/RelationIntrospector.php b/packages/admin/src/Data/RelationIntrospector.php new file mode 100644 index 0000000..803294c --- /dev/null +++ b/packages/admin/src/Data/RelationIntrospector.php @@ -0,0 +1,171 @@ +hasMany(Line::class)` + * without running it. So the method is called on a fresh, unsaved model, and the Relation object it returns + * is asked. That builds a query builder and executes NOTHING: Eloquent defers the query until you call get() + * or first(), neither of which happens here. + * + * WHICH METHODS ARE SAFE TO CALL, and this is the whole safety argument. Only a public, non-static method + * with no required parameters whose DECLARED RETURN TYPE is a Relation subclass. The declared return type is + * what makes it safe: a method announcing `: HasMany` is a relation definition by construction — it is the + * shape Laravel's own IDE tooling, its `with()` validation and every static analyser already rely on — and + * an accessor or a side-effecting method cannot claim it without lying about its own signature. Anything + * without that annotation is left alone, which costs a relation on an unannotated legacy model and is the + * right trade against calling arbitrary code on a page load. + * + * MorphTo IS REPORTED BUT NOT NAVIGABLE. Its other end is decided per ROW by a type column, so there is no + * single related class and no single resource to link to. Showing it as a relation with no link is more + * useful than hiding it: a reader learns the model is polymorphic, which is usually why the record in front + * of them looks the way it does. + * + * Every step is wrapped: a model that cannot be constructed, a relation method that throws, an Eloquent + * version whose accessor is named differently. The browser degrades to "no relations" rather than failing to + * render a record — the same bargain the rest of this package makes. + */ +final class RelationIntrospector +{ + /** @var array> */ + private array $cache = []; + + /** + * The relations $entityClass declares, before any of them are matched to a browsable resource. + * + * @return list + */ + public function forEntity(string $entityClass): array + { + if (isset($this->cache[$entityClass])) { + return $this->cache[$entityClass]; + } + + return $this->cache[$entityClass] = $this->discover($entityClass); + } + + /** + * @return list + */ + private function discover(string $entityClass): array + { + if (! class_exists($entityClass) || ! is_a($entityClass, Model::class, true)) { + return []; + } + + try { + $reflection = new ReflectionClass($entityClass); + if ($reflection->isAbstract()) { + return []; + } + $model = $reflection->newInstance(); + } catch (Throwable) { + return []; + } + + $relations = []; + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if (! $this->isRelationMethod($method)) { + continue; + } + + $relation = $this->describe($model, $method->getName()); + if ($relation !== null) { + $relations[] = $relation; + } + } + + usort($relations, static fn (array $a, array $b): int => [$a['toMany'], $a['name']] <=> [$b['toMany'], $b['name']]); + + return $relations; + } + + private function isRelationMethod(ReflectionMethod $method): bool + { + if ($method->isStatic() || $method->getNumberOfRequiredParameters() > 0 || $method->isConstructor()) { + return false; + } + + $type = $method->getReturnType(); + + return $type instanceof ReflectionNamedType + && ! $type->isBuiltin() + && is_a($type->getName(), Relation::class, true); + } + + /** + * @return array{name: string, kind: string, related: string, column: string, target: string, toMany: bool}|null + */ + private function describe(Model $model, string $name): ?array + { + try { + /** @var mixed $relation */ + $relation = $model->{$name}(); + } catch (Throwable) { + return null; + } + + if (! $relation instanceof Relation) { + return null; + } + + $kind = class_basename($relation); + + try { + // MorphTo first: it IS a BelongsTo subclass, and asking a MorphTo for its related class gives + // whichever placeholder Eloquent happened to instantiate rather than a real answer. + if ($relation instanceof MorphTo) { + return ['name' => $name, 'kind' => $kind, 'related' => '', 'column' => $relation->getForeignKeyName(), 'target' => '', 'toMany' => false]; + } + + $related = $relation->getRelated()::class; + + if ($relation instanceof BelongsTo) { + // The key is on THIS row and points at the other table. + return ['name' => $name, 'kind' => $kind, 'related' => $related, 'column' => $relation->getForeignKeyName(), 'target' => $relation->getOwnerKeyName(), 'toMany' => false]; + } + + if ($relation instanceof HasOneOrMany) { + // The key is on the OTHER table and points back at this row, which is what makes "the lines + // of order 7" a filter on the child listing rather than a lookup on this one. + return ['name' => $name, 'kind' => $kind, 'related' => $related, 'column' => $this->tail($relation->getForeignKeyName()), 'target' => $relation->getLocalKeyName(), 'toMany' => true]; + } + + if ($relation instanceof BelongsToMany) { + // The join lives in a pivot table, so neither side carries a column the browser can filter + // on. Reported for its shape, not as a link. + return ['name' => $name, 'kind' => $kind, 'related' => $related, 'column' => '', 'target' => '', 'toMany' => true]; + } + + // HasManyThrough and the morph-many family: a real relation whose join this browser cannot + // express as one column comparison. Named, counted as to-many, not linked. + return ['name' => $name, 'kind' => $kind, 'related' => $related, 'column' => '', 'target' => '', 'toMany' => true]; + } catch (Throwable) { + return null; + } + } + + /** Eloquent qualifies a child key as `table.column`; the browser filters on the bare column. */ + private function tail(string $key): string + { + return str_contains($key, '.') ? substr($key, strrpos($key, '.') + 1) : $key; + } +} diff --git a/packages/admin/src/Data/RepositoryIntrospector.php b/packages/admin/src/Data/RepositoryIntrospector.php index b8086f4..fbb9235 100644 --- a/packages/admin/src/Data/RepositoryIntrospector.php +++ b/packages/admin/src/Data/RepositoryIntrospector.php @@ -258,6 +258,7 @@ private function displayType(ReflectionNamedType $type): string if ($type->isBuiltin()) { return match ($type->getName()) { 'int' => DataColumn::TYPE_INT, + 'float' => DataColumn::TYPE_FLOAT, 'bool' => DataColumn::TYPE_BOOL, 'array', 'iterable' => DataColumn::TYPE_JSON, default => DataColumn::TYPE_STRING, diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 6909a16..c2fdb93 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -9,6 +9,8 @@ use Firefly\Admin\AdminSettings; use Firefly\Admin\BeanGraph; use Firefly\Admin\Data\DataBrowser; +use Firefly\Admin\Data\DataFilter; +use Firefly\Admin\Data\DatasourceReport; use Firefly\Admin\Format; use Firefly\Context\Scan\AppScan; use Illuminate\Contracts\Container\Container; @@ -35,6 +37,7 @@ public function __construct( private Container $container, private ManagementPortGuard $guard, private DataBrowser $data, + private DatasourceReport $datasource, ) {} public function __invoke(Request $request, string $page = ''): SymfonyResponse @@ -77,9 +80,43 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse return $request->isMethod('POST') ? $this->dataWrite($request) : $this->dataPage($request); } + if ($slug === 'datasource') { + return $this->datasourcePage($request, $current); + } + return $this->html($this->render($slug === '' ? 'overview' : $slug, $this->data($slug), $current), 200); } + /** + * The data-layer page. + * + * ONE CONNECTION IS PROBED PER PAGE LOAD, not all of them. Opening a socket can hang against a + * firewalled host, and a page that opened every configured connection would take the slowest one's + * timeout to render — on the page an operator opens precisely because something is wrong. So the + * default connection is probed on arrival and any other is probed only when asked for by name, which + * bounds the work to one connection whatever the config holds. + */ + private function datasourcePage(Request $request, AdminPage $current): SymfonyResponse + { + $connections = $this->datasource->connections(); + + $requested = $request->query('probe'); + $probing = is_string($requested) && $requested !== '' ? $requested : $this->datasource->defaultConnection(); + + $known = array_column($connections, 'name'); + $probe = in_array($probing, $known, true) ? ['name' => $probing, ...$this->datasource->probe($probing)] : null; + + return $this->html($this->render('datasource', [ + 'available' => $this->datasource->available(), + 'default' => $this->datasource->defaultConnection(), + 'connections' => $connections, + 'pooling' => $this->datasource->pooling(), + 'transactional' => $this->datasource->transactionalMethods(), + 'probe' => $probe, + 'probeEnabled' => $this->datasource->probeEnabled(), + ], $current), 200); + } + /** * An edit or a delete from the record page. * @@ -167,6 +204,7 @@ private function dataPage(Request $request): SymfonyResponse : $this->html($this->render('data-record', [ 'record' => $record, 'writable' => $this->data->isWritable(), + 'relations' => $this->data->relationsFor($slug), ]), 200); } @@ -175,6 +213,14 @@ private function dataPage(Request $request): SymfonyResponse $direction = $request->query('dir') === 'desc' ? 'desc' : 'asc'; $search = $request->query('q'); + // `fk`/`fv` is how a relation link narrows a listing: "the lines whose order_id is 7". DataBrowser + // drops a column the schema does not have, so a hand-edited pair cannot reach the driver. + $column = $request->query('fk'); + $value = $request->query('fv'); + $filter = is_string($column) && $column !== '' && is_string($value) && $value !== '' + ? new DataFilter($column, $value) + : null; + return $this->html($this->render('data-list', [ 'listing' => $this->data->list( $slug, @@ -183,8 +229,10 @@ private function dataPage(Request $request): SymfonyResponse is_string($sort) && $sort !== '' ? $sort : null, $direction, is_string($search) && $search !== '' ? $search : null, + $filter, ), 'writable' => $this->data->isWritable(), + 'relations' => $this->data->relationsFor($slug), ]), 200); } @@ -466,6 +514,10 @@ private function nav(): array fn (AdminPage $page): bool => $this->settings->allows($page->slug) // The data browser has no actuator endpoint; its own switch decides whether it is offered. && ($page->slug !== 'data' || $this->data->isEnabled()) + // Datasource needs a database manager to describe. An application with none is a legal + // LaraFly application, and a menu entry leading to "there is nothing here" is worse than no + // entry at all. + && ($page->slug !== 'datasource' || $this->datasource->available()) && ($page->requires === null || $this->reader->has($page->requires)), )); } diff --git a/packages/admin/src/Web/AdminPage.php b/packages/admin/src/Web/AdminPage.php index e8281b0..d8c15f0 100644 --- a/packages/admin/src/Web/AdminPage.php +++ b/packages/admin/src/Web/AdminPage.php @@ -69,8 +69,11 @@ public static function all(): array new self('loggers', 'Loggers', 'loggers', self::GROUP_CONFIG, 'Log channels and their levels.'), - // `requires` is null: the data browser reads repositories through the container, not an actuator - // endpoint. Its own switch decides whether the page appears — see AdminAction::nav(). + // Both Data pages have a null `requires`: they read the container, not an actuator endpoint. + // Datasource is offered whenever a database manager is bound; the browser has its own switch on + // top of that — see AdminAction::nav(). + new self('datasource', 'Datasource', null, self::GROUP_DATA, + 'Connections, persistence settings and the compiled #[Transactional] contract.'), new self('data', 'Browse data', null, self::GROUP_DATA, 'Every repository this application declared, and the records behind it.'), ]; diff --git a/packages/admin/tests/Data/DataRelationsTest.php b/packages/admin/tests/Data/DataRelationsTest.php new file mode 100644 index 0000000..1c0c7e0 --- /dev/null +++ b/packages/admin/tests/Data/DataRelationsTest.php @@ -0,0 +1,121 @@ +relatedBrowser()->relationsFor('admin-record'); + $child = $this->relatedBrowser()->relationsFor('admin-entry'); + + expect($parent)->toHaveCount(1) + ->and($parent[0]->kind)->toBe('HasMany') + ->and($parent[0]->toMany)->toBeTrue() + // The key is on the CHILD table and points back here, which is what makes "the entries of record 1" + // a filter on the child listing rather than a lookup on this row. + ->and($parent[0]->column)->toBe('record_id') + ->and($parent[0]->target)->toBe('id') + ->and($parent[0]->relatedSlug)->toBe('admin-entry') + ->and($parent[0]->navigable())->toBeTrue(); + + expect($child)->toHaveCount(1) + ->and($child[0]->kind)->toBe('BelongsTo') + ->and($child[0]->toMany)->toBeFalse() + // The other way round: the key is on THIS row. + ->and($child[0]->column)->toBe('record_id') + ->and($child[0]->target)->toBe('id') + ->and($child[0]->relatedSlug)->toBe('admin-record'); +}); + +it('calls only the methods that declare a relation return type', function () { + /** @var DataBrowserTestCase $this */ + $this->relatedBrowser()->relationsFor('admin-entry'); + + // AdminEntry::touchedCount() is public, takes no arguments, and increments a static. If discovery ever + // widened past "the declared return type is a Relation", this is the counter that would move — and the + // failure would be arbitrary application code running on a dashboard page load. + expect(AdminEntry::$calls)->toBe(0); +}); + +it('is not navigable when the other end is not a browsable resource', function () { + /** @var DataBrowserTestCase $this */ + // A catalogue with only the parent: the relation still exists and is still worth showing, but there is + // nowhere for a link to go. Distinguished here rather than in the view so a template cannot mint a URL + // that 404s. + $relations = $this->browserOver([AdminRecordRepository::class], ['enabled' => true])->relationsFor('admin-record'); + + expect($relations)->toHaveCount(1) + ->and($relations[0]->relatedSlug)->toBeNull() + ->and($relations[0]->navigable())->toBeFalse(); +}); + +it('narrows a listing to the rows on the other end of a relation', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $this->seedEntries(); + + $all = $this->relatedBrowser()->list('admin-entry'); + $mine = $this->relatedBrowser()->list('admin-entry', filter: new DataFilter('record_id', '1')); + + expect($all->total)->toBe(3) + ->and($mine->total)->toBe(2) + ->and($mine->filter?->column)->toBe('record_id') + ->and(array_column($mine->rows, 'note'))->toBe(['first for ada', 'second for ada']); +}); + +it('combines a filter with a search rather than letting either escape the other', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $this->seedEntries(); + + // Searching inside a relation's listing must NARROW it. An OR here would answer "every entry matching + // 'ada', plus every entry of record 1" — which shows a reader rows from outside the relation they are + // looking at. + $listing = $this->relatedBrowser()->list('admin-entry', search: 'second', filter: new DataFilter('record_id', '1')); + + expect($listing->total)->toBe(1) + ->and($listing->rows[0]['note'])->toBe('second for ada'); + + expect($this->relatedBrowser()->list('admin-entry', search: 'only', filter: new DataFilter('record_id', '1'))->total)->toBe(0); +}); + +it('drops a filter naming a column the resource does not have', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $this->seedEntries(); + + // The column arrives in a URL an operator can hand-edit. A query that reached the driver with an + // arbitrary identifier in it is a column-name oracle at best, so an unknown column is dropped and the + // listing widens rather than erroring — which also tells the caller nothing about what does exist. + $listing = $this->relatedBrowser()->list('admin-entry', filter: new DataFilter('no_such_column', '1')); + + expect($listing->failed())->toBeFalse() + ->and($listing->total)->toBe(3) + ->and($listing->filter)->toBeNull(); +}); + +it('offers no relations when the browser or the feature is switched off', function () { + /** @var DataBrowserTestCase $this */ + expect($this->relatedBrowser(['enabled' => false])->relationsFor('admin-record'))->toBe([]) + ->and($this->relatedBrowser(['enabled' => true, 'relations' => false])->relationsFor('admin-record'))->toBe([]); +}); diff --git a/packages/admin/tests/Data/Fixtures/AdminEntry.php b/packages/admin/tests/Data/Fixtures/AdminEntry.php new file mode 100644 index 0000000..85ab3bb --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/AdminEntry.php @@ -0,0 +1,57 @@ + */ + protected function casts(): array + { + return ['record_id' => 'integer', 'amount' => 'float']; + } + + /** @return BelongsTo */ + public function record(): BelongsTo + { + return $this->belongsTo(AdminRecord::class, 'record_id'); + } + + public function label(): string + { + return 'entry'; + } + + public function touchedCount(): int + { + return ++self::$calls; + } +} diff --git a/packages/admin/tests/Data/Fixtures/AdminEntryRepository.php b/packages/admin/tests/Data/Fixtures/AdminEntryRepository.php new file mode 100644 index 0000000..ce421fd --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/AdminEntryRepository.php @@ -0,0 +1,15 @@ + + */ +final class AdminEntryRepository extends EloquentRepository +{ + protected string $model = AdminEntry::class; +} diff --git a/packages/admin/tests/Data/Fixtures/AdminRecord.php b/packages/admin/tests/Data/Fixtures/AdminRecord.php index 7962f39..ab75226 100644 --- a/packages/admin/tests/Data/Fixtures/AdminRecord.php +++ b/packages/admin/tests/Data/Fixtures/AdminRecord.php @@ -5,6 +5,7 @@ namespace Firefly\Admin\Tests\Data\Fixtures; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * A real Eloquent model over a real sqlite table, shaped to exercise every branch of column derivation at @@ -20,6 +21,9 @@ * @property bool $active * @property array|null $meta * @property string|null $created_at + * + * It also declares the PARENT half of the relation fixture, so the browser has an edge to walk in both + * directions — a hasMany here and a belongsTo on AdminEntry. */ final class AdminRecord extends Model { @@ -37,4 +41,10 @@ protected function casts(): array { return ['meta' => 'array', 'active' => 'boolean']; } + + /** @return HasMany */ + public function entries(): HasMany + { + return $this->hasMany(AdminEntry::class, 'record_id'); + } } diff --git a/packages/admin/tests/Data/Support/DataBrowserTestCase.php b/packages/admin/tests/Data/Support/DataBrowserTestCase.php index 3e19b4e..ec31a7d 100644 --- a/packages/admin/tests/Data/Support/DataBrowserTestCase.php +++ b/packages/admin/tests/Data/Support/DataBrowserTestCase.php @@ -10,6 +10,7 @@ use Firefly\Admin\Data\DataRecord; use Firefly\Admin\Data\DataResource; use Firefly\Admin\Data\DataSchema; +use Firefly\Admin\Tests\Data\Fixtures\AdminEntryRepository; use Firefly\Admin\Tests\Data\Fixtures\AdminRecordRepository; use Firefly\Admin\Tests\Data\Fixtures\NotARepository; use Firefly\Admin\Tests\Data\Fixtures\PlainNoteRepository; @@ -47,6 +48,15 @@ protected function setUp(): void $table->dateTime('created_at')->nullable(); }); + // The child half of the relation fixture: a foreign key back to admin_records, so a hasMany and a + // belongsTo are both walkable. + Schema::create('admin_entries', function (Blueprint $table): void { + $table->increments('id'); + $table->integer('record_id'); + $table->string('note'); + $table->decimal('amount', 10, 2)->default(0); + }); + Schema::create('admin_notes', function (Blueprint $table): void { $table->integer('id')->primary(); $table->string('title'); @@ -97,6 +107,15 @@ protected function seedRecords(): void DB::table('admin_records')->insert($rows); } + protected function seedEntries(): void + { + DB::table('admin_entries')->insert([ + ['id' => 1, 'record_id' => 1, 'note' => 'first for ada', 'amount' => 10.50], + ['id' => 2, 'record_id' => 1, 'note' => 'second for ada', 'amount' => 20.25], + ['id' => 3, 'record_id' => 3, 'note' => 'only for grace', 'amount' => 30.00], + ]); + } + protected function seedWidgets(): void { DB::table('widgets')->insert([ @@ -135,6 +154,16 @@ protected function browser(array $data = ['enabled' => true]): DataBrowser ); } + /** + * A browser over the two halves of the relation fixture — a parent and its children. + * + * @param array $data + */ + protected function relatedBrowser(array $data = ['enabled' => true]): DataBrowser + { + return $this->browserOver([AdminRecordRepository::class, AdminEntryRepository::class], $data); + } + /** * A browser over an arbitrary catalogue — the seam the slug-collision test needs. * diff --git a/packages/cli/tests/Skeleton/SkeletonExampleTest.php b/packages/cli/tests/Skeleton/SkeletonExampleTest.php index 9022f6c..351bb20 100644 --- a/packages/cli/tests/Skeleton/SkeletonExampleTest.php +++ b/packages/cli/tests/Skeleton/SkeletonExampleTest.php @@ -9,6 +9,7 @@ use Firefly\Container\Scanner\ComponentManifest; use Firefly\Web\Route\RouteDescriptor; use Firefly\Web\Route\RouteManifest; +use Illuminate\Support\Facades\DB; /** * The shipped example, under the default gate. @@ -270,3 +271,66 @@ ->and(preg_match('/[A-Z]/', $path))->toBe(0, "route path [{$path}] contains an upper-case segment"); } }); + +it('writes an order and its lines into two tables, and cascades the delete', function () { + /** @var SkeletonExampleTestCase $this */ + $id = $this->postJson('/orders', SkeletonApp::orderBody())->json('id'); + if (! is_int($id)) { + throw new RuntimeException('the created order came back without an integer id.'); + } + + // The shipped sample has two tables because a LINE is an entity and an ADDRESS is a value: the address + // is embedded as a json column on the order, the lines are rows with a foreign key. That split is what + // gives the admin dashboard a relation to walk and what makes "how many WIDGET-1 did we sell" a query + // rather than a JSON scan — and it is only correct if both writes actually happen. + expect(DB::table('orders')->where('id', $id)->count())->toBe(1) + ->and(DB::table('order_lines')->where('order_id', $id)->count())->toBe(2) + ->and(DB::table('order_lines')->where('order_id', $id)->orderBy('id')->value('sku'))->toBe('WIDGET-1'); + + $this->deleteJson('/orders/'.$id)->assertNoContent(); + + // A cancelled order that left its lines behind would leave rows nothing can reach and every + // sum(unit_price) wrong. + expect(DB::table('order_lines')->where('order_id', $id)->count())->toBe(0); +}); + +it('replaces an order\'s lines wholesale rather than merging them', function () { + /** @var SkeletonExampleTestCase $this */ + $id = $this->postJson('/orders', SkeletonApp::orderBody())->json('id'); + if (! is_int($id)) { + throw new RuntimeException('the created order came back without an integer id.'); + } + + // A PUT says nothing about which line is which, so matching the incoming lines to the stored ones would + // invent an identity the client never sent. + $this->putJson('/orders/'.$id, SkeletonApp::orderBody([ + 'lines' => [['sku' => 'BOLT-9', 'quantity' => 3, 'unitPrice' => 2.0]], + ]))->assertOk()->assertJsonCount(1, 'lines'); + + expect(DB::table('order_lines')->where('order_id', $id)->count())->toBe(1) + ->and(DB::table('order_lines')->where('sku', 'WIDGET-1')->count())->toBe(0) + // The total is recomputed from the new lines, never carried over from the old ones. + // The driver hands a decimal back as a string, so the total is read as a scalar and cast once + // rather than compared against whichever spelling this connection happens to return. + ->and(scalarTotal($id))->toBe(6.0); +}); + +it('compiles a #[Transactional] proxy for the sample service', function () { + // Placing an order is two statements across two tables, so the sample annotates its writes — and the + // annotation is only real if `firefly:cache` actually emitted a proxy for it. A report of zero proxies + // here would mean every write in the shipped example runs unwrapped while the docblock says otherwise. + $report = SkeletonExampleTestCase::$report; + if ($report === null) { + throw new RuntimeException('the skeleton compile produced no report.'); + } + + expect($report->proxyCount)->toBeGreaterThan(0); +}); + +/** The stored total of one order, as a float whatever spelling the driver returned it in. */ +function scalarTotal(int $id): float +{ + $value = DB::table('orders')->where('id', $id)->value('total'); + + return is_scalar($value) ? (float) $value : 0.0; +} diff --git a/skeleton/app/Orders/OrderEntity.php b/skeleton/app/Orders/OrderEntity.php index 81b4fa4..c81aaca 100644 --- a/skeleton/app/Orders/OrderEntity.php +++ b/skeleton/app/Orders/OrderEntity.php @@ -5,6 +5,7 @@ namespace App\Orders; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * The persistence shape of an order — an ordinary Eloquent model over the `orders` table. @@ -18,24 +19,39 @@ * The mapping between this and Order lives in OrderService, the same place Spring puts it when a repository * returns entities and the use cases speak in domain types. * - * `ship_to` and `lines` are cast to arrays because they are json columns; `total` is a decimal column, and - * PDO hands decimals back as strings, so without the cast the API's `total` would silently change from a - * number to a string the first time the value came from the database instead of from Order::total(). + * `ship_to` is cast to an array because it is a json column — an address is a VALUE, embedded in the order, + * with no identity of its own. Lines are not: they are entities with their own ids, so they live in their + * own table behind `lines()` below. `total` is a decimal column, and PDO hands decimals back as strings, so + * without the cast the API's `total` would silently change from a number to a string the first time the + * value came from the database instead of from Order::total(). */ class OrderEntity extends Model { protected $table = 'orders'; /** @var list */ - protected $fillable = ['customer', 'email', 'ship_to', 'lines', 'total']; + protected $fillable = ['customer', 'email', 'ship_to', 'total']; /** @return array */ protected function casts(): array { return [ 'ship_to' => 'array', - 'lines' => 'array', 'total' => 'float', ]; } + + /** + * The order's lines. + * + * The declared `: HasMany` return type is what firefly/admin's data browser reads to offer "browse the + * lines of this order" — it discovers a relation by its return type rather than by its name, because a + * name says nothing and a type says exactly what this is. + * + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(OrderLineEntity::class, 'order_id'); + } } diff --git a/skeleton/app/Orders/OrderLineEntity.php b/skeleton/app/Orders/OrderLineEntity.php new file mode 100644 index 0000000..745b694 --- /dev/null +++ b/skeleton/app/Orders/OrderLineEntity.php @@ -0,0 +1,47 @@ + */ + protected $fillable = ['order_id', 'sku', 'quantity', 'unit_price']; + + /** @return array */ + protected function casts(): array + { + return [ + 'order_id' => 'integer', + 'quantity' => 'integer', + // PDO hands decimals back as strings; without the cast an API's `unitPrice` would silently + // change from a number to a string the first time it came from the database. + 'unit_price' => 'float', + ]; + } + + /** @return BelongsTo */ + public function order(): BelongsTo + { + return $this->belongsTo(OrderEntity::class); + } +} diff --git a/skeleton/app/Orders/OrderLineRepository.php b/skeleton/app/Orders/OrderLineRepository.php new file mode 100644 index 0000000..f6c1db0 --- /dev/null +++ b/skeleton/app/Orders/OrderLineRepository.php @@ -0,0 +1,41 @@ + + */ +#[Repository] +class OrderLineRepository extends EloquentRepository +{ + /** @var class-string */ + protected string $model = OrderLineEntity::class; + + /** + * Every line of one order, in insertion order. + * + * @return list + */ + public function findByOrderIdOrderByIdAsc(int $orderId): array + { + $rows = $this->dispatchQuery(__FUNCTION__, func_get_args()); + assert(is_array($rows)); + + /** @var list $rows */ + return $rows; + } +} diff --git a/skeleton/app/Orders/OrderService.php b/skeleton/app/Orders/OrderService.php index 18da870..0ec0e51 100644 --- a/skeleton/app/Orders/OrderService.php +++ b/skeleton/app/Orders/OrderService.php @@ -6,6 +6,7 @@ use Firefly\Container\Attributes\Service; use Firefly\Data\Repository\Pageable; +use Firefly\Data\Transaction\Attributes\Transactional; use Firefly\Kernel\Exception\Business\ResourceNotFoundException; /** @@ -29,11 +30,24 @@ * TOTAL IS COMPUTED, NEVER ACCEPTED. `Order::total()` derives the value from the lines; toRow() writes what * it computed into the column. A client that posts a `total` is ignored, because the request DTO has no such * field — the strongest way to say a value is not the client's to set. + * + * WHY THE WRITES ARE #[Transactional]. An order is two tables — the order row and its lines — so placing one + * is two statements and replacing one is three. Without a transaction a crash between them leaves an order + * with half its lines and a `total` that matches neither, which is not a state any reader can recover from. + * `firefly:cache` compiles the annotation into a proxy that opens and commits around the method, so nothing + * here calls `DB::transaction()` and nothing here has a `try/rollback` — the same trade Spring makes, and the + * reason this class is NOT final: the generated proxy `extends` it. + * + * The reads are deliberately not annotated. A single SELECT needs no transaction, and wrapping one in + * #[Transactional(readOnly: true)] to look symmetrical would buy a proxy and a round trip for nothing. */ #[Service] -final class OrderService +class OrderService { - public function __construct(private readonly OrderRepository $orders) {} + public function __construct( + private readonly OrderRepository $orders, + private readonly OrderLineRepository $lines, + ) {} /** * @return array{page: int, size: int, total: int, items: list} @@ -58,6 +72,7 @@ public function find(int $id): Order return $this->toDomain($this->row($id)); } + #[Transactional] public function place(Order $order): Order { $row = new OrderEntity; @@ -65,32 +80,69 @@ public function place(Order $order): Order $saved = $this->orders->save($row); assert($saved instanceof OrderEntity); + $this->writeLines((int) $saved->getKey(), $order); + return $this->toDomain($saved); } /** @throws ResourceNotFoundException when no order carries that id */ + #[Transactional] public function replace(int $id, Order $order): Order { // PUT replaces the order wholesale but keeps its identity, so the existing row is refilled rather // than deleted and re-inserted: the id in the client's URL stays valid and so does anything holding - // a foreign key to it. + // a foreign key to it. The LINES are replaced outright, because a PUT says nothing about which line + // is which and matching them up would be inventing an identity the client never sent. $row = $this->row($id); $row->fill($this->toRow($order)); $saved = $this->orders->save($row); assert($saved instanceof OrderEntity); + $this->writeLines($id, $order); + return $this->toDomain($saved); } /** @throws ResourceNotFoundException when no order carries that id */ + #[Transactional] public function cancel(int $id): void { // deleteById() returns void — deleting something absent is not an error to Eloquent — so the // existence check is what turns "nothing happened" into the 404 the API promised. $this->row($id); + $this->deleteLines($id); $this->orders->deleteById($id); } + /** + * Replace an order's lines with the ones it now carries. + * + * The delete-then-insert is inside the caller's transaction, which is the only thing that makes it safe: + * on its own it is a window in which an order has no lines at all. + */ + private function writeLines(int $orderId, Order $order): void + { + $this->deleteLines($orderId); + + foreach ($order->lines as $line) { + $row = new OrderLineEntity; + $row->fill([ + 'order_id' => $orderId, + 'sku' => $line->sku, + 'quantity' => $line->quantity, + 'unit_price' => $line->unitPrice, + ]); + $this->lines->save($row); + } + } + + private function deleteLines(int $orderId): void + { + foreach ($this->lines->findByOrderIdOrderByIdAsc($orderId) as $line) { + $this->lines->deleteById($line->getKey()); + } + } + /** @throws ResourceNotFoundException when no order carries that id */ private function row(int $id): OrderEntity { @@ -103,14 +155,20 @@ private function row(int $id): OrderEntity return $row; } - /** A row as the domain understands it. */ + /** A row, and the rows it owns, as the domain understands them. */ private function toDomain(OrderEntity $row): Order { /** @var array{street?: string, city?: string, postcode?: string, country?: string} $shipTo */ $shipTo = is_array($row->ship_to) ? $row->ship_to : []; - /** @var list $lines */ - $lines = is_array($row->lines) ? array_values($row->lines) : []; + $lines = array_map( + static fn (OrderLineEntity $line): OrderLine => new OrderLine( + (string) $line->sku, + (int) $line->quantity, + (float) $line->unit_price, + ), + $this->lines->findByOrderIdOrderByIdAsc((int) $row->getKey()), + ); return new Order( (int) $row->getKey(), @@ -122,20 +180,14 @@ private function toDomain(OrderEntity $row): Order (string) ($shipTo['postcode'] ?? ''), (string) ($shipTo['country'] ?? ''), ), - array_map( - static fn (array $line): OrderLine => new OrderLine( - (string) ($line['sku'] ?? ''), - (int) ($line['quantity'] ?? 0), - (float) ($line['unitPrice'] ?? 0), - ), - $lines, - ), + array_values($lines), ); } /** - * A domain order as columns. The id is absent on purpose — it belongs to the row, and `place()` must not - * be able to choose it. + * A domain order as the ORDER table's columns. Its lines are not here: they are rows of their own, and + * writeLines() owns them. The id is absent on purpose too — it belongs to the row, and `place()` must + * not be able to choose it. * * @return array */ @@ -150,14 +202,6 @@ private function toRow(Order $order): array 'postcode' => $order->shipTo->postcode, 'country' => $order->shipTo->country, ], - 'lines' => array_map( - static fn (OrderLine $line): array => [ - 'sku' => $line->sku, - 'quantity' => $line->quantity, - 'unitPrice' => $line->unitPrice, - ], - $order->lines, - ), 'total' => $order->total(), ]; } diff --git a/skeleton/database/migrations/0001_01_01_000000_create_orders_table.php b/skeleton/database/migrations/0001_01_01_000000_create_orders_table.php index c8e9a1e..f65a056 100644 --- a/skeleton/database/migrations/0001_01_01_000000_create_orders_table.php +++ b/skeleton/database/migrations/0001_01_01_000000_create_orders_table.php @@ -13,11 +13,15 @@ * `POST /orders` works against the bundled sqlite file the moment the installer finishes. Delete this file * and app/Orders if you do not want the sample. * - * SHIPPING ADDRESS AND LINES ARE JSON COLUMNS. An order's lines are worth a table of their own the moment - * anything queries across them — "how many WIDGET-1 did we sell" is a join, not a JSON scan. They are one - * column here because the sample's job is to show the framework's repository layer, and a second table would - * add a relation to explain without adding anything to that story. Firefly\Data\Repository\EloquentRepository - * is an Eloquent repository, so `hasMany` works exactly as it always does when you are ready for it. + * TWO TABLES, AND THE SPLIT IS THE LESSON. `ship_to` stays a json column because an address is a VALUE — it + * has no identity of its own, nothing ever queries for one, and nothing else refers to it. Lines are + * ENTITIES: they have their own ids, "how many WIDGET-1 did we sell" is a query across them, and a JSON + * column would make that a scan. So the address is embedded and the lines get a table with a foreign key, + * which is the same call you make in Spring between an @Embeddable and an @Entity. + * + * It is also what makes the relation real. App\Orders\OrderEntity declares `hasMany(OrderLineEntity)` and + * the line declares `belongsTo(OrderEntity)`, so the admin dashboard's data browser can walk from an order + * to its lines and back — a feature a single-table sample could not have demonstrated at all. */ return new class extends Migration { @@ -28,19 +32,34 @@ public function up(): void $table->string('customer'); $table->string('email'); $table->json('ship_to'); - $table->json('lines'); // Derived from the lines by the domain, stored so the column can be sorted, summed and reported - // on without decoding JSON — the ordinary reason a derived value is also persisted. Nothing + // on without decoding a join — the ordinary reason a derived value is also persisted. Nothing // accepts it from a client: OrderService writes what Order::total() computed. $table->decimal('total', 12, 2)->default(0); $table->timestamps(); $table->index('email'); }); + + Schema::create('order_lines', function (Blueprint $table): void { + $table->id(); + // cascadeOnDelete so removing an order removes its lines in the DATABASE, not only in whichever + // code path happened to remember. OrderService deletes them explicitly too, inside the same + // transaction, because sqlite enforces foreign keys only when the pragma is on and an + // application should not depend on a setting to keep its own invariants. + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->string('sku'); + $table->unsignedInteger('quantity'); + $table->decimal('unit_price', 12, 2); + $table->timestamps(); + + $table->index('sku'); + }); } public function down(): void { + Schema::dropIfExists('order_lines'); Schema::dropIfExists('orders'); } }; diff --git a/skeleton/resources/views/welcome.blade.php b/skeleton/resources/views/welcome.blade.php index be89059..c7c74dd 100644 --- a/skeleton/resources/views/welcome.blade.php +++ b/skeleton/resources/views/welcome.blade.php @@ -24,7 +24,9 @@ --text:#2b2521; --text-2:#6b6259; - --text-3:#9a9086; + /* 4.8:1 on this page's cream, where #9a9086 was 3.1 — below AA for the captions, + the footer and the path labels it paints. */ + --text-3:#787066; --amber:#dc6b0c; --amber-2:#ffa53d; @@ -51,7 +53,7 @@ --text:#f3ede5; --text-2:#b5aa9d; - --text-3:#887d70; + --text-3:#948878; --amber:#ffab52; --amber-2:#ffbe72; diff --git a/skeleton/tests/Feature/OrderTest.php b/skeleton/tests/Feature/OrderTest.php index 671910a..e4d5467 100644 --- a/skeleton/tests/Feature/OrderTest.php +++ b/skeleton/tests/Feature/OrderTest.php @@ -18,10 +18,15 @@ * derived collection path, a validated request body with a nested DTO and a list of DTOs, declared 201/204 * statuses, and an RFC-7807 404 that no line of controller code produces. * - * The store is the `orders` table, reached through App\Orders\OrderRepository — which is an - * EloquentRepository with a model name and no method bodies. RefreshDatabase migrates the in-memory sqlite - * configured in phpunit.xml and rolls each test back, so every case starts empty and none depends on - * another's leftovers. + * The store is the `orders` and `order_lines` tables, reached through App\Orders\OrderRepository and + * OrderLineRepository — both EloquentRepositories with a model name and no method bodies. RefreshDatabase + * migrates the in-memory sqlite configured in phpunit.xml and rolls each test back, so every case starts + * empty and none depends on another's leftovers. + * + * TWO TABLES IS WHY THE WRITES ARE #[Transactional]. Placing an order is two statements and replacing one is + * three; a crash between them would leave an order with half its lines and a `total` matching neither. The + * cases below assert both tables after every write for that reason — a response that looked right while only + * one table was written is exactly the failure the annotation exists to prevent. * * THE PERSISTENCE ASSERTIONS BELOW ARE NOT DECORATION. An earlier version of this sample kept orders in an * array on a singleton repository, and this suite passed: Laravel reuses one application across the requests @@ -82,6 +87,11 @@ public function test_it_creates_an_order_with_a_nested_address_and_a_list_of_lin 'email' => 'ada@example.com', 'total' => 22.25, ]); + + // The lines went to their own table with a foreign key, which is what makes them queryable and what + // lets the admin dashboard walk from an order to them. + $this->assertDatabaseHas('order_lines', ['order_id' => $response->json('id'), 'sku' => 'GEAR-77', 'quantity' => 1]); + $this->assertDatabaseCount('order_lines', 2); } public function test_it_reads_lists_replaces_and_deletes_an_order(): void @@ -113,6 +123,36 @@ public function test_it_reads_lists_replaces_and_deletes_an_order(): void $this->deleteJson('/orders/'.$id)->assertNoContent(); $this->getJson('/orders/'.$id)->assertStatus(404); $this->assertDatabaseMissing('orders', ['id' => $id]); + + // And the lines went with it. A cancelled order that left its lines behind would leave rows nothing + // can reach and every `sum(unit_price)` wrong. + $this->assertDatabaseCount('order_lines', 0); + } + + /** + * Replacing an order replaces its lines outright. + * + * A PUT says nothing about which line is which, so matching the incoming lines to the stored ones would + * be inventing an identity the client never sent. Delete-and-reinsert is the honest reading, and it is + * only safe because #[Transactional] holds the window open — on its own it is a moment in which the + * order has no lines at all. + */ + public function test_replacing_an_order_replaces_its_lines(): void + { + $id = $this->postJson('/orders', $this->body())->json('id'); + $this->assertDatabaseCount('order_lines', 2); + + $this->putJson('/orders/'.$id, $this->body(['lines' => [['sku' => 'BOLT-9', 'quantity' => 3, 'unitPrice' => 2.0]]])) + ->assertOk() + ->assertJsonPath('lines.0.sku', 'BOLT-9') + ->assertJsonCount(1, 'lines') + // The total is recomputed from the NEW lines, never carried over. + // JSON has one number type, so an exact total encodes as `6` and a fractional one as `22.25`. + ->assertJsonPath('total', 6); + + $this->assertDatabaseCount('order_lines', 1); + $this->assertDatabaseHas('order_lines', ['order_id' => $id, 'sku' => 'BOLT-9', 'quantity' => 3]); + $this->assertDatabaseMissing('order_lines', ['sku' => 'WIDGET-1']); } /** @@ -128,11 +168,17 @@ public function test_an_order_is_written_to_the_database_and_not_to_process_memo { $id = $this->postJson('/orders', $this->body())->json('id'); - // The raw row. `lines` is a json column, so the store holds the payload, not a PHP object graph. + // The raw rows, read straight off the connection. An order is two tables, so this is also where the + // second write is proved to have happened. $row = DB::table('orders')->where('id', $id)->first(); $this->assertNotNull($row); $this->assertSame('ada@example.com', $row->email); - $this->assertCount(2, (array) json_decode((string) $row->lines, true)); + $this->assertCount(2, DB::table('order_lines')->where('order_id', $id)->get()); + + // `ship_to` stays a json column: an address is a VALUE with no identity, so it is embedded rather + // than given a table of its own. That split — value embedded, entity related — is the sample's + // whole point about modelling. + $this->assertSame('London', ((array) json_decode((string) $row->ship_to, true))['city']); // A repository built now, by hand, with no connection to the one that served the POST. $found = (new OrderRepository)->findById($id); From 6c4afd295947337db15b4f42ccae71b2f3dadeaf Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 19:27:24 -0700 Subject: [PATCH 23/31] feat(admin,web): full CRUD and real filtering in the explorer; overridable error pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERROR PAGES API PATHS ALWAYS ANSWER JSON. Accept-negotiation alone gets one common case wrong: a developer opens an API URL in a browser to see what it returns and is shown a styled page instead of the payload their client will receive — and so is anything that follows a link into the API carrying a copied browser header. `firefly.web.error-page.json-paths` defaults to `api/*` and is checked BEFORE the header, because the header says who is asking and the path says what the URL IS. AN APPLICATION CAN SUPPLY ITS OWN VIEW, per status or as a default, so a public 404 is the product's page while a 500 in staging is still the framework's diagnostic one. The override is handed the same ErrorReport the built-in page gets, so it is bound by the same `trace` gate — a custom view cannot print a stack trace the settings withheld, because the report it holds never gathered one. A view that THROWS falls back to the built-in page rather than propagating: this runs while the application is already failing, and an override is application code (a renamed layout, a component querying the database that is down). Tested against the real Blade compiler with a deliberately broken template, because only a real compile throws the way a drifted one does. THE DATA EXPLORER FILTERING is now eight comparisons — is, is not, contains, starts with, greater, less, empty, not empty — over the columns the resource already publishes, built by a filter bar and expressed as a GET URL an operator can bookmark or paste into a ticket. Every comparison BINDS, including the LIKE ones, where the wildcards go around an escaped value rather than the value going into a pattern. A column the schema does not publish and an operator outside the set are DROPPED rather than passed to the driver, so a hand-edited URL cannot probe for column names. Filters AND with each other and with the search, so narrowing a relation's listing cannot escape it — verified with a contradiction that returns zero rather than a union. The same eight comparisons are implemented for the in-PHP fallback path, because a repository that cannot page must be filtered by the same rules as one that can; two implementations would drift, and the drift would show as one filter meaning different things on different resources. PAGINATION gained a page-number window with first/last jumps and a rows-per-page control, and every link carries the sort, the search, the filters and the page size — a sort that dropped the filter would widen the listing back to every row, which reads as rows appearing from nowhere. CREATE completes the CRUD, and the refusal it replaces was half right. "A generic form cannot honour a constructor's invariants" is true for a repository over a hand-written domain object, and that case is still refused by name. It was never true for an Eloquent model, which is constructed empty and filled by attribute — exactly what update() has always done to a row that exists. Create was refusing on a risk update was already taking. Same two switches, same coercion, same unknown-field refusal; the identifier and masked columns are omitted from the form rather than shown and ignored, because a field the browser would refuse to write should not appear to accept. CELLS ARE NOW TYPED, in a partial rather than in each view: figures right-align with tabular numerals so digits line up down a column, booleans are a chip because two states read faster as a shape than as the word "false", null is a dim em-dash so absent is visibly different from empty, and every cell clips to one line with its full value on hover — a table whose row height depends on its longest json blob is not a table. 2044 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../admin/resources/views/_cell.blade.php | 38 ++++ .../admin/resources/views/data-list.blade.php | 192 ++++++++++++------ .../admin/resources/views/data-new.blade.php | 74 +++++++ .../admin/resources/views/layout.blade.php | 44 ++++ packages/admin/src/Data/DataBrowser.php | 127 +++++++++++- packages/admin/src/Data/DataFilter.php | 107 ++++++++-- packages/admin/src/Data/DataListing.php | 9 +- packages/admin/src/Data/DataQueryEngine.php | 89 ++++++-- packages/admin/src/Web/AdminAction.php | 114 +++++++++-- .../admin/tests/Data/DataBrowserWriteTest.php | 57 +++++- .../admin/tests/Data/DataRelationsTest.php | 12 +- packages/web/src/Error/ErrorPageRenderer.php | 40 +++- packages/web/src/Error/ErrorPageSettings.php | 77 +++++++ packages/web/src/WebServiceProvider.php | 7 +- .../web/tests/Error/ErrorPageOverrideTest.php | 78 +++++++ packages/web/tests/Error/ErrorPageTest.php | 15 ++ .../Fixtures/views/broken-error.blade.php | 3 + .../Fixtures/views/custom-error.blade.php | 3 + 18 files changed, 947 insertions(+), 139 deletions(-) create mode 100644 packages/admin/resources/views/_cell.blade.php create mode 100644 packages/admin/resources/views/data-new.blade.php create mode 100644 packages/web/tests/Error/ErrorPageOverrideTest.php create mode 100644 packages/web/tests/Fixtures/views/broken-error.blade.php create mode 100644 packages/web/tests/Fixtures/views/custom-error.blade.php diff --git a/packages/admin/resources/views/_cell.blade.php b/packages/admin/resources/views/_cell.blade.php new file mode 100644 index 0000000..7022199 --- /dev/null +++ b/packages/admin/resources/views/_cell.blade.php @@ -0,0 +1,38 @@ +{{-- + One table cell, typed. + + VALUES ARRIVE RAW. An Eloquent-backed row holds whatever the driver returned, so a bool column can be + int 1 and a json column a string. The COLUMN TYPE is the rendering hint and the value's PHP type is + never consulted — that distinction is what stops a listing rendering `0` as an empty cell on one driver + and `false` on another. + + Each type gets the treatment that makes a table readable rather than merely correct: numbers are + right-aligned with tabular figures so digits line up down the column, booleans are a chip because a + two-state value is faster to scan as a shape than as the word "false", null is a dim em-dash so an + absent value is visibly different from an empty string, and json is monospaced and clipped with its full + text on hover. +--}} +@php + use Firefly\Admin\Data\DataColumn; + + $type = $column->type; + $isNull = $value === null; + $text = match (true) { + $isNull => '—', + $type === DataColumn::TYPE_BOOL => ((int) $value) === 1 ? 'true' : 'false', + $type === DataColumn::TYPE_JSON => is_string($value) ? $value : (string) json_encode($value, JSON_UNESCAPED_SLASHES), + is_scalar($value) => (string) $value, + default => (string) json_encode($value, JSON_UNESCAPED_SLASHES), + }; +@endphp +
    + @if ($isNull) + + @elseif ($type === DataColumn::TYPE_BOOL) + {{ $text }} + @elseif ($column->identifier) + {{ $text }} + @else + {{ $text }} + @endif +
    +
    @foreach ($columns as $column) @@ -98,10 +150,10 @@ $isSorted = $listing->sort === $column->name; $next = $isSorted && $listing->direction === 'asc' ? 'desc' : 'asc'; @endphp - @foreach ($columns as $column) - + @include('firefly-admin::_cell', ['value' => $row[$column->name] ?? null, 'column' => $column, 'base' => $base]) @endforeach @if ($identifier !== null)
    + @if ($sortable) - - {{ $column->label() }}@if ($isSorted) {{ $listing->direction === 'asc' ? '↑' : '↓' }}@endif + + {{ $column->label() }}{{ $isSorted ? ($listing->direction === 'asc' ? '↑' : '↓') : '' }} @else {{ $column->label() }} @@ -115,9 +167,7 @@ @foreach ($listing->rows as $row)
    - {{ $render($row[$column->name] ?? null, $column) }} - @@ -133,28 +183,56 @@
    - @if ($listing->totalPages() > 1) -
    - Page {{ $listing->page }} of {{ $listing->totalPages() }} - - @php - $keep = ($listing->sort !== null ? '&sort='.urlencode($listing->sort).'&dir='.$listing->direction : '') - .($listing->search !== null ? '&q='.urlencode($listing->search) : '') - .$keepFilter; - @endphp - @if ($listing->hasPrevious()) - Previous + @php + $keep = $keepSort.$keepSearch.$keepFilter.$keepSize; + $last = $listing->totalPages(); + // A window around the current page. Rendering every page of a 400-page table is a + // pagination control nobody can use, and the ends are kept because "first" and "last" are + // the two jumps people actually make. + $window = range(max(1, $listing->page - 2), min($last, $listing->page + 2)); + @endphp +
    +
    + + @if ($listing->sort)@endif + + @if ($listing->search !== null)@endif + @foreach ($listing->filters as $filter) + + + + @endforeach + +
    + + @if ($last > 1) + hasPrevious()) href="{{ $base }}&page={{ $listing->page - 1 }}{{ $keep }}" @endif>Previous + @if ($window[0] > 1) + 1 + @if ($window[0] > 2)@endif @endif - @if ($listing->hasNext()) - Next + @foreach ($window as $n) + {{ $n }} + @endforeach + @if (end($window) < $last) + @if (end($window) < $last - 1)@endif + {{ $last }} @endif -
    - @endif + hasNext()) href="{{ $base }}&page={{ $listing->page + 1 }}{{ $keep }}" @endif>Next + @else + Showing all {{ number_format($listing->total) }} + @endif +
    @endif
    @endif - - @unless ($writable) -

    Read-only. Set firefly.admin.data.writable to allow edits and deletes.

    - @endunless @endsection diff --git a/packages/admin/resources/views/data-new.blade.php b/packages/admin/resources/views/data-new.blade.php new file mode 100644 index 0000000..fbc4436 --- /dev/null +++ b/packages/admin/resources/views/data-new.blade.php @@ -0,0 +1,74 @@ +@extends('firefly-admin::layout') +@section('title', 'New record') +@section('body') + @php + use Firefly\Admin\Format; + $base = $settings->url('data').'?resource='.urlencode($resource->slug); + @endphp + +
    +

    New {{ strtolower($resource->label) }}

    +

    + @if ($resource->entityClass !== null){{ Format::shortClass($resource->entityClass) }} · @endif + back to {{ strtolower($resource->label) }} +

    +
    + + @if (session('data-message')) +

    {{ session('data-message') }}

    + @endif + + @if (! $writable) +
    + @include('firefly-admin::_empty', [ + 'title' => 'The browser is read-only', + 'body' => 'Set firefly.admin.data.writable to permit writes. It is a separate key from + firefly.admin.data.enabled on purpose: switching the browser on never + silently makes it writable.', + ]) +
    + @elseif (! $resource->isEloquentBacked()) +
    + @include('firefly-admin::_empty', [ + 'title' => 'This resource cannot be created from here', + 'body' => 'Its entity is not an Eloquent model, and a generic form cannot honour an arbitrary + constructor’s invariants — a required value the form does not know about, an + argument order it cannot guess. Records for it belong to your own use cases. Editing + an existing one is refused for the same reason.', + ]) +
    + @else +
    + @include('firefly-admin::_panel-head', [ + 'title' => 'Fields', + 'count' => count(array_filter($schema->columns, fn ($c) => $c->isEditable())), + ]) +
    + @csrf + + + + @foreach ($schema->columns as $column) + @continue (! $column->isEditable()) + + @endforeach + +
    + + Cancel +
    +
    + {{-- The identifier and any masked column are absent from the form, not disabled in it: a field + the browser would refuse to write is a field it should not appear to accept. --}} +

    + The identifier is assigned by the database, and masked columns are never written from here — + both are omitted rather than shown and ignored. +

    +
    + @endif +@endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 4134eef..168df82 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -317,6 +317,50 @@ /* A settings row is a bag of small key/value facts, not a table of its own — a nested table for `charset: utf8mb4` would be four times the markup to say the same thing, and would not wrap. */ .pair{display:inline-flex;align-items:baseline;gap:5px;margin:0 8px 4px 0;font-family:var(--mono);font-size:11.5px;white-space:nowrap} + + /* ── The data grid ───────────────────────────────────────────────────────────────────────────── + A table is read DOWN a column, not across a row, so the type decides the alignment: figures are + right-aligned with tabular numerals so digits line up and a long number is visibly long, and text + stays left. Everything is clipped to one line with the full value on the title, because a table + whose row height depends on its longest json blob is not a table. + */ + table.grid td.cell{max-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px} + table.grid td.cell .v,table.grid td.cell .idv{overflow:hidden;text-overflow:ellipsis;display:block} + table.grid td.t-int,table.grid td.t-float,table.grid th.t-int,table.grid th.t-float{text-align:right;font-variant-numeric:tabular-nums} + table.grid td.t-json{color:var(--ink-2)} + table.grid td.t-datetime{color:var(--ink-2);white-space:nowrap} + table.grid td.nil{text-align:center} + table.grid td.secret .v{color:var(--ink-3);letter-spacing:.08em} + .nul{color:var(--ink-3)} + .idv{font-weight:650} + th .ord{display:inline-block;width:10px;color:var(--brand)} + + /* Two states read faster as a shape than as a word. */ + .bool{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600} + .bool::before{content:"";width:7px;height:7px;border-radius:2px;background:currentColor} + .bool.yes{color:var(--up)} + .bool.no{color:var(--ink-3)} + + /* ── The filter bar ── */ + details.filters>summary{display:flex;align-items:center;gap:10px;padding:12px 16px;cursor:pointer; + font-size:13px;font-weight:650;background:var(--panel-2);border-bottom:1px solid var(--line);list-style:none} + details.filters>summary::-webkit-details-marker{display:none} + details.filters>summary::before{content:"▸";color:var(--ink-3);font-size:10px} + details.filters[open]>summary::before{content:"▾"} + .filterform{padding:14px 16px;display:flex;flex-direction:column;gap:8px} + .frow{display:flex;gap:8px;flex-wrap:wrap} + .frow select,.frow input{font:inherit;font-size:12.5px;padding:5px 8px;border:1px solid var(--line-2); + border-radius:7px;background:var(--panel);color:var(--ink);min-width:0} + .frow select{flex:0 1 190px} + .frow input{flex:1 1 190px} + .filterform .actions{display:flex;gap:8px;align-items:center} + form.inline{display:flex;gap:6px;align-items:center;margin:0} + + .pager .act.on{border-color:var(--brand);color:var(--brand);font-weight:650} + .pager .act.off{opacity:.45;pointer-events:none} + .pager .gap{color:var(--ink-3);padding:0 2px} + .sizer{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-2)} + .sizer select{font:inherit;font-size:12px;padding:3px 6px;border:1px solid var(--line-2);border-radius:6px;background:var(--panel);color:var(--ink)} .pair b{font-weight:600;color:var(--ink-3)} .pair.opt b{color:var(--accent)} .stat dd.sm{font-size:14px;word-break:break-all} diff --git a/packages/admin/src/Data/DataBrowser.php b/packages/admin/src/Data/DataBrowser.php index 9df7bd6..c809a0e 100644 --- a/packages/admin/src/Data/DataBrowser.php +++ b/packages/admin/src/Data/DataBrowser.php @@ -131,6 +131,8 @@ public function schema(string $slug): ?DataSchema * `$perPage` is null to mean "the configured default" and is clamped to `firefly.admin.data.max-page-size` * in every case, so a caller-supplied page size can never ask the fallback path to materialise a table. * `$page` is 1-based and floored at 1. + * + * @param list $filters */ public function list( string $slug, @@ -139,7 +141,7 @@ public function list( ?string $sort = null, string $direction = 'asc', ?string $search = null, - ?DataFilter $filter = null, + array $filters = [], ): DataListing { $perPage = $this->settings->clampPageSize($perPage); $page = max(1, $page); @@ -159,14 +161,31 @@ public function list( return DataListing::failure(self::UNRESOLVABLE, $resource, $schema, $page, $perPage); } - // A filter naming a column the resource does not have is DROPPED rather than passed to the - // database. The column arrives in a URL an operator can hand-edit, and a query that reached the - // driver with an arbitrary identifier in it is a column-name oracle at best. - if ($filter !== null && ! in_array($filter->column, array_map(static fn (DataColumn $c): string => $c->name, $schema->columns), true)) { - $filter = null; - } + return $this->engine->list($repository, $resource, $schema, $page, $perPage, $sort, $direction, $search, $this->validFilters($filters, $schema)); + } + + /** + * Filters the resource can actually answer, with everything else dropped. + * + * A filter naming a column the resource does not have is DROPPED rather than passed to the database, and + * so is one naming an operator that is not in the fixed set. Both arrive in a URL an operator can + * hand-edit, and a query that reached the driver with an arbitrary identifier or comparison in it is a + * column-name oracle at best. Dropping rather than erroring is deliberate too: an error message that + * distinguished "no such column" from "no rows" would answer the same question more slowly. + * + * @param list $filters + * @return list + */ + private function validFilters(array $filters, DataSchema $schema): array + { + $columns = array_map(static fn (DataColumn $column): string => $column->name, $schema->columns); - return $this->engine->list($repository, $resource, $schema, $page, $perPage, $sort, $direction, $search, $filter); + return array_values(array_filter( + $filters, + static fn (DataFilter $filter): bool => in_array($filter->column, $columns, true) + && DataFilter::isOperator($filter->operator) + && ($filter->value !== '' || ! $filter->needsValue()), + )); } /** @@ -381,6 +400,96 @@ public function update(string $slug, int|string $id, array $fields): DataWriteRe return $this->applyUpdate($repository, $entity, $schema, $slug, $id, $fields); } + /** + * Insert a new record. + * + * WHY THIS EXISTS NOW, having been deliberately absent. The original argument was that a generic form + * cannot honour an entity's constructor invariants — true, and it still is for a repository over a + * hand-written domain object, which is why that case is still refused by name. It was never true for an + * ELOQUENT model: Eloquent constructs one empty and fills it by attribute, which is exactly what + * `update()` already does to a row that exists. Create was therefore refusing on a risk that update was + * already taking, and the inconsistency cost every application a CRUD surface that stopped at RUD. + * + * The same gate, the same coercion and the same unknown-field refusal apply. A column the schema calls + * uneditable — the identifier, a masked secret — is skipped exactly as it is on update, so a crafted + * POST cannot choose a primary key or write a value the page would only ever show as `******`. + * + * @param array $fields + */ + public function create(string $slug, array $fields): DataWriteResult + { + $refusal = $this->refuseWrite($slug, null); + if ($refusal !== null) { + return $refusal; + } + + $resource = $this->registry->get($slug); + if ($resource === null) { + return DataWriteResult::notFound('No such resource.', $slug); + } + + $schema = $this->schemas->for($resource); + + $unknown = array_values(array_filter( + array_keys($fields), + static fn (int|string $name): bool => ! $schema->has((string) $name), + )); + if ($unknown !== []) { + return DataWriteResult::refused( + sprintf('%d submitted field(s) are not columns of this resource.', count($unknown)), + $slug, + ); + } + + $repository = $this->repositoryFor($resource); + if ($repository === null) { + return DataWriteResult::failed(self::UNRESOLVABLE, $slug); + } + + $model = $resource->entityClass; + if (! $resource->isEloquentBacked() || $model === null || ! is_a($model, Model::class, true)) { + return DataWriteResult::refused( + 'This resource is not backed by an Eloquent model. A generic form cannot honour an arbitrary ' + .'entity\'s constructor invariants, so records for it must be created through your own use cases.', + $slug, + ); + } + + $entity = new $model; + + foreach ($fields as $name => $value) { + $column = $schema->column((string) $name); + if ($column === null || ! $column->isEditable()) { + continue; + } + + $coerced = $this->coerce($entity, $column, $value); + if ($coerced === false) { + return DataWriteResult::refused( + sprintf('The value for `%s` is not a valid %s.', $column->name, $column->type), + $slug, + ); + } + + $entity->setAttribute($column->name, $coerced[0]); + } + + try { + $saved = $repository->save($entity); + } catch (Throwable $e) { + return DataWriteResult::failed($this->engine->safeReason('The insert failed', $e), $slug); + } + + $id = $saved instanceof Model ? $saved->getKey() : null; + + return DataWriteResult::done( + 'Created.', + $slug, + is_int($id) || is_string($id) ? $id : null, + array_keys($entity->getAttributes()), + ); + } + /** * @param CrudRepository $repository * @param array $fields @@ -508,7 +617,7 @@ private function coerceJson(Model $entity, DataColumn $column, string $value): a * browser on and forgot the second key needs to be told which key, and an operator who never turned the * browser on at all should not be told that a write key exists. */ - private function refuseWrite(string $slug, int|string $id): ?DataWriteResult + private function refuseWrite(string $slug, int|string|null $id): ?DataWriteResult { if (! $this->settings->enabled) { return DataWriteResult::refused(self::DISABLED, $slug, $id); diff --git a/packages/admin/src/Data/DataFilter.php b/packages/admin/src/Data/DataFilter.php index 677b508..ca68a90 100644 --- a/packages/admin/src/Data/DataFilter.php +++ b/packages/admin/src/Data/DataFilter.php @@ -5,28 +5,109 @@ namespace Firefly\Admin\Data; /** - * A single equality constraint on a listing — "the lines whose order_id is 7". + * One constraint on a listing: a column, a comparison, and a value. * - * IT IS NOT A GENERAL QUERY LANGUAGE, on purpose. The only filter the browser accepts is one column equal to - * one value, because the only filter it needs to OFFER is the one a relation implies: every "show me the - * children of this row" link is exactly that shape. A richer filter builder in a URL an operator can edit is - * a query surface, and a query surface over arbitrary columns is a very different security review from a - * paginated read. + * IT IS A FIXED SET OF COMPARISONS OVER A VALIDATED COLUMN, not a query language. The column is checked + * against the resource's schema before anything reaches a driver — an unknown one is dropped rather than + * passed through, so a hand-edited URL cannot probe for column names — and the value is always a bound + * parameter. What an operator can express is therefore exactly what the eight operators below allow, over + * exactly the columns the resource already publishes, which is the same surface the search box has had all + * along, made precise. * - * The column is validated against the schema by the caller before it reaches a query — a filter naming a - * column the resource does not have is dropped rather than passed to the database, so a hand-edited URL - * cannot probe for column names. + * THE SHORT FORM EXISTS FOR RELATIONS. `fk`/`fv` in a URL is one equality and is what every "children of + * this row" link produces; the indexed form (`fc[]`/`fo[]`/`fv[]`) is what the filter bar builds. Keeping + * both means a relation link stays short and readable while the UI is not limited to a single condition. */ final readonly class DataFilter { + public const string EQ = 'eq'; + + public const string NE = 'ne'; + + public const string CONTAINS = 'contains'; + + public const string STARTS = 'starts'; + + public const string GT = 'gt'; + + public const string LT = 'lt'; + + public const string NULL = 'null'; + + public const string NOT_NULL = 'notnull'; + public function __construct( public string $column, - public string $value, + public string $operator = self::EQ, + public string $value = '', ) {} - /** The query-string form, so a listing link and a "clear" link are built from one place. */ - public function toQuery(): string + /** + * The operators, id => the label a person picks from. + * + * @return array + */ + public static function operators(): array + { + return [ + self::EQ => 'is', + self::NE => 'is not', + self::CONTAINS => 'contains', + self::STARTS => 'starts with', + self::GT => 'greater than', + self::LT => 'less than', + self::NULL => 'is empty', + self::NOT_NULL => 'is not empty', + ]; + } + + public static function isOperator(string $operator): bool + { + return array_key_exists($operator, self::operators()); + } + + /** Whether this comparison uses the value at all — `is empty` does not. */ + public function needsValue(): bool + { + return $this->operator !== self::NULL && $this->operator !== self::NOT_NULL; + } + + public function label(): string + { + return self::operators()[$this->operator] ?? $this->operator; + } + + /** + * The query-string form of a list of filters, so every link that must preserve them is built from one + * place. A single equality keeps the short `fk`/`fv` spelling that relation links use. + * + * @param list $filters + */ + public static function toQuery(array $filters): string + { + if ($filters === []) { + return ''; + } + + if (count($filters) === 1 && $filters[0]->operator === self::EQ) { + return 'fk='.urlencode($filters[0]->column).'&fv='.urlencode($filters[0]->value); + } + + $parts = []; + foreach ($filters as $filter) { + $parts[] = 'fc[]='.urlencode($filter->column) + .'&fo[]='.urlencode($filter->operator) + .'&fv[]='.urlencode($filter->value); + } + + return implode('&', $parts); + } + + /** A one-line description of what this filter narrows to, for the banner above a filtered listing. */ + public function describe(): string { - return 'fk='.urlencode($this->column).'&fv='.urlencode($this->value); + return $this->needsValue() + ? $this->column.' '.$this->label().' '.$this->value + : $this->column.' '.$this->label(); } } diff --git a/packages/admin/src/Data/DataListing.php b/packages/admin/src/Data/DataListing.php index 2ade9a9..f521821 100644 --- a/packages/admin/src/Data/DataListing.php +++ b/packages/admin/src/Data/DataListing.php @@ -25,6 +25,7 @@ { /** * @param list> $rows each row keyed by column name, in schema column order + * @param list $filters */ public function __construct( public ?DataResource $resource, @@ -37,9 +38,15 @@ public function __construct( public string $direction = 'asc', public ?string $search = null, public ?string $error = null, - public ?DataFilter $filter = null, + public array $filters = [], ) {} + /** The query-string form of this listing's filters, for every link that must preserve them. */ + public function filterQuery(): string + { + return DataFilter::toQuery($this->filters); + } + /** * The empty-with-a-reason constructor every refusal and every caught failure goes through. */ diff --git a/packages/admin/src/Data/DataQueryEngine.php b/packages/admin/src/Data/DataQueryEngine.php index b391642..d6fa9a1 100644 --- a/packages/admin/src/Data/DataQueryEngine.php +++ b/packages/admin/src/Data/DataQueryEngine.php @@ -96,6 +96,7 @@ public function __construct(private readonly RepositoryIntrospector $introspecto * home, in DataBrowser, shared with the write path. * * @param CrudRepository $repository + * @param list $filters */ public function list( CrudRepository $repository, @@ -106,7 +107,7 @@ public function list( ?string $sort, string $direction, ?string $search, - ?DataFilter $filter = null, + array $filters = [], ): DataListing { $sort = $this->sortColumn($schema, $sort); $direction = strtolower($direction) === 'desc' ? 'desc' : 'asc'; @@ -116,7 +117,7 @@ public function list( // and stringifies whatever it finds, which is not obviously fallible until a model's accessor or a // value object's __toString throws — and a half-rendered page is exactly as broken as a failed query. try { - [$entities, $total] = $this->fetch($repository, $schema, $page, $perPage, $sort, $direction, $term, $filter); + [$entities, $total] = $this->fetch($repository, $schema, $page, $perPage, $sort, $direction, $term, $filters); $rows = []; foreach ($entities as $entity) { @@ -126,7 +127,7 @@ public function list( return DataListing::failure($this->safeReason('The listing query failed', $e), $resource, $schema, $page, $perPage); } - return new DataListing($resource, $schema, $rows, $total, $page, $perPage, $sort, $direction, $term, null, $filter); + return new DataListing($resource, $schema, $rows, $total, $page, $perPage, $sort, $direction, $term, null, $filters); } /** @@ -160,6 +161,7 @@ public function find(CrudRepository $repository, DataResource $resource, DataSch * Pick the page of entities and the grand total, by whichever of the four paths this repository supports. * * @param CrudRepository $repository + * @param list $filters * @return array{0: list, 1: int} */ private function fetch( @@ -170,11 +172,11 @@ private function fetch( ?string $sort, string $direction, ?string $term, - ?DataFilter $filter = null, + array $filters = [], ): array { $pageable = new Pageable($page, $perPage, $this->sort($sort, $direction)); - if (($term !== null || $filter !== null) && $repository instanceof EloquentRepository) { + if (($term !== null || $filters !== []) && $repository instanceof EloquentRepository) { $specifications = []; if ($term !== null) { @@ -185,43 +187,60 @@ private function fetch( $specifications[] = $this->searchSpecification($columns, $term); } - if ($filter !== null) { + foreach ($filters as $filter) { $specifications[] = $this->filterSpecification($filter); } - // AND, so a search inside a relation's listing narrows that relation rather than escaping it — - // the same reasoning that keeps the search's OR group nested. + // AND throughout, so a search inside a relation's listing narrows that relation rather than + // escaping it, and a second filter narrows the first — the same reasoning that keeps the + // search's OR group nested. /** @var Page $result */ $result = $repository->findBySpecificationPaged(Specifications::allOf(...$specifications), $pageable); return [$result->items, $result->total]; } - if ($term === null && $filter === null && $repository instanceof PagingAndSortingRepository) { + if ($term === null && $filters === [] && $repository instanceof PagingAndSortingRepository) { /** @var Page $result */ $result = $repository->findPaged($pageable); return [$result->items, $result->total]; } - return $this->fetchInPhp($repository, $schema, $page, $perPage, $sort, $direction, $term, $filter); + return $this->fetchInPhp($repository, $schema, $page, $perPage, $sort, $direction, $term, $filters); } /** - * `column = value`, applied through the repository's own builder so anything its `query()` seam already + * One filter as a predicate on the repository's own builder, so anything its `query()` seam already * constrained still holds. * - * The comparison is a LOOSE string one, because the value arrives from a URL and is therefore always a - * string while the column may be an integer key. Binding it as-is lets the database do the coercion it - * would do for `where id = '7'` anyway, and keeps the value a bound parameter rather than anything - * concatenated. + * EVERY COMPARISON BINDS. The column has already been validated against the schema by the caller, and + * the value is passed as a parameter in every branch — including the LIKE ones, where the wildcards are + * added around an escaped value rather than by interpolating the value into a pattern. The result is + * that what an operator can express is exactly these eight comparisons over exactly the columns the + * resource publishes, and nothing about a hand-edited URL widens either. + * + * The comparisons are LOOSE on type, because a value arrives from a URL and is therefore always a string + * while the column may be an integer or a decimal. Binding it as-is lets the database do the coercion it + * would do for `where id = '7'` anyway. * * @return Specification */ private function filterSpecification(DataFilter $filter): Specification { return Specifications::where(static function (Builder $query) use ($filter): void { - $query->where($filter->column, '=', $filter->value); + $escaped = addcslashes($filter->value, '%_\\'); + + match ($filter->operator) { + DataFilter::NE => $query->where($filter->column, '!=', $filter->value), + DataFilter::CONTAINS => $query->where($filter->column, 'like', '%'.$escaped.'%'), + DataFilter::STARTS => $query->where($filter->column, 'like', $escaped.'%'), + DataFilter::GT => $query->where($filter->column, '>', $filter->value), + DataFilter::LT => $query->where($filter->column, '<', $filter->value), + DataFilter::NULL => $query->whereNull($filter->column), + DataFilter::NOT_NULL => $query->whereNotNull($filter->column), + default => $query->where($filter->column, '=', $filter->value), + }; }); } @@ -231,6 +250,7 @@ private function filterSpecification(DataFilter $filter): Specification * on the first page. * * @param CrudRepository $repository + * @param list $filters * @return array{0: list, 1: int} */ private function fetchInPhp( @@ -241,7 +261,7 @@ private function fetchInPhp( ?string $sort, string $direction, ?string $term, - ?DataFilter $filter = null, + array $filters = [], ): array { $needle = $term === null ? null : mb_strtolower($term); $columns = $this->searchColumns($schema); @@ -254,9 +274,10 @@ private function fetchInPhp( continue; } - // Loose, for the same reason the SQL path binds a string: the value came from a URL and the - // column is as likely to be an int key as a string. - if ($filter !== null && ! $this->equals($values[$filter->column] ?? null, $filter->value)) { + // The same eight comparisons the SQL path applies, so a repository that cannot page is filtered + // by the same rules as one that can — two implementations of one predicate would drift, and the + // drift would show as the same filter meaning different things on different resources. + if (! $this->passes($values, $filters)) { continue; } @@ -300,9 +321,33 @@ private function searchSpecification(array $columns, string $term): Specificatio }); } - private function equals(mixed $value, string $expected): bool + /** + * @param array $values + * @param list $filters + */ + private function passes(array $values, array $filters): bool { - return is_scalar($value) && (string) $value === $expected; + foreach ($filters as $filter) { + $value = $values[$filter->column] ?? null; + $string = is_scalar($value) ? (string) $value : null; + + $ok = match ($filter->operator) { + DataFilter::NE => $string !== $filter->value, + DataFilter::CONTAINS => $string !== null && str_contains(mb_strtolower($string), mb_strtolower($filter->value)), + DataFilter::STARTS => $string !== null && str_starts_with(mb_strtolower($string), mb_strtolower($filter->value)), + DataFilter::GT => $string !== null && $this->compare($value, $filter->value) > 0, + DataFilter::LT => $string !== null && $this->compare($value, $filter->value) < 0, + DataFilter::NULL => $value === null, + DataFilter::NOT_NULL => $value !== null, + default => $string === $filter->value, + }; + + if (! $ok) { + return false; + } + } + + return true; } /** diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index c2fdb93..81fd149 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -134,14 +134,30 @@ private function dataWrite(Request $request): SymfonyResponse } $slug = $request->input('resource'); - $id = $request->input('id'); - - if (! is_string($slug) || $slug === '' || ! is_string($id) || $id === '') { - return $this->html($this->render('data-missing', ['slug' => is_string($slug) ? $slug : '']), 400); + if (! is_string($slug) || $slug === '') { + return $this->html($this->render('data-missing', ['slug' => '']), 400); } $back = $this->settings->url('data').'?resource='.urlencode($slug); + // A create has no id yet — that is the whole difference — so it is dispatched before the id check + // the other two operations need. + if ($request->input('op') === 'create') { + /** @var array $new */ + $new = is_array($request->input('f')) ? $request->input('f') : []; + $result = $this->data->create($slug, $new); + + return $this->redirect( + $result->isDone() && $result->id !== null ? $back.'&id='.urlencode((string) $result->id) : $back.'&new=1', + $result->reason, + ); + } + + $id = $request->input('id'); + if (! is_string($id) || $id === '') { + return $this->html($this->render('data-missing', ['slug' => $slug]), 400); + } + if ($request->input('op') === 'delete') { $result = $this->data->delete($slug, $id); @@ -195,6 +211,19 @@ private function dataPage(Request $request): SymfonyResponse return $this->html($this->render('data-index', ['resources' => $this->data->resources()]), 200); } + if ($request->query('new') !== null) { + $resource = $this->data->resource($slug); + $schema = $this->data->schema($slug); + + return $resource === null || $schema === null + ? $this->html($this->render('data-missing', ['slug' => $slug]), 404) + : $this->html($this->render('data-new', [ + 'resource' => $resource, + 'schema' => $schema, + 'writable' => $this->data->isWritable(), + ]), 200); + } + $id = $request->query('id'); if (is_string($id) && $id !== '') { $record = $this->data->find($slug, $id); @@ -213,29 +242,74 @@ private function dataPage(Request $request): SymfonyResponse $direction = $request->query('dir') === 'desc' ? 'desc' : 'asc'; $search = $request->query('q'); - // `fk`/`fv` is how a relation link narrows a listing: "the lines whose order_id is 7". DataBrowser - // drops a column the schema does not have, so a hand-edited pair cannot reach the driver. - $column = $request->query('fk'); - $value = $request->query('fv'); - $filter = is_string($column) && $column !== '' && is_string($value) && $value !== '' - ? new DataFilter($column, $value) - : null; + $perPage = $request->query('size'); + $filters = $this->filters($request); + + $listing = $this->data->list( + $slug, + max(1, $page), + is_string($perPage) && ctype_digit($perPage) ? (int) $perPage : null, + is_string($sort) && $sort !== '' ? $sort : null, + $direction, + is_string($search) && $search !== '' ? $search : null, + $filters, + ); return $this->html($this->render('data-list', [ - 'listing' => $this->data->list( - $slug, - max(1, $page), - null, - is_string($sort) && $sort !== '' ? $sort : null, - $direction, - is_string($search) && $search !== '' ? $search : null, - $filter, - ), + 'listing' => $listing, 'writable' => $this->data->isWritable(), 'relations' => $this->data->relationsFor($slug), + 'operators' => DataFilter::operators(), ]), 200); } + /** + * The filters a listing URL carries, in either spelling. + * + * TWO SPELLINGS, ONE MEANING. `fk`/`fv` is a single equality and is what every relation link produces — + * short enough to read in a status bar. `fc[]`/`fo[]`/`fv[]` is what the filter bar builds, and carries a + * column, an operator and a value per condition. Both are validated identically downstream: DataBrowser + * drops any column the schema does not publish and any operator outside the fixed set, so neither + * spelling is a wider surface than the other. + * + * @return list + */ + private function filters(Request $request): array + { + $columns = $request->query('fc'); + $operators = $request->query('fo'); + $values = $request->query('fv'); + + if (is_array($columns)) { + $operators = is_array($operators) ? $operators : []; + $values = is_array($values) ? $values : []; + + $filters = []; + foreach (array_values($columns) as $index => $column) { + if (! is_string($column) || $column === '') { + continue; + } + + $operator = $operators[$index] ?? DataFilter::EQ; + $value = $values[$index] ?? ''; + + $filters[] = new DataFilter( + $column, + is_string($operator) ? $operator : DataFilter::EQ, + is_string($value) ? $value : '', + ); + } + + return $filters; + } + + $short = $request->query('fk'); + + return is_string($short) && $short !== '' && is_string($values) && $values !== '' + ? [new DataFilter($short, DataFilter::EQ, $values)] + : []; + } + /** @return array */ private function data(string $slug): array { diff --git a/packages/admin/tests/Data/DataBrowserWriteTest.php b/packages/admin/tests/Data/DataBrowserWriteTest.php index a57c3e3..cd4a663 100644 --- a/packages/admin/tests/Data/DataBrowserWriteTest.php +++ b/packages/admin/tests/Data/DataBrowserWriteTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Firefly\Admin\Data\DataBrowser; use Firefly\Admin\Data\DataWriteOutcome; use Firefly\Admin\Tests\Data\Support\DataBrowserTestCase; use Illuminate\Support\Facades\DB; @@ -210,12 +209,52 @@ function writableData(): array ->and(DB::table('admin_records')->where('id', 1)->value('email'))->toBe('ada@example.test'); }); -// Not a formality: `create()` is refused on principle (a generic form cannot honour a constructor's -// invariants — see DataBrowser's class docblock), and this is the guard that keeps a future edit from -// quietly adding one back. -it('offers no create operation at all', function () { - expect(get_class_methods(DataBrowser::class)) - ->not->toContain('create') - ->not->toContain('insert') - ->not->toContain('store'); +it('creates a record on an Eloquent-backed resource, under the same two switches', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browser(['enabled' => true, 'writable' => true]); + + $result = $browser->create('admin-record', ['email' => 'new@example.test', 'amount' => '75', 'active' => '1']); + + expect($result->isDone())->toBeTrue() + ->and(DB::table('admin_records')->where('email', 'new@example.test')->value('amount'))->toBe(75); +}); + +it('refuses create for a resource whose entity is not an Eloquent model', function () { + /** @var DataBrowserTestCase $this */ + // THIS is the invariant the old blanket ban was protecting, and it is the only half of it that was ever + // true. A generic form cannot honour an arbitrary constructor — PlainNote takes a protected id and three + // promoted parameters — so a record for it must come from the application's own use cases. Eloquent is + // the opposite case: it builds one empty and fills it by attribute, which is exactly what update() has + // always done to a row that exists, so create was refusing on a risk update was already taking. + $result = $this->browser(['enabled' => true, 'writable' => true])->create('plain-note', ['title' => 'nope']); + + expect($result->isDone())->toBeFalse() + ->and($result->reason)->toContain('not backed by an Eloquent model') + ->and(DB::table('admin_notes')->where('title', 'nope')->count())->toBe(0); +}); + +it('will not create while the browser is read-only or switched off', function () { + /** @var DataBrowserTestCase $this */ + $readOnly = $this->browser(['enabled' => true, 'writable' => false]); + $off = $this->browser(['enabled' => false, 'writable' => true]); + + expect($readOnly->create('admin-record', ['email' => 'sneak@example.test'])->isDone())->toBeFalse() + ->and($off->create('admin-record', ['email' => 'sneak@example.test'])->isDone())->toBeFalse() + ->and(DB::table('admin_records')->where('email', 'sneak@example.test')->count())->toBe(0); +}); + +it('refuses a create naming a column the resource does not have, and skips the ones it may not set', function () { + /** @var DataBrowserTestCase $this */ + $browser = $this->browser(['enabled' => true, 'writable' => true]); + + expect($browser->create('admin-record', ['email' => 'x@example.test', 'not_a_column' => '1'])->isDone())->toBeFalse(); + + // The identifier and the masked secret are uneditable, so a crafted POST cannot choose a primary key or + // write a value the page would only ever show as ******. They are SKIPPED rather than refused, exactly + // as on update, so a form that round-trips a whole row still works. + $result = $browser->create('admin-record', ['id' => '999', 'email' => 'chosen@example.test', 'api_token' => 'sk_live_planted', 'amount' => '1']); + + expect($result->isDone())->toBeTrue() + ->and(DB::table('admin_records')->where('id', 999)->count())->toBe(0) + ->and(DB::table('admin_records')->where('email', 'chosen@example.test')->value('api_token'))->toBeNull(); }); diff --git a/packages/admin/tests/Data/DataRelationsTest.php b/packages/admin/tests/Data/DataRelationsTest.php index 1c0c7e0..a7a084c 100644 --- a/packages/admin/tests/Data/DataRelationsTest.php +++ b/packages/admin/tests/Data/DataRelationsTest.php @@ -75,11 +75,11 @@ $this->seedEntries(); $all = $this->relatedBrowser()->list('admin-entry'); - $mine = $this->relatedBrowser()->list('admin-entry', filter: new DataFilter('record_id', '1')); + $mine = $this->relatedBrowser()->list('admin-entry', filters: [new DataFilter('record_id', DataFilter::EQ, '1')]); expect($all->total)->toBe(3) ->and($mine->total)->toBe(2) - ->and($mine->filter?->column)->toBe('record_id') + ->and($mine->filters[0]->column)->toBe('record_id') ->and(array_column($mine->rows, 'note'))->toBe(['first for ada', 'second for ada']); }); @@ -91,12 +91,12 @@ // Searching inside a relation's listing must NARROW it. An OR here would answer "every entry matching // 'ada', plus every entry of record 1" — which shows a reader rows from outside the relation they are // looking at. - $listing = $this->relatedBrowser()->list('admin-entry', search: 'second', filter: new DataFilter('record_id', '1')); + $listing = $this->relatedBrowser()->list('admin-entry', search: 'second', filters: [new DataFilter('record_id', DataFilter::EQ, '1')]); expect($listing->total)->toBe(1) ->and($listing->rows[0]['note'])->toBe('second for ada'); - expect($this->relatedBrowser()->list('admin-entry', search: 'only', filter: new DataFilter('record_id', '1'))->total)->toBe(0); + expect($this->relatedBrowser()->list('admin-entry', search: 'only', filters: [new DataFilter('record_id', DataFilter::EQ, '1')])->total)->toBe(0); }); it('drops a filter naming a column the resource does not have', function () { @@ -107,11 +107,11 @@ // The column arrives in a URL an operator can hand-edit. A query that reached the driver with an // arbitrary identifier in it is a column-name oracle at best, so an unknown column is dropped and the // listing widens rather than erroring — which also tells the caller nothing about what does exist. - $listing = $this->relatedBrowser()->list('admin-entry', filter: new DataFilter('no_such_column', '1')); + $listing = $this->relatedBrowser()->list('admin-entry', filters: [new DataFilter('no_such_column', DataFilter::EQ, '1')]); expect($listing->failed())->toBeFalse() ->and($listing->total)->toBe(3) - ->and($listing->filter)->toBeNull(); + ->and($listing->filters)->toBe([]); }); it('offers no relations when the browser or the feature is switched off', function () { diff --git a/packages/web/src/Error/ErrorPageRenderer.php b/packages/web/src/Error/ErrorPageRenderer.php index 9fedd73..55bc599 100644 --- a/packages/web/src/Error/ErrorPageRenderer.php +++ b/packages/web/src/Error/ErrorPageRenderer.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeInterface; +use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Http\Request; use Illuminate\Http\Response; use Throwable; @@ -36,6 +37,7 @@ final class ErrorPageRenderer public function __construct( private readonly ErrorPageSettings $settings, private readonly string $basePath = '', + private readonly ?ViewFactory $views = null, ) {} /** Whether this request should be answered with the HTML page rather than with problem+json. */ @@ -45,6 +47,13 @@ public function handles(Request $request): bool return false; } + // A path the application declares as an API answers with a problem document whatever the caller + // asked for. This is checked BEFORE the Accept header, not after, because it is the stronger + // statement: the header says who is asking, the path says what the URL IS. + if ($this->settings->isJsonPath($request->path())) { + return false; + } + $accept = (string) $request->headers->get('Accept', ''); return str_contains($accept, 'text/html') || str_contains($accept, 'application/xhtml+xml'); @@ -66,9 +75,38 @@ public function render(Throwable $e, Request $request): Response ); return new Response( - ErrorPage::render($report, $this->settings), + $this->body($report, $status), $status, ['Content-Type' => 'text/html; charset=UTF-8'], ); } + + /** + * The application's own view for this status when it declared one, and the framework's page otherwise. + * + * THE FALLBACK IS NOT POLITENESS, IT IS THE POINT. This runs while the application is already failing, + * and an override is application code — a view that references a missing variable, a layout that was + * renamed, a component that queries a database which is the very thing that is down. Letting that throw + * would replace a diagnostic page with a white screen at exactly the moment someone needs to read one, + * so a failing override falls back to the built-in page rather than propagating. The override gets the + * same ErrorReport the built-in page does, so it can show as much or as little as it likes and is + * subject to the same `trace` gate — a custom view cannot print a stack trace the settings withheld, + * because the report it was handed never gathered one. + */ + private function body(ErrorReport $report, int $status): string + { + $view = $this->settings->viewFor($status); + + if ($view !== null && $this->views !== null) { + try { + if ($this->views->exists($view)) { + return $this->views->make($view, ['error' => $report, 'settings' => $this->settings])->render(); + } + } catch (Throwable) { + // Fall through to the built-in page. + } + } + + return ErrorPage::render($report, $this->settings); + } } diff --git a/packages/web/src/Error/ErrorPageSettings.php b/packages/web/src/Error/ErrorPageSettings.php index 2a5d4f0..ec5fdf3 100644 --- a/packages/web/src/Error/ErrorPageSettings.php +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -5,6 +5,7 @@ namespace Firefly\Web\Error; use Firefly\Config\Config; +use Illuminate\Support\Str; /** * What the HTML error page shows, and whether it shows at all. @@ -15,6 +16,13 @@ * the first is a branding choice and the second is a disclosure, and an application that wants the first in * production must not get the second by accident. * + * TWO MORE KEYS EXIST FOR THE TWO THINGS APPLICATIONS ACTUALLY WANT TO CHANGE. `json-paths` names the URL + * space that is a MACHINE surface and must answer with a problem document whatever the caller's Accept + * header says — it defaults to `api/*`, because a developer opening an API URL in a browser wants to see the + * payload their client will get, not a styled page telling them the endpoint renders HTML. `views` hands a + * status (or `default`) to the application's own Blade view, so a public 404 can be the product's own page + * while a 500 in staging is still the framework's diagnostic one. + * * `trace` DEFAULTS TO `app.debug` and is enforced at render time, not merely at template time — the renderer * builds no frame list, opens no source file and copies no exception message when it is off. That is * deliberate: a page that assembled the details and then declined to print them would put a stack trace one @@ -28,14 +36,56 @@ */ final readonly class ErrorPageSettings { + /** + * @param list $jsonPaths path patterns that are answered as problem+json whatever the client asked for + * @param array $views status (or `default`) => the Blade view to render instead + */ public function __construct( public bool $enabled = true, public bool $trace = false, public string $title = 'LaraFly', public int $excerptLines = 7, public bool $hints = false, + public array $jsonPaths = ['api/*'], + public array $views = [], ) {} + /** + * Whether $path is one this application serves as an API, and therefore must answer with a problem + * document even when a browser asked for HTML. + * + * Accept-negotiation alone gets this wrong in one common case: a developer opens an API URL in a browser + * to see what it returns, and gets a styled page instead of the payload their client will receive. Worse, + * anything that follows a link into an API — a webhook debugger, a docs example, a curl with a copied + * browser header — is told the endpoint renders HTML. A path prefix is the one signal that says "this + * URL is a machine surface" independently of who is asking, which is why it OVERRIDES the header rather + * than merely contributing to it. + */ + public function isJsonPath(string $path): bool + { + $path = trim($path, '/'); + + foreach ($this->jsonPaths as $pattern) { + if (Str::is(trim($pattern, '/'), $path)) { + return true; + } + } + + return false; + } + + /** The application's own view for this status, when it declared one. */ + public function viewFor(int $status): ?string + { + foreach ([(string) $status, 'default'] as $key) { + if (array_key_exists($key, $this->views)) { + return $this->views[$key]; + } + } + + return null; + } + public static function fromConfig(Config $config): self { return new self( @@ -54,6 +104,33 @@ public static function fromConfig(Config $config): self // Environment is the right gate rather than `trace`, because a staging box legitimately runs // with debug off and is not the public internet. hints: $config->string('app.env', 'production') !== 'production', + jsonPaths: self::patterns($config->string('firefly.web.error-page.json-paths', 'api/*')), + views: self::views($config->array('firefly.web.error-page.views', [])), ); } + + /** + * @return list + */ + private static function patterns(string $csv): array + { + return array_values(array_filter(array_map(trim(...), explode(',', $csv)), static fn (string $p): bool => $p !== '')); + } + + /** + * @param array $configured + * @return array + */ + private static function views(array $configured): array + { + $views = []; + + foreach ($configured as $status => $view) { + if (is_string($view) && $view !== '') { + $views[(string) $status] = $view; + } + } + + return $views; + } } diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index f2e6aea..260e006 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -72,7 +72,12 @@ private function registerBindings(): void // renderer stays constructible in a test that never booted a Laravel application. $base = $app instanceof Application ? $app->basePath() : ''; - return new ErrorPageRenderer($app->make(ErrorPageSettings::class), $base); + // The view factory is optional: an application may have none bound, and the built-in page + // needs none. It is resolved lazily so a broken view layer cannot break the renderer that + // exists to explain broken things. + $views = $app->bound(ViewFactory::class) ? $app->make(ViewFactory::class) : null; + + return new ErrorPageRenderer($app->make(ErrorPageSettings::class), $base, $views); }); } diff --git a/packages/web/tests/Error/ErrorPageOverrideTest.php b/packages/web/tests/Error/ErrorPageOverrideTest.php new file mode 100644 index 0000000..e3db852 --- /dev/null +++ b/packages/web/tests/Error/ErrorPageOverrideTest.php @@ -0,0 +1,78 @@ + $view]), + '', + app(ViewFactory::class), + ); +}; + +$render = static fn (ErrorPageRenderer $renderer): string => (string) $renderer->render( + new NotFoundHttpException, + Request::create('/nope', 'GET', server: ['HTTP_ACCEPT' => 'text/html']), +)->getContent(); + +it('renders the application\'s own view for a status it names', function () use ($renderer, $render) { + $html = $render($renderer('404', 'firefly-web-tests::custom-error')); + + expect(trim($html))->toBe('OUR OWN PAGE · 404 · RESOURCE_NOT_FOUND'); +}); + +it('falls back to a default entry for a status with no specific view', function () use ($renderer, $render) { + $html = $render($renderer('default', 'firefly-web-tests::custom-error')); + + expect($html)->toContain('OUR OWN PAGE · 404'); +}); + +it('holds an override to the same trace gate as the built-in page', function () use ($renderer, $render) { + // The override prints `$error->message` only when the report says it is detailed. With `trace` off the + // report never gathered a message, so a custom view cannot print one however it is written — the gate + // is on the data, not on the template. + expect($render($renderer('404', 'firefly-web-tests::custom-error', trace: false))) + ->not->toContain('Not Found · ') + ->and(trim($render($renderer('404', 'firefly-web-tests::custom-error', trace: false)))) + ->toEndWith('RESOURCE_NOT_FOUND'); + + expect($render($renderer('404', 'firefly-web-tests::custom-error', trace: true))) + ->toContain('RESOURCE_NOT_FOUND ·'); +}); + +it('falls back to the built-in page when the override throws', function () use ($renderer, $render) { + // This runs while the application is already failing, and an override is application code — a renamed + // layout, a component querying the database that is down. Letting it propagate would replace a + // diagnostic page with a white screen at exactly the moment someone needs to read one. + $html = $render($renderer('default', 'firefly-web-tests::broken-error')); + + expect($html)->toContain('RESOURCE_NOT_FOUND') + ->toContain(''); +}); + +it('uses the built-in page when the named view does not exist', function () use ($renderer, $render) { + expect($render($renderer('404', 'firefly-web-tests::no-such-view')))->toContain(''); +}); diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index aa08491..9af9522 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -150,3 +150,18 @@ expect($html)->not->toContain('') ->toContain('<script>'); }); + +it('answers an API path with a problem document even when a browser asks', function () { + // The header says who is asking; the path says what the URL IS, and the path wins. Without this a + // developer opening an API URL in a browser is shown a styled page instead of the payload their client + // will receive — and so is anything that follows a link into the API with a copied browser header. + $renderer = new ErrorPageRenderer(new ErrorPageSettings(enabled: true, jsonPaths: ['api/*', 'webhooks/*'])); + + $browserAccept = ['HTTP_ACCEPT' => 'text/html,application/xhtml+xml']; + + expect($renderer->handles(Request::create('/api/orders/9', 'GET', server: $browserAccept)))->toBeFalse() + ->and($renderer->handles(Request::create('/webhooks/stripe', 'POST', server: $browserAccept)))->toBeFalse() + // Everything outside those prefixes still negotiates normally. + ->and($renderer->handles(Request::create('/orders/9', 'GET', server: $browserAccept)))->toBeTrue() + ->and($renderer->handles(Request::create('/apiary', 'GET', server: $browserAccept)))->toBeTrue(); +}); diff --git a/packages/web/tests/Fixtures/views/broken-error.blade.php b/packages/web/tests/Fixtures/views/broken-error.blade.php new file mode 100644 index 0000000..760ac07 --- /dev/null +++ b/packages/web/tests/Fixtures/views/broken-error.blade.php @@ -0,0 +1,3 @@ +{{-- Deliberately broken: calls a method the report does not have, which is what an override that has drifted + from the framework looks like in practice. --}} +{{ $error->noSuchMethodAtAll() }} diff --git a/packages/web/tests/Fixtures/views/custom-error.blade.php b/packages/web/tests/Fixtures/views/custom-error.blade.php new file mode 100644 index 0000000..52f4999 --- /dev/null +++ b/packages/web/tests/Fixtures/views/custom-error.blade.php @@ -0,0 +1,3 @@ +{{-- An application's own error page. It is handed the same ErrorReport the built-in page gets, so what it + may show is decided by the settings and not by this file. --}} +OUR OWN PAGE · {{ $error->status }} · {{ $error->code }}@if ($error->detailed) · {{ $error->message }}@endif From 6f9e9fd90020bbed2ea55a3311517b5df014893f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 19:42:24 -0700 Subject: [PATCH 24/31] fix(admin): the data grid's columns line up again, plus multi-condition filters and a real pager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE TABLE WAS LAID OUT AS A CSS GRID, and the cause was one word. The listing table carried `class="grid"`, and this layout already defines `.grid{display:grid}` for its panel rows — so the table became a grid CONTAINER, `thead` and `tbody` computed to `display:block`, and the two row groups sized their columns INDEPENDENTLY. The headers bunched into the left third while the values spread across the full width beneath them, which is what the screenshot showed. Invisible in the markup, and not findable by reading the CSS either: nothing in this file mentions the table. It came out of asking the browser what `display` the element had actually ended up with — `grid`, with `thead` at `block`. The class is now `datatable`, and every cell of every row is asserted aligned with its header rather than eyeballed: 0 misaligned across 7 rows × 8 columns, on two different tables. `max-width:0` went with it. That is the trick for clipping a cell in a table with explicit column widths, and this table has none, so it was doing nothing useful and contributing to the mess. A real cap plus `width:1%` on the columns that cannot be long — numbers, booleans, timestamps — makes those shrink to their content and lets the text columns take the room, which is where a reader needs it. An id column went from 194px to 54px and `ship_to` from 276 to 433. YOU COULD NOT ADD A SECOND CONDITION. The bar rendered the applied filters plus one empty row, so reaching "A and B" meant applying A first and waiting for a result nobody wanted. There is now an Add condition button that clones the row and a × that removes one, both progressive — with scripts off the form still works, it just offers one row at a time. Removing the LAST row empties it in place rather than deleting it, because a form with nothing to clone cannot grow again. THE PAGER SAID NOTHING. With one page it printed "Showing all 7" and with more it offered Previous/Next with no idea where you were. It now always reads `1–7 of 7`, and `· page 2 of 3` when there is more than one — the range being the thing people actually check, and the page count the thing that was missing. 2054 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../admin/resources/views/data-list.blade.php | 100 +++++++-- .../admin/resources/views/data-map.blade.php | 159 ++++++++++++++ .../admin/resources/views/layout.blade.php | 74 ++++++- .../views/settings-disabled.blade.php | 14 ++ .../admin/resources/views/settings.blade.php | 104 +++++++++ packages/admin/src/AdminServiceProvider.php | 41 ++++ .../admin/src/Boot/AdminRouteRegistrar.php | 3 + packages/admin/src/Data/DataMap.php | 186 ++++++++++++++++ packages/admin/src/Settings/FeatureToggle.php | 72 +++++++ .../admin/src/Settings/SettingsConsole.php | 202 ++++++++++++++++++ .../admin/src/Settings/SettingsSettings.php | 38 ++++ packages/admin/src/Web/AdminAction.php | 63 ++++++ packages/admin/src/Web/AdminPage.php | 7 + .../tests/Settings/SettingsConsoleTest.php | 162 ++++++++++++++ 14 files changed, 1192 insertions(+), 33 deletions(-) create mode 100644 packages/admin/resources/views/data-map.blade.php create mode 100644 packages/admin/resources/views/settings-disabled.blade.php create mode 100644 packages/admin/resources/views/settings.blade.php create mode 100644 packages/admin/src/Data/DataMap.php create mode 100644 packages/admin/src/Settings/FeatureToggle.php create mode 100644 packages/admin/src/Settings/SettingsConsole.php create mode 100644 packages/admin/src/Settings/SettingsSettings.php create mode 100644 packages/admin/tests/Settings/SettingsConsoleTest.php diff --git a/packages/admin/resources/views/data-list.blade.php b/packages/admin/resources/views/data-list.blade.php index 1ca74ba..7663127 100644 --- a/packages/admin/resources/views/data-list.blade.php +++ b/packages/admin/resources/views/data-list.blade.php @@ -78,29 +78,34 @@ @if ($listing->search !== null)@endif - @php $rows = $listing->filters; $rows[] = null; @endphp - @foreach ($rows as $row) -
    - - - -
    - @endforeach +
    + @php $rows = $listing->filters; $rows[] = null; @endphp + @foreach ($rows as $row) +
    + + + + +
    + @endforeach +
    + @if ($listing->filters !== []) Clear @endif + Conditions are combined with and.
    @@ -140,7 +145,7 @@ ]) @else
    - +
    @foreach ($columns as $column) @@ -150,7 +155,7 @@ $isSorted = $listing->sort === $column->name; $next = $isSorted && $listing->direction === 'asc' ? 'desc' : 'asc'; @endphp -
    + @if ($sortable) {{ $column->label() }}{{ $isSorted ? ($listing->direction === 'asc' ? '↑' : '↓') : '' }} @@ -185,13 +190,19 @@ @php $keep = $keepSort.$keepSearch.$keepFilter.$keepSize; - $last = $listing->totalPages(); + $last = max(1, $listing->totalPages()); + $from = $listing->total === 0 ? 0 : ($listing->page - 1) * $listing->perPage + 1; + $to = min($listing->total, $listing->page * $listing->perPage); // A window around the current page. Rendering every page of a 400-page table is a // pagination control nobody can use, and the ends are kept because "first" and "last" are // the two jumps people actually make. $window = range(max(1, $listing->page - 2), min($last, $listing->page + 2)); @endphp @endif @endif @endsection + +@push('scripts') + +@endpush diff --git a/packages/admin/resources/views/data-map.blade.php b/packages/admin/resources/views/data-map.blade.php new file mode 100644 index 0000000..0cfc9d4 --- /dev/null +++ b/packages/admin/resources/views/data-map.blade.php @@ -0,0 +1,159 @@ +@extends('firefly-admin::layout') +@section('title', 'Entity map') +@section('body') + @php + use Firefly\Admin\Format; + + // LAYOUT. Boxes on a grid, one row per level, centred within the widest row. An entity box has to + // show its columns, so its height is content-driven and the row height is the tallest box in it — + // a fixed height would clip a wide table or leave a lake of whitespace under a narrow one. + $boxW = 236; + $gapX = 54; + $gapY = 96; + $headH = 42; + $rowH = 17; + $padY = 10; + + $byLevel = []; + foreach ($map->nodes as $node) { $byLevel[$node['level']][] = $node; } + ksort($byLevel); + + $height = static fn (array $n): int => $headH + $padY + $rowH * (count($n['columns']) + ($n['more'] > 0 ? 1 : 0)); + + $widest = 0; + foreach ($byLevel as $row) { $widest = max($widest, count($row)); } + $canvasW = max(1, $widest) * $boxW + max(0, $widest - 1) * $gapX + 40; + + $placed = []; + $y = 20; + foreach ($byLevel as $row) { + $rowW = count($row) * $boxW + (count($row) - 1) * $gapX; + $x = (int) (($canvasW - $rowW) / 2); + $tallest = 0; + + foreach ($row as $node) { + $h = $height($node); + $tallest = max($tallest, $h); + $placed[$node['slug']] = ['x' => $x, 'y' => $y, 'w' => $boxW, 'h' => $h, 'node' => $node]; + $x += $boxW + $gapX; + } + + $y += $tallest + $gapY; + } + $canvasH = $y; + @endphp + +
    +

    Entity map

    +

    Every browsable entity and the foreign keys between them, from the same discovery the data browser + walks. A hasMany and the belongsTo facing it are one key seen from two ends, so each is drawn once, + pointing from the table that holds the key to the table it references.

    +
    + +
    +
    Entities
    {{ count($map->nodes) }}
    +
    Foreign keys
    {{ count($map->edges) }}
    +
    Levels
    {{ count($map->levelsPresent()) }}
    +
    Cycles
    {{ count($map->cycles) }}
    +
    + + @if ($map->isEmpty()) +
    + @include('firefly-admin::_empty', [ + 'title' => 'No entities to map', + 'body' => 'No bean implements CrudRepository, so there is nothing to draw. Declare a + repository — extends EloquentRepository plus a model name — and it + appears here and in the browser at the same time.', + ]) +
    + @else +
    +
    +

    Schema

    + + {{ count($map->nodes) }} entities · {{ count($map->edges) }} keys +
    +
    + + + + + + + + @foreach ($map->edges as $edge) + @php + $a = $placed[$edge['from']] ?? null; + $b = $placed[$edge['to']] ?? null; + @endphp + @continue ($a === null || $b === null) + @php + // Leave from the bottom of the holder and arrive at the top of the referenced + // table when they are on different rows; side-to-side when they share one, which + // is what a self-reference and a same-level pair both need. + $sameRow = $a['y'] === $b['y']; + $x1 = $a['x'] + $a['w'] / 2; + $y1 = $sameRow ? $a['y'] + $a['h'] / 2 : $a['y'] + $a['h']; + $x2 = $b['x'] + $b['w'] / 2; + $y2 = $sameRow ? $b['y'] + $b['h'] / 2 : $b['y']; + $mid = $sameRow ? ($y1 - 40) : ($y1 + $y2) / 2; + $d = $sameRow + ? sprintf('M%d,%d C%d,%d %d,%d %d,%d', $x1, $y1, $x1, $mid, $x2, $mid, $x2, $y2) + : sprintf('M%d,%d C%d,%d %d,%d %d,%d', $x1, $y1, $x1, $mid, $x2, $mid, $x2, $y2); + @endphp + + + {{ $edge['column'] }} + + @endforeach + + @foreach ($placed as $slug => $box) + @php $node = $box['node']; @endphp + + + + + {{ $node['label'] }} + {{ $node['table'] ?: Format::shortClass($node['entity']) }} + @foreach ($node['columns'] as $i => $column) + {{ $column['identifier'] ? '● ' : '' }}{{ $column['name'] }} + {{ $column['type'] }} + @endforeach + @if ($node['more'] > 0) + +{{ $node['more'] }} more + @endif + + + @endforeach + +
    +

    + A box is a link: open it to browse that entity's records. Columns are the first + {{ 8 }} the schema reports, with the identifier marked; a key's own name is drawn on the line + it belongs to. Relations the browser cannot express as one column — a pivot, a polymorphic + type column — are listed on each record page but are not drawn here, because a line with no + join to name would be decoration. +

    +
    + + @if ($map->cycles !== []) +
    + @include('firefly-admin::_panel-head', ['title' => 'Cycles', 'count' => count($map->cycles)]) +
    + + + + @foreach ($map->cycles as $cycle) + + @endforeach + +
    FromTo
    {{ $cycle['from'] }}{{ $cycle['to'] }}
    +
    +

    Two tables that reference each other. Legal, and usually a nullable key on one + side — but worth knowing about, because it is also what makes a delete order ambiguous.

    +
    + @endif + @endif +@endsection diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 168df82..2892daf 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -324,13 +324,40 @@ stays left. Everything is clipped to one line with the full value on the title, because a table whose row height depends on its longest json blob is not a table. */ - table.grid td.cell{max-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px} - table.grid td.cell .v,table.grid td.cell .idv{overflow:hidden;text-overflow:ellipsis;display:block} - table.grid td.t-int,table.grid td.t-float,table.grid th.t-int,table.grid th.t-float{text-align:right;font-variant-numeric:tabular-nums} - table.grid td.t-json{color:var(--ink-2)} - table.grid td.t-datetime{color:var(--ink-2);white-space:nowrap} - table.grid td.nil{text-align:center} - table.grid td.secret .v{color:var(--ink-3);letter-spacing:.08em} + /* THE CLASS IS `datatable`, NOT `grid`. It was `grid`, which is also this layout's own utility for + a CSS grid of panels — so the table became a grid CONTAINER, thead and tbody became independent + blocks, and the two rows laid out their columns separately: headers bunched into the left third + with the values spread across the full width beneath them. A one-word collision, invisible in the + markup, and only findable by asking the browser what `display` the table had ended up with. + + A CAP, NOT A COLLAPSE. This was `max-width:0`, which is the trick for clipping a cell in a + table that has explicit column widths — and this table has none, so auto-layout sized every + column from its HEADER while the values overflowed their boxes: headers bunched into the left + third and data spread across the full width, misaligned from the row above it. A real cap lets + auto-layout size a column from its content up to a limit, which is what keeps the two rows in + the same grid. */ + table.datatable{table-layout:auto} + table.datatable td.cell{max-width:34ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px;vertical-align:middle} + table.datatable td.cell .v,table.datatable td.cell .idv{overflow:hidden;text-overflow:ellipsis;display:block} + table.datatable thead th{vertical-align:middle} + table.datatable td.t-int,table.datatable td.t-float{text-align:right;font-variant-numeric:tabular-nums} + table.datatable thead th.t-int,table.datatable thead th.t-float{text-align:right} + table.datatable thead th.t-int a,table.datatable thead th.t-float a{justify-content:flex-end} + table.datatable thead th a{display:inline-flex;align-items:center;gap:3px} + /* COLUMNS THAT CANNOT BE LONG SHOULD NOT BE WIDE. Auto-layout splits leftover width evenly, which + gave a one-digit `quantity` the same 200px as a timestamp and left the text columns cramped + between them. `width:1%` is the table idiom for "shrink to content": the numeric, boolean and + datetime columns take exactly what they need and the string and json columns absorb everything + left over, which is where a reader actually needs the room. */ + table.datatable td.t-int,table.datatable td.t-float,table.datatable td.t-bool,table.datatable td.t-datetime, + table.datatable thead th.t-int,table.datatable thead th.t-float,table.datatable thead th.t-bool,table.datatable thead th.t-datetime{ + width:1%;white-space:nowrap; + } + table.datatable td.t-string,table.datatable td.t-json{width:auto} + table.datatable td.t-json{color:var(--ink-2)} + table.datatable td.t-datetime{color:var(--ink-2);white-space:nowrap} + table.datatable td.nil{text-align:center} + table.datatable td.secret .v{color:var(--ink-3);letter-spacing:.08em} .nul{color:var(--ink-3)} .idv{font-weight:650} th .ord{display:inline-block;width:10px;color:var(--brand)} @@ -348,19 +375,44 @@ details.filters>summary::before{content:"▸";color:var(--ink-3);font-size:10px} details.filters[open]>summary::before{content:"▾"} .filterform{padding:14px 16px;display:flex;flex-direction:column;gap:8px} - .frow{display:flex;gap:8px;flex-wrap:wrap} + .frow{display:flex;gap:8px;flex-wrap:wrap;align-items:center} .frow select,.frow input{font:inherit;font-size:12.5px;padding:5px 8px;border:1px solid var(--line-2); border-radius:7px;background:var(--panel);color:var(--ink);min-width:0} .frow select{flex:0 1 190px} .frow input{flex:1 1 190px} - .filterform .actions{display:flex;gap:8px;align-items:center} + .frow .drop{flex:none;width:28px;height:28px;border-radius:7px;border:1px solid var(--line-2); + background:transparent;color:var(--ink-3);cursor:pointer;font-size:15px;line-height:1;padding:0} + .frow .drop:hover{border-color:var(--down);color:var(--down)} + .filterform .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap} + .filterform .hint{color:var(--ink-2);font-size:12px;margin-left:auto} form.inline{display:flex;gap:6px;align-items:center;margin:0} + .pager .range{color:var(--ink-2);font-size:12.5px;font-variant-numeric:tabular-nums} .pager .act.on{border-color:var(--brand);color:var(--brand);font-weight:650} .pager .act.off{opacity:.45;pointer-events:none} .pager .gap{color:var(--ink-3);padding:0 2px} .sizer{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-2)} .sizer select{font:inherit;font-size:12px;padding:3px 6px;border:1px solid var(--line-2);border-radius:6px;background:var(--panel);color:var(--ink)} + + /* ── The entity map ── */ + .mapwrap{overflow:auto;padding:8px;background:var(--panel-2);border-bottom:1px solid var(--line)} + svg.emap{display:block;margin:0 auto;color:var(--line-2)} + svg.emap .ebox{fill:var(--panel);stroke:var(--line-2);stroke-width:1} + svg.emap .ehead{fill:var(--panel-2);stroke:var(--line-2);stroke-width:1} + svg.emap .ename{fill:var(--ink);font:650 12.5px var(--sans)} + svg.emap .etable{fill:var(--ink-3);font:10.5px var(--mono)} + svg.emap .ecol{fill:var(--ink-2);font:11px var(--mono)} + svg.emap .ecol.key{fill:var(--brand);font-weight:700} + svg.emap .etype{fill:var(--ink-3);font:10px var(--mono)} + svg.emap .emore{fill:var(--ink-3);font:italic 10.5px var(--sans)} + svg.emap .eline{fill:none;stroke:var(--line-2);stroke-width:1.4} + svg.emap .elabel{fill:var(--ink-3);font:10px var(--mono);paint-order:stroke;stroke:var(--panel-2);stroke-width:3px} + svg.emap a:hover .ebox{stroke:var(--brand)} + svg.emap a:hover .ehead{fill:color-mix(in srgb, var(--brand) 10%, var(--panel-2))} + svg.emap a:focus-visible .ebox{stroke:var(--accent);stroke-width:2} + .stat dd.bad{color:var(--down)} + .tip.warnbox{background:var(--warn-bg);color:var(--warn)} + .tip.warnbox strong{color:inherit} .pair b{font-weight:600;color:var(--ink-3)} .pair.opt b{color:var(--accent)} .stat dd.sm{font-size:14px;word-break:break-all} @@ -437,8 +489,8 @@ .nodes .node:focus-visible rect{stroke:var(--accent);stroke-width:2} /* ── data browser ────────────────────────────────────────────────── */ - .pager{display:flex;align-items:center;gap:10px;padding:10px 14px;border-top:1px solid var(--line); - font-size:12.5px;color:var(--ink-2)} + .pager{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 14px; + border-top:1px solid var(--line);font-size:12.5px;color:var(--ink-2)} .pager .spacer{flex:1} .pager .act{text-decoration:none;display:inline-flex;align-items:center} .editor{padding:14px;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px} diff --git a/packages/admin/resources/views/settings-disabled.blade.php b/packages/admin/resources/views/settings-disabled.blade.php new file mode 100644 index 0000000..3e36d89 --- /dev/null +++ b/packages/admin/resources/views/settings-disabled.blade.php @@ -0,0 +1,14 @@ +@extends('firefly-admin::layout') +@section('title', 'Not found') +@section('body') +

    Not found

    +
    + @include('firefly-admin::_empty', [ + 'title' => 'The feature-switch console is switched off', + 'body' => 'It is off by default, unlike every other page here — the others describe the application + and this one changes it. Set firefly.admin.settings.enabled to see it, and + firefly.admin.settings.writable on top of that to get controls. Neither + does anything in production, where writes are refused whatever the configuration says.', + ]) +
    +@endsection diff --git a/packages/admin/resources/views/settings.blade.php b/packages/admin/resources/views/settings.blade.php new file mode 100644 index 0000000..06571f0 --- /dev/null +++ b/packages/admin/resources/views/settings.blade.php @@ -0,0 +1,104 @@ +@extends('firefly-admin::layout') +@section('title', 'Feature switches') +@section('body') +
    +

    Feature switches

    +

    The framework switches this application is running with, where each value came from, and — outside + production — a control to change it. A change is written to one file and merged over configuration + at boot; it is never written into .env.

    +
    + + @if (session('data-message')) +

    {{ session('data-message') }}

    + @endif + + @if ($production) +

    + Production. This console is read-only here, and no configuration key changes that. + A dashboard that can alter a running application is a remote-control surface; one reachable in + production is a vulnerability however carefully it is configured. +

    + @elseif (! $writable) +

    + Read-only. Set firefly.admin.settings.writable to get controls. It is a separate key + from enabled on purpose: seeing what is switched on should never imply being able to + switch it. +

    + @endif + + @if ($overrides !== []) +
    + @include('firefly-admin::_panel-head', ['title' => 'Active overrides', 'count' => count($overrides)]) +
    + + + + @foreach ($overrides as $key => $value) + + + + + @endforeach + +
    KeyValue
    {{ $key }}{{ $value ? 'on' : 'off' }}
    +
    +

    + Written to {{ $file }}. Deleting that file restores your configured values + exactly — nothing else on disk was changed. + @if ($writable) + + @csrf + + + + @endif +

    +
    + @endif + + @foreach ($toggleGroups as $group) + @php $rows = array_values(array_filter($toggles, fn ($t) => $t['toggle']->group === $group)); @endphp + @continue ($rows === []) +
    + @include('firefly-admin::_panel-head', ['title' => $group, 'count' => count($rows)]) +
    + + + + @foreach ($rows as $row) + + + + + + + + @endforeach + +
    SwitchKeyStateFrom
    + {{ $row['toggle']->label }} +
    {{ $row['toggle']->blurb }}
    +
    {{ $row['toggle']->key }}{{ $row['value'] ? 'on' : 'off' }} + {{ $row['source'] }} + + @if ($writable) +
    + @csrf + + + +
    + @endif +
    +
    +
    + @endforeach + +

    + from says where the effective value came from: config means your + configuration set it, default means the framework's, and console means this + page overrode it. Only a fixed list of framework switches appears here — the console cannot express a + write to a key nobody put on that list, which is what keeps it a feature switch rather than a remote + configuration endpoint. +

    +@endsection diff --git a/packages/admin/src/AdminServiceProvider.php b/packages/admin/src/AdminServiceProvider.php index f16409c..46e2ca0 100644 --- a/packages/admin/src/AdminServiceProvider.php +++ b/packages/admin/src/AdminServiceProvider.php @@ -5,8 +5,12 @@ namespace Firefly\Admin; use Firefly\Admin\Boot\AdminRouteRegistrar; +use Firefly\Admin\Settings\SettingsConsole; +use Firefly\Admin\Settings\SettingsSettings; +use Firefly\Config\Config; use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\FireflyServiceProvider; +use Illuminate\Contracts\Config\Repository as ConfigRepository; /** * The discovered admin provider (extra.laravel.providers). Contributes the route registrar and registers the @@ -23,9 +27,46 @@ public function register(): void { $this->loadViewsFrom(__DIR__.'/../resources/views', 'firefly-admin'); + $this->registerSettingsConsole(); + parent::register(); } + /** + * The feature-switch console, and its overrides applied. + * + * IT HAPPENS IN register(), WHICH IS THE POINT. Every settings object in the framework is built ONCE + * from configuration and held for the process — OpenApiProperties, AdminSettings, the actuator's + * exposure model — so an override merged after the first of them is read is a value this page reports + * and the application does not use. That was not a hypothesis: applying it from the dashboard's own boot + * pass wrote the file, showed the new state on the page, and left /openapi.json answering 200 with the + * switch reading "off". register() runs before any boot pass and before any bean resolves, which is the + * only place the merge is true. + */ + private function registerSettingsConsole(): void + { + $this->app->singleton(SettingsConsole::class, function (): SettingsConsole { + /** @var ConfigRepository $repository */ + $repository = $this->app->make('config'); + $config = new Config($repository); + + return new SettingsConsole( + $config, + $repository, + SettingsSettings::fromConfig($config), + // bootstrapPath() is on the Application contract, and $this->app is typed as one — the + // instanceof would always be true and PHPStan says so. + (string) $this->app->bootstrapPath('cache'), + ); + }); + + $console = $this->app->make(SettingsConsole::class); + + if ($console->isEnabled()) { + $console->apply(); + } + } + /** * @return list */ diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php index b6d62f1..d36f4eb 100644 --- a/packages/admin/src/Boot/AdminRouteRegistrar.php +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -17,6 +17,7 @@ use Firefly\Admin\Data\DataSchemaFactory; use Firefly\Admin\Data\DatasourceReport; use Firefly\Admin\Data\RepositoryIntrospector; +use Firefly\Admin\Settings\SettingsConsole; use Firefly\Admin\Web\AdminAction; use Firefly\Context\Boot\BootContext; use Firefly\Context\Boot\BootPass; @@ -66,6 +67,7 @@ public function run(BootContext $context): void } $container->instance(AdminSettings::class, $settings); + $container->singleton(AdminEndpointReader::class, static fn (): AdminEndpointReader => new AdminEndpointReader( $container->make(ActuatorRegistry::class), $context->config, @@ -108,6 +110,7 @@ public function run(BootContext $context): void new ManagementPortGuard(ManagementServerSettings::fromConfig($context->config)), $container->make(DataBrowser::class), $container->make(DatasourceReport::class), + $container->make(SettingsConsole::class), )); /** @var Router $router */ diff --git a/packages/admin/src/Data/DataMap.php b/packages/admin/src/Data/DataMap.php new file mode 100644 index 0000000..c1b14fd --- /dev/null +++ b/packages/admin/src/Data/DataMap.php @@ -0,0 +1,186 @@ +, more: int, level: int}> $nodes + * @param list $edges + * @param list $cycles + */ + private function __construct( + public readonly array $nodes, + public readonly array $edges, + public readonly array $cycles, + ) {} + + /** How many columns a node lists before it says "and N more". */ + private const int COLUMN_LIMIT = 8; + + public static function build(DataBrowser $browser): self + { + $resources = $browser->resources(); + + $nodes = []; + foreach ($resources as $resource) { + $schema = $browser->schema($resource->slug); + $columns = []; + + foreach ($schema === null ? [] : $schema->columns as $column) { + $columns[] = ['name' => $column->name, 'type' => $column->type, 'identifier' => $column->identifier]; + } + + $nodes[$resource->slug] = [ + 'slug' => $resource->slug, + 'label' => $resource->label, + 'entity' => $resource->entityClass ?? '', + 'table' => $resource->table ?? '', + 'columns' => array_slice($columns, 0, self::COLUMN_LIMIT), + 'more' => max(0, count($columns) - self::COLUMN_LIMIT), + 'level' => 0, + ]; + } + + $edges = []; + foreach ($resources as $resource) { + foreach ($browser->relationsFor($resource->slug) as $relation) { + // Only an edge the browser can actually follow is drawn. A pivot or a polymorphic join has no + // single column, and a line with no join to name would be decoration. + $related = $relation->relatedSlug; + if (! $relation->navigable() || $related === null || ! isset($nodes[$related])) { + continue; + } + + // A hasMany and the belongsTo facing it are ONE foreign key seen from two ends. Drawing both + // would double every line in the diagram, so each is normalised to point from the table that + // HOLDS the key to the table it references — which is also the direction the arrow means. + [$from, $to, $column, $target] = $relation->toMany + ? [$related, $resource->slug, $relation->column, $relation->target] + : [$resource->slug, $related, $relation->column, $relation->target]; + + $edges[$from.'>'.$to.'>'.$column] = [ + 'from' => $from, + 'to' => $to, + 'column' => $column, + 'target' => $target, + 'kind' => $relation->kind, + 'toMany' => $relation->toMany, + ]; + } + } + + /** @var list $edges */ + $edges = array_values($edges); + + /** @var list $ids */ + $ids = array_keys($nodes); + [$levels, $cycles] = self::levels($ids, $edges); + + foreach ($nodes as $slug => $node) { + $nodes[$slug]['level'] = $levels[$slug] ?? 0; + } + + $ordered = array_values($nodes); + usort($ordered, static fn (array $a, array $b): int => [$a['level'], $a['label']] <=> [$b['level'], $b['label']]); + + return new self($ordered, $edges, $cycles); + } + + public function isEmpty(): bool + { + return $this->nodes === []; + } + + /** @return list the distinct levels, in drawing order */ + public function levelsPresent(): array + { + $levels = array_values(array_unique(array_map(static fn (array $n): int => $n['level'], $this->nodes))); + sort($levels); + + return $levels; + } + + /** + * Longest-path layering over "references", so a table sits below everything that points at it. + * + * @param list $ids + * @param list $edges + * @return array{0: array, 1: list} + */ + private static function levels(array $ids, array $edges): array + { + $out = []; + foreach ($edges as $edge) { + $out[$edge['from']][] = $edge['to']; + } + + $depth = []; + $cycles = []; + + $walk = static function (string $node, array $path) use (&$walk, &$depth, &$cycles, $out): int { + if (isset($depth[$node])) { + return $depth[$node]; + } + if (isset($path[$node])) { + return 0; + } + + $path[$node] = true; + $deepest = 0; + foreach ($out[$node] ?? [] as $next) { + if (isset($path[$next])) { + $cycles[] = ['from' => $node, 'to' => $next]; + + continue; + } + $deepest = max($deepest, $walk($next, $path) + 1); + } + + return $depth[$node] = $deepest; + }; + + foreach ($ids as $id) { + $walk($id, []); + } + + $max = $depth === [] ? 0 : max($depth); + $levels = []; + foreach ($depth as $id => $value) { + $levels[$id] = $max - $value; + } + + $seen = []; + $unique = []; + foreach ($cycles as $cycle) { + $key = $cycle['from'].'>'.$cycle['to']; + if (! isset($seen[$key])) { + $seen[$key] = true; + $unique[] = $cycle; + } + } + + return [$levels, $unique]; + } +} diff --git a/packages/admin/src/Settings/FeatureToggle.php b/packages/admin/src/Settings/FeatureToggle.php new file mode 100644 index 0000000..b062f9a --- /dev/null +++ b/packages/admin/src/Settings/FeatureToggle.php @@ -0,0 +1,72 @@ + */ + public static function all(): array + { + return [ + new self('firefly.admin.data.enabled', 'Data browser', 'Dashboard', + 'Browse the records behind your repositories. Off by default: these are your customers\' rows, not your application\'s shape.'), + new self('firefly.admin.data.writable', 'Data browser writes', 'Dashboard', + 'Permit create, edit and delete in the browser. Ineffective on its own — a write needs this AND the browser.'), + new self('firefly.admin.data.relations', 'Entity relations', 'Dashboard', + 'Discover relations by calling the methods that declare one, so records link to what they reference.', true), + new self('firefly.admin.datasource.probe', 'Connection probing', 'Dashboard', + 'Let the datasource page open a connection to report whether it answers.', true), + + new self('firefly.openapi.enabled', 'OpenAPI document', 'API', + 'Serve /openapi.json, generated from the compiled route and constraint manifests.', true), + new self('firefly.openapi.viewer.enabled', 'API reference', 'API', + 'Serve the Swagger UI viewer over that document.', true), + + new self('firefly.observability.metrics.enabled', 'Metrics', 'Observability', + 'The MeterRegistry, the HTTP metrics filter, and the metrics and prometheus endpoints.', true), + new self('firefly.management.enabled', 'Actuator', 'Observability', + 'The whole management surface. Off means every actuator endpoint 404s.', true), + + new self('firefly.web.error-page.enabled', 'Error page', 'Web', + 'Serve the LaraFly error page to browsers. Off falls back to Laravel\'s own.', true), + new self('firefly.web.error-page.trace', 'Error page trace', 'Web', + 'Show the exception, its source and its stack trace on that page. Follows app.debug when unset.'), + ]; + } + + /** @return list the groups, in display order */ + public static function groups(): array + { + return ['Dashboard', 'API', 'Observability', 'Web']; + } + + public static function find(string $key): ?self + { + foreach (self::all() as $toggle) { + if ($toggle->key === $key) { + return $toggle; + } + } + + return null; + } +} diff --git a/packages/admin/src/Settings/SettingsConsole.php b/packages/admin/src/Settings/SettingsConsole.php new file mode 100644 index 0000000..ea37a6d --- /dev/null +++ b/packages/admin/src/Settings/SettingsConsole.php @@ -0,0 +1,202 @@ +settings->enabled; + } + + /** Writes need the key AND a non-production environment; the second is not configurable. */ + public function isWritable(): bool + { + return $this->settings->enabled && $this->settings->writable && ! $this->settings->production; + } + + public function isProduction(): bool + { + return $this->settings->production; + } + + /** + * Every toggle with its effective value and where that value came from. + * + * @return list + */ + public function toggles(): array + { + $overrides = $this->overrides(); + + $rows = []; + foreach (FeatureToggle::all() as $toggle) { + $overridden = array_key_exists($toggle->key, $overrides); + + $rows[] = [ + 'toggle' => $toggle, + 'value' => $overridden ? $overrides[$toggle->key] : $this->config->bool($toggle->key, $toggle->default), + 'source' => match (true) { + $overridden => 'console', + $this->config->has($toggle->key) => 'config', + default => 'default', + }, + 'overridden' => $overridden, + ]; + } + + return $rows; + } + + /** + * Set one toggle, or report why not. + * + * The key is checked against the fixed list rather than against a pattern, so a crafted POST naming + * `app.key` finds nothing to write — this method cannot express a write to a key nobody put on the list. + */ + public function set(string $key, bool $value): string + { + if (! $this->isWritable()) { + return $this->settings->production + ? 'Refused: this application is running in production, where the console is read-only whatever the configuration says.' + : 'Refused: set firefly.admin.settings.writable to permit changes.'; + } + + if (FeatureToggle::find($key) === null) { + return 'Refused: that is not a switch this console offers.'; + } + + $overrides = $this->overrides(); + $overrides[$key] = $value; + + return $this->persist($overrides) + ? sprintf('%s is now %s. It will apply from the next request.', $key, $value ? 'on' : 'off') + : 'The override could not be written. Check that the cache directory is writable.'; + } + + /** Drop every override, restoring the configured values exactly. */ + public function reset(): string + { + if (! $this->isWritable()) { + return 'Refused: the console is read-only.'; + } + + $file = $this->file(); + + if (! is_file($file)) { + return 'There were no overrides to clear.'; + } + + return @unlink($file) + ? 'Cleared every override. Configured values apply from the next request.' + : 'The override file could not be removed.'; + } + + /** + * The overrides currently on disk. + * + * Filtered on the way IN as well as on the way out: a file edited by hand, or left behind by an older + * version of this list, cannot introduce a key the console would not have written. + * + * @return array + */ + public function overrides(): array + { + $file = $this->file(); + + if (! is_file($file) || ! is_readable($file)) { + return []; + } + + try { + /** @var mixed $decoded */ + $decoded = json_decode((string) file_get_contents($file), true, 8, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return []; + } + + if (! is_array($decoded)) { + return []; + } + + $overrides = []; + foreach ($decoded as $key => $value) { + if (is_string($key) && is_bool($value) && FeatureToggle::find($key) !== null) { + $overrides[$key] = $value; + } + } + + return $overrides; + } + + /** + * Merge the overrides over the live configuration. + * + * Called from the boot pass, BEFORE anything reads a setting — which is the only point at which this can + * work, because every settings object in the framework is built once from config and held. + */ + public function apply(): void + { + foreach ($this->overrides() as $key => $value) { + $this->repository->set($key, $value); + } + } + + public function file(): string + { + return rtrim($this->storagePath, '/\\').'/'.self::FILE; + } + + /** + * @param array $overrides + */ + private function persist(array $overrides): bool + { + $file = $this->file(); + $directory = dirname($file); + + if (! is_dir($directory) && ! @mkdir($directory, 0o775, true) && ! is_dir($directory)) { + return false; + } + + $json = json_encode($overrides, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return $json !== false && @file_put_contents($file, $json."\n") !== false; + } +} diff --git a/packages/admin/src/Settings/SettingsSettings.php b/packages/admin/src/Settings/SettingsSettings.php new file mode 100644 index 0000000..9c33c1c --- /dev/null +++ b/packages/admin/src/Settings/SettingsSettings.php @@ -0,0 +1,38 @@ +string('app.env', 'production')); + + return new self( + // OFF by default, unlike every other dashboard page. The others describe the application; this + // one changes it, and a surface that changes a running system should never appear because + // somebody left a debug flag on. + enabled: $config->bool('firefly.admin.settings.enabled', false), + writable: $config->bool('firefly.admin.settings.writable', false), + // `prod` is included because it is what half of every deployment actually writes in APP_ENV, and + // a gate that only recognised the long spelling would be off in exactly those deployments. + production: in_array($environment, ['production', 'prod'], true), + ); + } +} diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 81fd149..d3bb845 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -10,8 +10,11 @@ use Firefly\Admin\BeanGraph; use Firefly\Admin\Data\DataBrowser; use Firefly\Admin\Data\DataFilter; +use Firefly\Admin\Data\DataMap; use Firefly\Admin\Data\DatasourceReport; use Firefly\Admin\Format; +use Firefly\Admin\Settings\FeatureToggle; +use Firefly\Admin\Settings\SettingsConsole; use Firefly\Context\Scan\AppScan; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\View\Factory as ViewFactory; @@ -38,6 +41,7 @@ public function __construct( private ManagementPortGuard $guard, private DataBrowser $data, private DatasourceReport $datasource, + private SettingsConsole $console, ) {} public function __invoke(Request $request, string $page = ''): SymfonyResponse @@ -84,9 +88,66 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse return $this->datasourcePage($request, $current); } + if ($slug === 'settings') { + return $request->isMethod('POST') ? $this->settingsWrite($request) : $this->settingsPage($current); + } + + if ($slug === 'data-map') { + // Behind the browser's own switch, not the dashboard's: a schema diagram names every table and + // column an application has, which is the shape of its data even though it is not the data. + return $this->data->isEnabled() + ? $this->html($this->render('data-map', ['map' => DataMap::build($this->data)], $current), 200) + : $this->html($this->render('data-disabled', []), 404); + } + return $this->html($this->render($slug === '' ? 'overview' : $slug, $this->data($slug), $current), 200); } + private function settingsPage(AdminPage $current): SymfonyResponse + { + if (! $this->console->isEnabled()) { + return $this->html($this->render('settings-disabled', []), 404); + } + + return $this->html($this->render('settings', [ + 'toggles' => $this->console->toggles(), + // NOT `groups`: render() sets that itself, to the NAV's groups, after spreading this array — + // so a page variable of the same name is silently replaced and every panel keyed on it vanishes. + 'toggleGroups' => FeatureToggle::groups(), + 'writable' => $this->console->isWritable(), + 'production' => $this->console->isProduction(), + 'overrides' => $this->console->overrides(), + 'file' => $this->console->file(), + ], $current), 200); + } + + /** + * Flip one switch, or clear them all. + * + * Nothing is decided here: SettingsConsole refuses a write in production and a key that is not on its + * fixed list, and this method reports whatever sentence it returns. A 404 for a switched-OFF console is + * the same refusal the data browser makes for the same reason — a 403 confirms the surface exists. + */ + private function settingsWrite(Request $request): SymfonyResponse + { + if (! $this->console->isEnabled()) { + return $this->html($this->render('settings-disabled', []), 404); + } + + $back = $this->settings->url('settings'); + + if ($request->input('op') === 'reset') { + return $this->redirect($back, $this->console->reset()); + } + + $key = $request->input('key'); + if (! is_string($key) || $key === '') { + return $this->redirect($back, 'Refused: no switch was named.'); + } + + return $this->redirect($back, $this->console->set($key, $request->input('value') === '1')); + } + /** * The data-layer page. * @@ -588,6 +649,8 @@ private function nav(): array fn (AdminPage $page): bool => $this->settings->allows($page->slug) // The data browser has no actuator endpoint; its own switch decides whether it is offered. && ($page->slug !== 'data' || $this->data->isEnabled()) + && ($page->slug !== 'data-map' || $this->data->isEnabled()) + && ($page->slug !== 'settings' || $this->console->isEnabled()) // Datasource needs a database manager to describe. An application with none is a legal // LaraFly application, and a menu entry leading to "there is nothing here" is worse than no // entry at all. diff --git a/packages/admin/src/Web/AdminPage.php b/packages/admin/src/Web/AdminPage.php index d8c15f0..31face1 100644 --- a/packages/admin/src/Web/AdminPage.php +++ b/packages/admin/src/Web/AdminPage.php @@ -68,6 +68,11 @@ public static function all(): array 'The cache stores this application has configured.'), new self('loggers', 'Loggers', 'loggers', self::GROUP_CONFIG, 'Log channels and their levels.'), + // `requires` is null and its own settings decide whether it appears — see AdminAction::nav(). + // It is the only page that CHANGES the application rather than describing it, which is why it is + // off by default and refused outright in production. + new self('settings', 'Feature switches', null, self::GROUP_CONFIG, + 'The framework switches this application is running with, and where each value came from.'), // Both Data pages have a null `requires`: they read the container, not an actuator endpoint. // Datasource is offered whenever a database manager is bound; the browser has its own switch on @@ -76,6 +81,8 @@ public static function all(): array 'Connections, persistence settings and the compiled #[Transactional] contract.'), new self('data', 'Browse data', null, self::GROUP_DATA, 'Every repository this application declared, and the records behind it.'), + new self('data-map', 'Entity map', null, self::GROUP_DATA, + 'The entities and the foreign keys between them, drawn.'), ]; } diff --git a/packages/admin/tests/Settings/SettingsConsoleTest.php b/packages/admin/tests/Settings/SettingsConsoleTest.php new file mode 100644 index 0000000..5926a29 --- /dev/null +++ b/packages/admin/tests/Settings/SettingsConsoleTest.php @@ -0,0 +1,162 @@ + $config + */ +function settingsConsole(array $config, string $environment = 'local', ?string $dir = null): SettingsConsole +{ + $repository = new Repository(['app' => ['env' => $environment], ...$config]); + $wrapped = new Config($repository); + + return new SettingsConsole( + $wrapped, + $repository, + SettingsSettings::fromConfig($wrapped), + $dir ?? sys_get_temp_dir().'/firefly-settings-'.bin2hex(random_bytes(6)), + ); +} + +/** @return array */ +function settingsOn(bool $writable = false): array +{ + return ['firefly' => [ + 'admin' => ['settings' => ['enabled' => true, 'writable' => $writable]], + 'openapi' => ['enabled' => true], + ]]; +} + +function settingsDir(): string +{ + return sys_get_temp_dir().'/firefly-settings-'.bin2hex(random_bytes(6)); +} + +afterEach(function () { + foreach (glob(sys_get_temp_dir().'/firefly-settings-*/'.SettingsConsole::FILE) ?: [] as $file) { + @unlink($file); + @rmdir(dirname($file)); + } +}); + +it('is switched off by default, and reports every switch when it is on', function () { + // Off unlike every other dashboard page, because the others describe the application and this one + // changes it — a surface that alters a running system should never appear because a debug flag was left + // on somewhere. + expect(settingsConsole([])->isEnabled())->toBeFalse() + ->and(settingsConsole(settingsOn())->isEnabled())->toBeTrue() + ->and(settingsConsole(settingsOn())->toggles())->toHaveCount(count(FeatureToggle::all())); +}); + +it('separates seeing a switch from being able to flip it', function () { + $readOnly = settingsConsole(settingsOn()); + + expect($readOnly->isWritable())->toBeFalse() + ->and($readOnly->set('firefly.openapi.enabled', false))->toContain('writable') + ->and($readOnly->overrides())->toBe([]); +}); + +it('refuses every write in production, whatever the configuration says', function (string $environment) { + $live = settingsConsole(settingsOn(writable: true), $environment); + + expect($live->isProduction())->toBeTrue() + ->and($live->isWritable())->toBeFalse() + ->and($live->set('firefly.openapi.enabled', false))->toContain('production') + ->and($live->overrides())->toBe([]); +})->with(['production', 'prod', 'PRODUCTION']); + +it('cannot express a write to a key nobody put on the list', function () { + $writable = settingsConsole(settingsOn(writable: true)); + + // The check is against the fixed list, not a pattern — which is what makes this a feature switch rather + // than a remote configuration endpoint. A crafted POST naming a database host or the app key finds + // nothing to write. + foreach (['app.key', 'database.connections.mysql.host', 'logging.channels.stack.path', 'firefly.openapi'] as $key) { + expect($writable->set($key, true))->toContain('not a switch'); + } + + expect($writable->overrides())->toBe([]); +}); + +it('writes an override, reports it as the source, and clears it exactly', function () { + $writable = settingsConsole(settingsOn(writable: true), 'local', settingsDir()); + + expect($writable->set('firefly.openapi.enabled', false))->toContain('off') + ->and($writable->overrides())->toBe(['firefly.openapi.enabled' => false]); + + $rows = array_values(array_filter( + $writable->toggles(), + static fn (array $candidate): bool => $candidate['toggle']->key === 'firefly.openapi.enabled', + )); + + expect($rows)->toHaveCount(1); + $row = $rows[0]; + + expect($row['value'])->toBeFalse() + // The page must never show a value without saying where it came from: an override that looked like + // configuration would send someone hunting through files for a setting this page invented. + ->and($row['source'])->toBe('console') + ->and($row['overridden'])->toBeTrue(); + + expect($writable->reset())->toContain('Cleared') + ->and($writable->overrides())->toBe([]) + ->and(is_file($writable->file()))->toBeFalse(); +}); + +it('ignores anything in the override file that it would not have written', function () { + $dir = settingsDir(); + mkdir($dir, 0o775, true); + + // The file is on disk and a person can edit it. Filtering on the way IN as well as out means a + // hand-written entry — or one left by an older version of the list — cannot introduce a key the console + // would have refused, and cannot smuggle a non-boolean into config(). + file_put_contents($dir.'/'.SettingsConsole::FILE, (string) json_encode([ + 'firefly.openapi.enabled' => false, + 'app.key' => 'base64:planted', + 'firefly.admin.data.enabled' => 'yes please', + ])); + + expect(settingsConsole(settingsOn(writable: true), 'local', $dir)->overrides()) + ->toBe(['firefly.openapi.enabled' => false]); +}); + +it('survives an override file that is not json at all', function () { + $dir = settingsDir(); + mkdir($dir, 0o775, true); + file_put_contents($dir.'/'.SettingsConsole::FILE, 'this is not json'); + + expect(settingsConsole(settingsOn(), 'local', $dir)->overrides())->toBe([]); +}); + +it('merges its overrides into the live configuration', function () { + $dir = settingsDir(); + $repository = new Repository(['app' => ['env' => 'local'], ...settingsOn(writable: true)]); + $wrapped = new Config($repository); + $writable = new SettingsConsole($wrapped, $repository, SettingsSettings::fromConfig($wrapped), $dir); + + $writable->set('firefly.openapi.enabled', false); + $writable->apply(); + + // apply() is called from the provider's register(), before any settings object is built. It was + // originally called from the dashboard's own boot pass, which wrote the file and showed the new state on + // the page while /openapi.json kept answering 200 — every settings object in the framework is + // constructed once from config and held, so a merge after the first read changes nothing. + expect($repository->get('firefly.openapi.enabled'))->toBeFalse(); + + @unlink($writable->file()); + @rmdir($dir); +}); From 38a7f72751d98573e0e45187b9185c03c0dca89f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 19:46:36 -0700 Subject: [PATCH 25/31] feat(admin): a feature-switch console and a connection wizard, both gated like the hazards they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE FEATURE-SWITCH CONSOLE shows every framework switch this application is running with, where each value came from — your config, the framework's default, or this page — and outside production offers a control. THREE GATES, AND THE THIRD IS NOT A CONFIG KEY. `firefly.admin.settings.enabled` decides whether the page exists (off by default, unlike every other page here: the others describe the application and this one changes it). `.writable` decides whether it has controls. And `app.env` decides whether a write is possible at all — in production it is refused whatever the other two say, and nothing lifts that. Making the last gate unconfigurable is the difference between "we made it safe" and "we made it configurable to be safe", and only the first survives someone copying a .env. IT IS A FEATURE SWITCH, NOT A REMOTE CONFIGURATION ENDPOINT. The list is fixed and framework-owned, so a crafted POST naming `app.key` or a database host finds nothing to write — the method cannot express it. A write goes to one json file that is filtered on the way in as well as out, so a hand-edited entry cannot introduce a key the console would have refused, and deleting it restores your configured values exactly. Never to `.env`: a config cache would disagree with it until someone cleared it, the file is routinely read-only in an image, and a web form that edits the file holding your database password is not a feature. THE MERGE HAPPENS IN register(), and that was found the hard way. Applied from the dashboard's own boot pass it wrote the file and showed the new state on the page while /openapi.json kept answering 200 — every settings object in this framework is built once from config and held, so a merge after the first read changes nothing. Verified end to end afterwards: toggling the switch takes /openapi.json from 200 to 404 and the welcome page's card with it, and clearing restores both. THE CONNECTION WIZARD collapses the edit-.env, clear-cache, reload, read-a-useless-error loop into one form: the connection is opened there, and what comes back is the server's version or the driver's own message. Same gating, for a sharper reason: a form that opens a socket to a host somebody typed is a request-forgery primitive, and its errors distinguish "refused" from "timed out" well enough to map a private network. Off by default, refused in production, and POST-only so a link, an image tag or a prefetch can never reach it. It never writes anything — the result is a config block to paste, with the password always an `env()` call and never the value that was typed. getPdo() is forced open BEFORE the test query, and that ordering is the whole feature. Going through selectOne() puts Laravel's reconnect wrapper in the way, which rethrows "Lost connection and no reconnector available" for a wrong password, a closed port and a typo in the host alike; forcing the socket and unwrapping to the innermost exception gives back `connection to server at "127.0.0.1", port 5432 failed: Connection refused` and `Operation timed out` as different sentences, which is the only reason to have the button. 2054 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- .../resources/views/datasource.blade.php | 52 +++++ .../admin/resources/views/layout.blade.php | 2 + .../admin/src/Boot/AdminRouteRegistrar.php | 3 + packages/admin/src/Data/ConnectionWizard.php | 220 ++++++++++++++++++ packages/admin/src/Web/AdminAction.php | 26 +++ .../admin/tests/Data/ConnectionWizardTest.php | 88 +++++++ 6 files changed, 391 insertions(+) create mode 100644 packages/admin/src/Data/ConnectionWizard.php create mode 100644 packages/admin/tests/Data/ConnectionWizardTest.php diff --git a/packages/admin/resources/views/datasource.blade.php b/packages/admin/resources/views/datasource.blade.php index 1128f51..a30df8a 100644 --- a/packages/admin/resources/views/datasource.blade.php +++ b/packages/admin/resources/views/datasource.blade.php @@ -107,6 +107,58 @@

    + {{-- THE WIZARD. A POST only: a GET must never be able to open an outbound socket to a host somebody + put in a URL, which keeps this surface out of reach of a link, an image tag or a prefetch. --}} + @if ($wizard->isAvailable()) +
    + + Try a connection + + nothing is written + + + @isset($trial) +

    + {{ $trial['ok'] ? 'Works' : 'Failed' }}. + {{ $trial['message'] }} + @if ($trial['version'] !== '') · server {{ $trial['version'] }}@endif +

    + @if ($trial['snippet'] !== '') +
    {{ $trial['snippet'] }}
    + @endif + @endisset + +
    + @csrf +
    + + + +
    +
    + + + +
    +
    + + The settings are used for this request only — nothing is saved, and the + password never appears in the snippet. +
    +
    +
    + @elseif ($wizard->isProduction()) +

    + The connection wizard is unavailable in production, and no configuration key changes that: a form + that opens a socket to a host you type is a request-forgery tool, and its error messages + distinguish “refused” from “timed out” well enough to map a private network. +

    + @endif +
    @include('firefly-admin::_panel-head', [ 'title' => 'Transactional methods', 'count' => count($transactional), diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php index 2892daf..9b0eeaa 100644 --- a/packages/admin/resources/views/layout.blade.php +++ b/packages/admin/resources/views/layout.blade.php @@ -412,6 +412,8 @@ svg.emap a:focus-visible .ebox{stroke:var(--accent);stroke-width:2} .stat dd.bad{color:var(--down)} .tip.warnbox{background:var(--warn-bg);color:var(--warn)} + pre.snippet{margin:12px 16px 0;padding:12px 14px;background:var(--panel-2);border:1px solid var(--line); + border-radius:8px;font-family:var(--mono);font-size:12px;overflow-x:auto;color:var(--ink-2);white-space:pre} .tip.warnbox strong{color:inherit} .pair b{font-weight:600;color:var(--ink-3)} .pair.opt b{color:var(--accent)} diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php index d36f4eb..b37781e 100644 --- a/packages/admin/src/Boot/AdminRouteRegistrar.php +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -10,6 +10,7 @@ use Firefly\Actuator\Server\ManagementServerSettings; use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; +use Firefly\Admin\Data\ConnectionWizard; use Firefly\Admin\Data\DataBrowser; use Firefly\Admin\Data\DataBrowserSettings; use Firefly\Admin\Data\DataQueryEngine; @@ -80,6 +81,7 @@ public function run(BootContext $context): void // Assembled here for the same reason DataBrowser is: it must exist even when there is no database // manager to describe, because the page's job in that case is to say so. $container->singleton(DatasourceReport::class, static fn (): DatasourceReport => DatasourceReport::forContainer($container)); + $container->singleton(ConnectionWizard::class, static fn (): ConnectionWizard => ConnectionWizard::forContainer($container, $context->config)); $container->singleton(DataBrowser::class, static function () use ($container, $context): DataBrowser { $settings = DataBrowserSettings::fromConfig($context->config); @@ -111,6 +113,7 @@ public function run(BootContext $context): void $container->make(DataBrowser::class), $container->make(DatasourceReport::class), $container->make(SettingsConsole::class), + $container->make(ConnectionWizard::class), )); /** @var Router $router */ diff --git a/packages/admin/src/Data/ConnectionWizard.php b/packages/admin/src/Data/ConnectionWizard.php new file mode 100644 index 0000000..1c2ee88 --- /dev/null +++ b/packages/admin/src/Data/ConnectionWizard.php @@ -0,0 +1,220 @@ + 3306, 'mariadb' => 3306, 'pgsql' => 5432, 'sqlsrv' => 1433, 'sqlite' => 0]; + + public function __construct( + private readonly ?ConnectionFactory $factory, + private readonly bool $enabled, + private readonly bool $production, + ) {} + + public static function forContainer(Container $container, Config $config): self + { + $factory = null; + try { + $factory = $container->make(ConnectionFactory::class); + } catch (Throwable) { + } + + $environment = strtolower($config->string('app.env', 'production')); + + return new self( + $factory, + $config->bool('firefly.admin.datasource.wizard', false), + in_array($environment, ['production', 'prod'], true), + ); + } + + public function isAvailable(): bool + { + return $this->enabled && ! $this->production && $this->factory !== null; + } + + public function isProduction(): bool + { + return $this->production; + } + + /** @return list */ + public function drivers(): array + { + return array_keys(self::DRIVERS); + } + + public function defaultPort(string $driver): int + { + return self::DRIVERS[$driver] ?? 0; + } + + /** + * Open the connection described by $input and report what happened. + * + * @param array $input + * @return array{ok: bool, message: string, version: string, snippet: string} + */ + public function test(array $input): array + { + if (! $this->isAvailable() || $this->factory === null) { + return [ + 'ok' => false, + 'message' => $this->production + ? 'Refused: the wizard is unavailable in production, and no configuration key changes that.' + : 'Refused: set firefly.admin.datasource.wizard to use it.', + 'version' => '', + 'snippet' => '', + ]; + } + + $settings = $this->normalise($input); + + if (! in_array($settings['driver'], $this->drivers(), true)) { + return ['ok' => false, 'message' => 'That is not a driver this wizard knows.', 'version' => '', 'snippet' => '']; + } + + try { + $connection = $this->factory->make($settings); + + // getPdo() FIRST, and that ordering is the whole difference between a useful failure and a + // useless one. Going through selectOne() puts Laravel's reconnect wrapper in the way, which + // catches the driver's exception and rethrows "Lost connection and no reconnector available" — + // the same sentence for a wrong password, a closed port and a typo in the host. Forcing the + // connection open directly lets the driver's own message through. + $pdo = $connection->getPdo(); + $attribute = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION); + $version = is_scalar($attribute) ? (string) $attribute : ''; + + // And a real statement after it, because a PDO handle proves the socket opened and the + // credentials were accepted — not that the DATABASE named exists and is readable. + $connection->selectOne('select 1'); + $connection->disconnect(); + + return [ + 'ok' => true, + 'message' => 'The connection opened and answered a query.', + 'version' => $version, + 'snippet' => $this->snippet($settings), + ]; + } catch (Throwable $e) { + // The driver's own message, verbatim, and the deepest one in the chain: "could not connect" is + // the least useful thing to say here, and the whole point is that `password authentication + // failed for user "app"` and `no such host` send you to different places. + return ['ok' => false, 'message' => $this->deepest($e), 'version' => '', 'snippet' => '']; + } + } + + /** + * The innermost message in an exception chain. + * + * Laravel wraps a connection failure at least once and sometimes twice, and every wrapper's message is + * less specific than the one it wrapped. The driver sits at the bottom. + */ + private function deepest(Throwable $e): string + { + while ($e->getPrevious() !== null) { + $e = $e->getPrevious(); + } + + return $e->getMessage(); + } + + /** + * @param array $input + * @return array + */ + private function normalise(array $input): array + { + $driver = strtolower(trim($input['driver'] ?? 'mysql')); + $get = static fn (string $key, string $fallback = ''): string => trim($input[$key] ?? '') !== '' ? trim($input[$key]) : $fallback; + + if ($driver === 'sqlite') { + return ['driver' => 'sqlite', 'database' => $get('database', ':memory:'), 'prefix' => '', 'foreign_key_constraints' => true]; + } + + return [ + 'driver' => $driver, + 'host' => $get('host', '127.0.0.1'), + 'port' => (int) $get('port', (string) $this->defaultPort($driver)), + 'database' => $get('database'), + 'username' => $get('username'), + 'password' => $input['password'] ?? '', + 'charset' => $get('charset', $driver === 'pgsql' ? 'utf8' : 'utf8mb4'), + 'prefix' => '', + // A wizard that hung for the driver's default timeout — thirty seconds on some, none at all on + // others — would look broken on exactly the wrong host. + 'options' => [PDO::ATTR_TIMEOUT => 5], + ]; + } + + /** + * The `config/database.php` block for settings that worked — with the password as an `env()` call, never + * inlined. A wizard that printed a working credential into a file people paste into a repository would + * be a very effective way of leaking one. + * + * @param array $settings + */ + private function snippet(array $settings): string + { + $string = static fn (string $key): string => is_scalar($settings[$key] ?? null) ? (string) $settings[$key] : ''; + + if ($string('driver') === 'sqlite') { + return "'sqlite' => [\n" + ." 'driver' => 'sqlite',\n" + ." 'database' => env('DB_DATABASE', database_path('database.sqlite')),\n" + ." 'prefix' => '',\n" + .'],'; + } + + return sprintf( + "'%s' => [\n" + ." 'driver' => '%s',\n" + ." 'host' => env('DB_HOST', '%s'),\n" + ." 'port' => env('DB_PORT', '%s'),\n" + ." 'database' => env('DB_DATABASE', '%s'),\n" + ." 'username' => env('DB_USERNAME', '%s'),\n" + ." 'password' => env('DB_PASSWORD', ''),\n" + ." 'charset' => '%s',\n" + ." 'prefix' => '',\n" + .'],', + $string('driver'), + $string('driver'), + $string('host'), + $string('port'), + $string('database'), + $string('username'), + $string('charset'), + ); + } +} diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index d3bb845..4d615fb 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -8,6 +8,7 @@ use Firefly\Admin\AdminEndpointReader; use Firefly\Admin\AdminSettings; use Firefly\Admin\BeanGraph; +use Firefly\Admin\Data\ConnectionWizard; use Firefly\Admin\Data\DataBrowser; use Firefly\Admin\Data\DataFilter; use Firefly\Admin\Data\DataMap; @@ -42,6 +43,7 @@ public function __construct( private DataBrowser $data, private DatasourceReport $datasource, private SettingsConsole $console, + private ConnectionWizard $wizard, ) {} public function __invoke(Request $request, string $page = ''): SymfonyResponse @@ -167,6 +169,12 @@ private function datasourcePage(Request $request, AdminPage $current): SymfonyRe $known = array_column($connections, 'name'); $probe = in_array($probing, $known, true) ? ['name' => $probing, ...$this->datasource->probe($probing)] : null; + // The wizard runs only on a POST — a GET can never open an outbound socket to a caller-supplied + // host, which keeps the whole surface out of reach of a link, an image tag or a prefetch. + $trial = $request->isMethod('POST') && $this->wizard->isAvailable() + ? $this->wizard->test($this->wizardInput($request)) + : null; + return $this->html($this->render('datasource', [ 'available' => $this->datasource->available(), 'default' => $this->datasource->defaultConnection(), @@ -175,9 +183,27 @@ private function datasourcePage(Request $request, AdminPage $current): SymfonyRe 'transactional' => $this->datasource->transactionalMethods(), 'probe' => $probe, 'probeEnabled' => $this->datasource->probeEnabled(), + 'wizard' => $this->wizard, + 'trial' => $trial, + 'trialInput' => $this->wizardInput($request), ], $current), 200); } + /** + * @return array + */ + private function wizardInput(Request $request): array + { + $input = []; + + foreach (['driver', 'host', 'port', 'database', 'username', 'password', 'charset'] as $field) { + $value = $request->input($field); + $input[$field] = is_scalar($value) ? (string) $value : ''; + } + + return $input; + } + /** * An edit or a delete from the record page. * diff --git a/packages/admin/tests/Data/ConnectionWizardTest.php b/packages/admin/tests/Data/ConnectionWizardTest.php new file mode 100644 index 0000000..1aaa6bc --- /dev/null +++ b/packages/admin/tests/Data/ConnectionWizardTest.php @@ -0,0 +1,88 @@ + new ConnectionWizard( + new ConnectionFactory(app()), + $enabled, + $production, +); + +it('is unavailable until it is switched on, and refuses rather than silently doing nothing', function () use ($wizard) { + $off = $wizard(enabled: false); + + expect($off->isAvailable())->toBeFalse() + ->and($off->test(['driver' => 'sqlite', 'database' => ':memory:'])['ok'])->toBeFalse() + ->and($off->test(['driver' => 'sqlite', 'database' => ':memory:'])['message'])->toContain('wizard'); +}); + +it('is unavailable in production whatever the key says', function () use ($wizard) { + $live = $wizard(enabled: true, production: true); + + expect($live->isAvailable())->toBeFalse() + ->and($live->isProduction())->toBeTrue() + ->and($live->test(['driver' => 'sqlite', 'database' => ':memory:'])['message'])->toContain('production'); +}); + +it('opens a connection that works and hands back a config block', function () use ($wizard) { + $result = $wizard()->test(['driver' => 'sqlite', 'database' => ':memory:']); + + expect($result['ok'])->toBeTrue() + ->and($result['version'])->not->toBe('') + ->and($result['snippet'])->toContain("'driver' => 'sqlite'"); +}); + +it('never inlines a password into the config block it hands back', function () use ($wizard) { + // A wizard that printed a working credential into a block people paste into a repository would be an + // efficient way to leak one, so the snippet always spells the password as an env() call. + $result = $wizard()->test(['driver' => 'mysql', 'host' => '127.0.0.1', 'database' => 'x', 'username' => 'u', 'password' => 'hunter2-in-the-clear']); + + expect($result['snippet'])->not->toContain('hunter2-in-the-clear'); + + // And on the success path, where a snippet is actually produced. + $sqlite = $wizard()->test(['driver' => 'sqlite', 'database' => ':memory:']); + expect($sqlite['snippet'])->not->toContain('hunter2-in-the-clear') + ->toContain("env('DB_DATABASE'"); +}); + +it('reports the driver\'s own message when a connection fails', function () use ($wizard) { + // Going through selectOne() puts Laravel's reconnect wrapper in the way, which rethrows "Lost connection + // and no reconnector available" for a wrong password, a closed port and a typo in the host alike. The + // wizard forces the PDO open first and unwraps to the innermost exception, so the message that comes + // back is the one that tells you where to look. + $result = $wizard()->test(['driver' => 'sqlite', 'database' => '/no/such/directory/at/all.sqlite']); + + expect($result['ok'])->toBeFalse() + ->and($result['message'])->not->toContain('no reconnector') + // Specific enough to act on: it names the path it tried, which is the whole difference from the + // wrapper's one-size-fits-all sentence. + ->and($result['message'])->toContain('/no/such/directory/at/all.sqlite'); +}); + +it('refuses a driver it does not know rather than handing it to a connector', function () use ($wizard) { + expect($wizard()->test(['driver' => 'redis', 'host' => 'somewhere'])['message'])->toContain('not a driver'); +}); + +it('never writes anything', function () { + // The result is a snippet to paste, not a file edit. Persisting a connection would mean writing + // credentials from a browser form into a file on disk, and telling you whether the settings work does + // not require that. + expect(get_class_methods(ConnectionWizard::class)) + ->not->toContain('save') + ->not->toContain('persist') + ->not->toContain('write'); +}); From 5aa82d8d44757f0b15e507c567eacc6be610d357 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 19:54:29 -0700 Subject: [PATCH 26/31] docs: the reference documents every key again, and a test keeps it that way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skeleton/config/firefly.php is not a config file in the ordinary sense — almost all of it is commented out at its own default. It is the DOCUMENTATION, shipped where people will actually find it, which makes it exactly the kind of file that rots: a key is added to a settings class during a feature, the feature ships, and the only place anybody would have learned about the key never mentions it. THREE HAD ALREADY DRIFTED OUT, and nothing would ever have said so. `firefly.management.server.address` is half of the management-port feature — the split between the application's port and the operator's — and the whole `server` block was undocumented; `firefly.openapi.summary` and `.terms-of-service` are Info Object members. All three are now written up, along with everything this session added: `firefly.web.error-page.{json-paths,views}`, `firefly.admin.data.relations`, `firefly.admin.datasource.{probe,wizard}` and `firefly.admin.settings.{enabled,writable}`. tests/ConfigReferenceTest.php holds the line: 84 keys read through the Config port, 0 undocumented. Adding one to a settings class and not the reference fails the build — verified by adding one and watching it fail. It matches on the leaf key rather than the dotted path, deliberately: the reference is a nested array so a dotted key never appears literally in it, and a weaker check that still catches "nobody wrote anything about this" beats a stricter one that would need to evaluate a file that is 90% comments. THE MODULE PAGES CAUGHT UP with the code, including two that had to argue against their own earlier selves: data-browser's "Create is deliberately absent" is now "Create exists for Eloquent, and is refused for everything else". The constructor-invariants argument was half right and is kept verbatim for the case it was right about; it was never true for a model Eloquent builds empty and fills by attribute, which is what update() has always done. Its "why decimal and float map to string" note is now "float never reformats the value". The precision argument survives intact and is the reason the cell prints verbatim and a write is VALIDATED as numeric but STORED as the string — casting to a PHP float would reintroduce exactly the loss a decimal column exists to avoid. That is a code change this documentation pass found: `12345678901234567890.12` now round-trips. openapi gained "What an endpoint returns" and the type-expression table; error-handling gained the negotiation table and the HTML page; admin gained the datasource page, the entity map and the feature-switch console, each with its gates spelled out. 2061 tests pass, PHPStan max clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- docs/modules/admin.md | 102 ++++++++++++++++- docs/modules/data-browser.md | 130 ++++++++++++++++------ docs/modules/error-handling.md | 68 +++++++++++- docs/modules/openapi.md | 120 ++++++++++++++++++-- packages/admin/src/Data/DataBrowser.php | 10 +- skeleton/config/firefly.php | 140 +++++++++++++++++++++++- tests/ConfigReferenceTest.php | 80 ++++++++++++++ 7 files changed, 602 insertions(+), 48 deletions(-) create mode 100644 tests/ConfigReferenceTest.php diff --git a/docs/modules/admin.md b/docs/modules/admin.md index ca87191..007ef3c 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -25,7 +25,7 @@ install line above. [Access: the whole security boundary](#access-the-whole-security-boundary) before enabling it outside debug. -## The thirteen pages +## The pages Every page is a view over one `ActuatorEndpoint`'s payload. The menu groups them the way an operator thinks rather than the way the packages are laid out — *what is it doing right now*, *what did it wire at boot*, *how is it @@ -46,6 +46,13 @@ configured* — because a flat list of thirteen links is a worse menu than three | Configuration | Config properties | `/firefly/configprops` | `configprops` | Every `#[ConfigProperties]` DTO the application bound, with the values it resolved | | Configuration | Caches | `/firefly/caches` | `caches` | The cache stores this application has configured | | Configuration | Loggers | `/firefly/loggers` | `loggers` | Log channels and their levels, with a control to change one | +| Configuration | **Feature switches** | `/firefly/settings` | — | Every framework switch, where its value came from, and — outside production — a control. **Off by default**; see [below](#the-feature-switch-console) | +| Data | **Datasource** | `/firefly/datasource` | — | Connections, connection reuse, and the compiled `#[Transactional]` contract | +| Data | **Browse data** | `/firefly/data` | — | The records behind your repositories — see [Data Browser](data-browser.md). **Off by default** | +| Data | **Entity map** | `/firefly/data-map` | — | The entities and the foreign keys between them, drawn | + +The four pages with no endpoint read the container rather than the actuator, and each decides its own visibility: +an entry that led to "there is nothing here" is worse than no entry. A page whose endpoint is **not registered in this process** — or is switched off — is *hidden from the menu* rather than offered as a link that lands on an apology, and requesting it directly answers 404 with a page saying which @@ -227,16 +234,104 @@ Under PHP-FPM every request is a different process, and three pages inherit that | `firefly.admin.theme` | `'auto'` | `auto` \| `light` \| `dark`. Anything unrecognised falls back to `auto` (follow the operating system) rather than rendering unstyled. | | `firefly.admin.graph.max-nodes` | `220` | The ceiling past which the [bean graph](bean-graph.md) lists relations instead of drawing them. Clamped to a minimum of `0`, which suppresses the diagram entirely. | | `firefly.admin.pages.exclude` | `''` | CSV of page slugs to refuse. This is a **refusal, not a menu preference**: an excluded page is hidden *and* its URL 404s — hiding `env` from the menu achieves nothing if the URL still answers. Use `overview` for the index page. | +| `firefly.admin.datasource.probe` | `true` | Whether the datasource page may **open** a configured connection to report that it answers. | +| `firefly.admin.datasource.wizard` | **`false`** | The connection wizard. Off by default and refused in production — see [below](#the-connection-wizard). | +| `firefly.admin.settings.enabled` | **`false`** | The feature-switch console. | +| `firefly.admin.settings.writable` | **`false`** | Whether that console has controls. Ineffective in production. | The `firefly.admin.data.*` keys are documented separately, in [Data Browser](data-browser.md#configuration-fireflyadmindata), because the browser is gated independently of everything above: `firefly.admin.enabled` does **not** switch it on, and neither does `app.debug`. +## The datasource page + +Four questions an operator asks at 3am that this dashboard could not answer: + +- **Which database am I talking to?** Driver, host, port and database per connection, with `password` masked by the + same masker the actuator's `env` endpoint uses — the page is behind the dashboard's gate, and a connection array + dumped verbatim would put the database password on that URL. +- **Is it up?** *One* connection is probed per page load — the default, or the one named by `?probe=` — because + opening a socket can hang against a firewalled host, and a page that opened every configured connection would + take the slowest one's timeout to render, on the page you opened *because* something is wrong. What comes back + is the server version, or the driver's own message. +- **What does pooling mean here?** PHP has no connection pool, and a "pool size" gauge would be an invented number. + What exists is PDO's `ATTR_PERSISTENT`, reported as what it is — with the note that under php-fpm the effective + pool size is your worker count, decided by the process manager, and that real pooling in front of Postgres is + pgbouncer's job. +- **What did `#[Transactional]` compile to?** One row per proxied method with its propagation, isolation, timeout + and connection. It existed only as a compiled artifact under `bootstrap/cache`. + +### The connection wizard + +`firefly.admin.datasource.wizard` adds a form that opens a connection you have **not** configured yet and reports +the server version or the driver's own error, plus the `config/database.php` block to paste. It collapses the +edit-`.env`, clear-cache, reload, read-a-useless-error loop into one round trip. + +It is **off by default and refused outright when `app.env` is production**, and no configuration key lifts that. A +form that opens a socket to a host somebody typed is a request-forgery primitive by construction, and its failure +messages distinguish "refused" from "timed out" well enough to map a private network. It is POST-only for the same +reason — a link, an image tag or a prefetch must never be able to reach it — and it **writes nothing**: the result +is a snippet, with the password always an `env()` call and never the value that was typed. + +!!! note "Why the errors are useful at all" + The connection is opened with `getPdo()` *before* the test query. Going through `selectOne()` puts Laravel's + reconnect wrapper in the way, which rethrows `Lost connection and no reconnector available` for a wrong + password, a closed port and a typo in the host alike. Forcing the socket and unwrapping to the innermost + exception is what turns the button into something worth pressing. + +## The entity map + +`/firefly/data-map` draws every browsable entity as a box with its columns, and every foreign key as a labelled +edge, from the same discovery the [data browser](data-browser.md#relations) walks. Boxes are links into their own +records. + +A `hasMany` and the `belongsTo` facing it are **one** key seen from two ends, so each is drawn once — pointing from +the table that *holds* the key to the table it references, which is also what the arrow means. Relations the +browser cannot express as a single column comparison (a pivot, a polymorphic type column) are listed on each record +page but are not drawn, because a line with no join to name would be decoration. Entities with no relations at all +*are* drawn: a standalone table is a fact about the model, and a diagram that quietly dropped it would let a reader +conclude the application has fewer tables than it does. + +It is behind the browser's own switch, not the dashboard's: a schema diagram names every table and column an +application has, which is the shape of its data even though it is not the data. + +## The feature-switch console + +`/firefly/settings` is the one page that **changes** the application rather than describing it, and it is gated +accordingly. + +| Gate | Default | What it decides | +|---|---|---| +| `firefly.admin.settings.enabled` | `false` | Whether the page exists at all | +| `firefly.admin.settings.writable` | `false` | Whether it has controls as well as readings | +| `app.env` is `production` | — | **Not a configuration key.** Writes are refused, whatever the two above say | + +The third gate is deliberately unconfigurable. That is the difference between "we made it safe" and "we made it +configurable to be safe", and only the first survives someone copying a `.env`. + +**It is a feature switch, not a remote configuration endpoint.** The list of switches is fixed and framework-owned, +so a crafted POST naming `app.key` or a database host finds nothing to write — the method cannot express it. Each +row shows where its value came from: `config` (yours), `default` (the framework's), or `console` (this page). + +A change is written to **one** JSON file under `bootstrap/cache`, filtered on the way in as well as out — a +hand-edited entry cannot introduce a key the console would have refused — and merged over configuration during the +provider's `register()`. Deleting that file restores your configured values exactly. Nothing is ever written to +`.env`: a config cache would disagree with it until someone cleared it, the file is routinely read-only in a +container image, and a web form that edits the file holding your database password is not a feature. + +!!! note "Why `register()` and not a boot pass" + Every settings object in this framework is built once from configuration and held for the process. Applying the + overrides from the dashboard's own boot pass wrote the file and showed the new state on the page while + `/openapi.json` kept answering 200 — a merge after the first read changes nothing. `register()` runs before any + boot pass and before any bean resolves, which is the only point at which the merge is true. + ## Laravel comparison | Concern | Plain Laravel | LaraFly (`firefly/admin`) | |---|---|---| | A management UI | none first-party; Telescope is a *request* debugger, Horizon a *queue* dashboard — neither reports on wiring or configuration | one dashboard over the actuator's own endpoints | +| Browsing your data | none; Nova and Filament are paid or app-scale admin *frameworks* you build screens in | a Django-style browser over the repositories you already declared, off by default | +| Feature switches | a config file and a deploy | a gated console, with the production gate not configurable | | Where it runs | Telescope/Horizon each add tables, a service provider and a middleware group | Blade views over beans that already exist; no storage of its own, nothing recorded | | Data source | a recorder writing to the database | the live `ActuatorRegistry`, read in-process at render time | | Enabling it safely | `TelescopeServiceProvider::gate()` — a closure you write | `firefly.admin.enabled` defaulting to `app.debug`, plus your own middleware when you override it | @@ -246,8 +341,9 @@ and neither does `app.debug`. - **No instance registry.** Spring Boot Admin is a separate server that many applications register *with*, giving one console across a fleet. This is a per-instance dashboard, which is what makes the in-process read possible; a fleet view would need a different design and is not planned. -- **No write operations besides the log level.** `/caches` is read-only for the same reason it is read-only on the - JSON surface — `firefly/actuator` carries no code edge to `firefly/security` and so cannot say who asked. +- **No write operations besides the log level, the data browser and the feature switches.** `/caches` is read-only + for the same reason it is read-only on the JSON surface — `firefly/actuator` carries no code edge to + `firefly/security` and so cannot say who asked. - **`when-authorized` health details** degrade to `never` on the JSON surface (see [Actuator](actuator.md#known-latent)); the dashboard sidesteps it entirely by reading the contributor registry. diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md index d6d6b27..98f0cae 100644 --- a/docs/modules/data-browser.md +++ b/docs/modules/data-browser.md @@ -104,28 +104,27 @@ a table also armed the delete button. A write attempted with only one gate set is **refused with a stated reason**, not silently ignored. -## Create is deliberately absent +## Create exists for Eloquent, and is refused for everything else -There is no `create()`, and that is not an omission to be filled in later. +The browser once had no `create()` at all, and the argument for that was half right. -A generic create form over an arbitrary entity is a promise the browser cannot keep. **An aggregate's -constructor is where its invariants live** — an `Order` that must have at least one line, a `Wallet` whose -balance starts at zero in the currency it was opened in, a value object that rejects a malformed IBAN — and a -form built from a column list knows none of them. +**An aggregate's constructor is where its invariants live** — an `Order` that must have at least one line, a +`Wallet` whose balance starts at zero in the currency it was opened in, a value object that rejects a malformed +IBAN — and a form built from a column list knows none of them. For a repository over a hand-written domain object +there are only two ways to build the row and both are wrong: call the constructor, which needs arguments the form +cannot supply in the right types or the right order; or write the columns straight to the table, which produces a +row the domain model considers impossible and which every later read then has to cope with. The second is what a +"just insert the columns" implementation actually does, and it is *worse than having no button*, because it looks +like it worked. **That case is still refused, by name.** -There are only two ways to build the row, and both are wrong: +It was never true for an **Eloquent model**. Eloquent constructs one empty and fills it by attribute — which is +exactly what `update()` has always done to a row that exists. Create was refusing on a risk update was already +taking, and the inconsistency cost every application a CRUD surface that stopped at RUD. -1. **Call the constructor** — which needs arguments the form cannot supply in the right types or the right - order, and which fails on the first entity with a non-trivial signature. -2. **Write the columns straight to the table** — which produces a row the domain model considers impossible, - and which every later read then has to cope with. - -The second is what a "just insert the columns" implementation actually does, and it is *worse than having no -button*, because it looks like it worked. Creation belongs to the application's own code, where the constructor -is. - -The two writes that do exist pass that test. `update()` operates on a row that **already satisfies its -invariants** and changes named columns on it; `delete()` needs no invariant at all. +So: `create()` is offered when the resource is Eloquent-backed, under the same two switches, the same coercion and +the same unknown-field refusal. The identifier and any masked column are **omitted from the form** rather than +disabled in it — a field the browser would refuse to write should not appear to accept — so a crafted POST cannot +choose a primary key or plant a value the page would only ever show as `******`. ## Reads: four paths, and one of them is a foot-gun @@ -202,19 +201,21 @@ a number. The model's own `$casts` carry the semantic type the schema cannot exp ### The display type vocabulary is closed, and deliberately small -`string`, `int`, `bool`, `datetime`, `json`. It is a **rendering hint, not a schema echo**: the view has to -decide "right-align this", "draw a checkbox", "format this as a timestamp", "pretty-print this blob", and there -are only those four decisions plus a default. Anything outside the vocabulary degrades to `string` rather than -reaching the view. +`string`, `int`, `float`, `bool`, `datetime`, `json`. It is a **rendering hint, not a schema echo**: the view has +to decide "right-align this", "draw a chip", "format this as a timestamp", "clip this blob", and there are only +those decisions plus a default. Anything outside the vocabulary degrades to `string` rather than reaching the view. + +!!! note "`float` never reformats the value" + A `decimal(10,2)` column arrives from PDO as the string `"10.10"`, and that is not an accident of the driver + — it is how the value survives a round trip without binary floating point eating the last cent. Every + non-integer number used to be typed `string` for that reason, which meant a money column read as a string in + the explorer, was offered to a `LIKE` search, and let the editor save `"abc"` into it. -!!! note "Why `decimal` and `float` map to `string`" - A `decimal(10,2)` column arrives from PDO as the string `"10.10"`, and that is not an accident of the - driver — it is how the value survives a round trip without binary floating point eating the last cent. - Typing it `int`/`float` invites the view to format it as a number, and the first thing a number formatter - does to `"10.10"` is render it as `10.1`. **A browser that silently rewrites a money column is worse than - one that shows the raw text**, so the raw text is what the type promises. `int` is reserved for genuinely - integral columns — keys, counters, foreign keys — where right-aligning is correct and no precision can be - lost. + `float` is a hint about **alignment and validation**, not about formatting. The cell prints the value + verbatim, so `"10.10"` renders as `10.10`; it is right-aligned with tabular numerals so digits line up down + the column. And a write is **validated** with `is_numeric` but **stored as the string**: casting to a PHP + float to store it would reintroduce exactly the precision loss a `decimal` column exists to avoid — + `12345678901234567890.12` does not survive a `float`, and the driver can bind the digits verbatim. ### The identifier is derived, and allowed to be null @@ -231,6 +232,71 @@ supply present as `null` rather than missing. `getAttributes()` returns keys in returned them, which differs between drivers and can differ between two rows of the same table after a migration adds a column — and a detail page whose fields move between rows is unreadable. +## Relations + +An entity's relations are discovered by **calling** the methods that declare one, because that is the only way to +learn which columns they join on: a method's name says nothing and its return type says only the kind. + +Which makes "what is safe to call" the load-bearing question, and the answer is the **declared return type**. Only +a public, non-static, no-argument method whose return type is an Eloquent `Relation` subclass is ever called — a +method announcing `: HasMany` is a relation definition by construction, the same signal Laravel's own IDE tooling +and `with()` validation rely on, and one an accessor cannot claim without lying about its signature. Anything +without that annotation is left alone. The call itself executes no query: Eloquent defers until `get()`. + +```php +class OrderEntity extends Model +{ + /** @return HasMany */ + public function lines(): HasMany + { + return $this->hasMany(OrderLineEntity::class, 'order_id'); + } +} +``` + +| Relation | On the record page | Where it goes | +|---|---|---| +| `BelongsTo` | **Open →** | the one parent record | +| `HasOne`/`HasMany` | **Browse →** | the child listing, filtered to this row's key | +| `BelongsToMany`, `HasManyThrough`, the morph family | listed, not linked | no single column to filter on | +| `MorphTo` | listed, not linked | the other end is decided per row by a type column | + +A relation whose other end is **not a browsable resource** — no repository declares it, or its resource is excluded +— is still shown, because it tells a reader the shape of the model, but is not rendered as a link: distinguishing +the two in the model rather than in the template is what stops a view minting a URL that 404s. + +## Filtering, sorting and paging + +The listing is a **URL**. Every filtered, sorted, paged view is something an operator can bookmark, paste into a +ticket or hand to someone else, which is most of what a data explorer is for — and every link on the page carries +the whole state, because a sort that dropped the filter would widen the listing back to every row, which reads as +rows appearing from nowhere. + +Eight comparisons, over the columns the resource already publishes: + +| Operator | Meaning | +|---|---| +| `eq`, `ne` | `=`, `!=` | +| `contains`, `starts` | `LIKE`, with the wildcards added around an **escaped** value | +| `gt`, `lt` | `>`, `<` | +| `null`, `notnull` | `IS NULL`, `IS NOT NULL` | + +Two spellings in the URL: `?fk=order_id&fv=7` is a single equality and is what every relation link produces — +short enough to read in a status bar — while `?fc[]=…&fo[]=…&fv[]=…` is what the filter bar builds. Both are +validated identically. + +**A column the schema does not publish, and an operator outside that set, are dropped** rather than passed to the +driver. Both arrive in a URL an operator can hand-edit, and a query that reached the driver with an arbitrary +identifier in it is a column-name oracle at best. Dropping rather than erroring is deliberate too: an error that +distinguished "no such column" from "no rows" would answer the same question more slowly. + +Filters **AND** with each other and with the search box, so narrowing a relation's listing cannot escape it. Every +comparison binds its value, including the `LIKE` ones. + +The same eight comparisons are implemented for the [in-PHP fallback path](#reads-four-paths-and-one-of-them-is-a-foot-gun), +because a repository that cannot page must be filtered by the same rules as one that can — two implementations +would drift, and the drift would show as one filter meaning different things on different resources. + ## Secrets Sensitivity is decided **by name, in one place**: the actuator's own `SensitiveValueMasker`, the same rule that @@ -308,6 +374,7 @@ class name. The message stays in the exception, where a log can have it. | `firefly.admin.data.page-size` | `25` | Default rows per page. Clamped into `[1, max-page-size]`. | | `firefly.admin.data.max-page-size` | `200` | Ceiling applied to any caller-supplied page size. Itself capped at **1000**, because `?perPage=1000000` on a resource that cannot page is a request to materialise the table into PHP memory. | | `firefly.admin.data.exclude` | `''` | CSV of resource slugs to refuse. A **hard refusal, not a menu preference**: the resource is hidden *and* every operation on it is refused. Hiding `user` because the table holds PII achieves nothing if the row URL still answers. | +| `firefly.admin.data.relations` | `true` | Discover relations, so records link to what they reference and the [entity map](admin.md#the-entity-map) has edges. Discovery **calls** the model methods that declare one — see [Relations](#relations) — so it is a key rather than a constant. | The page-size cap is applied to whatever the caller asks for, so the query layer never sees a size it did not agree to. @@ -341,7 +408,8 @@ instead. reads and both writes are complete and tested, and `DataBrowser::forContainer()` makes them usable from an application's own code today. What has not landed is the Blade page and the route that would put them in the dashboard's menu — so at present the gates below govern a library, not a URL. -- **No create**, permanently — see [above](#create-is-deliberately-absent). +- **No create for a non-Eloquent repository**, permanently — see + [above](#create-exists-for-eloquent-and-is-refused-for-everything-else). - **Writes are Eloquent-only.** A plain `CrudRepository` over value objects is browsable and read-only. - **No relationship navigation.** A foreign key renders as its value, not as a link to the row it points at: the browser knows a column's type, not its target, and Eloquent relationships are methods rather than diff --git a/docs/modules/error-handling.md b/docs/modules/error-handling.md index d7cfab6..e56a17d 100644 --- a/docs/modules/error-handling.md +++ b/docs/modules/error-handling.md @@ -81,8 +81,10 @@ $payload = $response->toArray(); // omits null/empty optionals by `Firefly\Web\Exception\ProblemDetailsRenderer`, via `ErrorResponse::fromException(...)`, at the exception's own `httpStatus()` — the shape is exactly the payload above, produced by the same kernel-level `ErrorResponse` this page documents. A generic (non-`FireflyException`) `Throwable` is -first wrapped as a category-`Internal`, HTTP-500 `FireflyException` before being rendered the same way, -whenever the request expects JSON (`$request->expectsJson()`). +first wrapped as a category-`Internal`, HTTP-500 `FireflyException` before being rendered the same way. +That wrapping rule lives in one place — `Firefly\Web\Error\ProblemMapper` — because the HTML page below +needs the same answer, and two copies of it would eventually tell a browser and a client different things +about one failure. Before that generic rendering happens, LaraFly gives the application a chance to handle the exception itself: @@ -99,3 +101,65 @@ itself: - If no handler matches at any scope, the exception propagates to the RFC-7807 renderer described above — so an unhandled 404/422/500 always still comes back as `application/problem+json`, never an uncaught framework error page. + +## Who gets JSON, and who gets a page + +The same failure is rendered two ways, and the choice is not "is this a `FireflyException`". It used to be, +which meant a person clicking a stale link to `/orders/999999` in a browser was shown a raw JSON blob: the +exception taxonomy that makes LaraFly's errors consistent for clients was the very thing that made them +unreadable for people. + +| The caller | What it gets | +|---|---| +| Named `text/html` (or `application/xhtml+xml`) in `Accept` | The HTML error page | +| Asked for JSON, or is an `XMLHttpRequest` | `application/problem+json` | +| Sent only a wildcard `Accept` — a bare `curl` | `application/problem+json` | +| Requested a path under `firefly.web.error-page.json-paths` | `application/problem+json`, whatever it asked for | + +The rule is **the client NAMED text/html**, not `acceptsHtml()`. A bare `curl` sends `*/*`, which +`acceptsHtml()` answers true for, so keying off it would have turned every unadorned command-line request +against an API into an HTML page — a worse regression than the bug being fixed. + +`json-paths` is the stronger statement and is checked **first**: the Accept header says who is asking, the +path says what the URL *is*. It defaults to `api/*`, because a developer opening an API URL in a browser +wants the payload their client will receive, not a styled page telling them the endpoint renders HTML. + +## The HTML error page + +`firefly/web` ships a page in the same visual language as the welcome page and the admin dashboard, showing +the status, the reason, the stable error `code` — the same one the problem document carries, so a support +ticket quoting it finds the same code in the log — and, when permitted, the exception, its `previous` chain, +the source around the throwing line, and the stack trace with **your** frames separated from your +dependencies'. + +```php +// config/firefly.php +'web' => [ + 'error-page' => [ + 'enabled' => true, // false falls back to Laravel's own page + 'trace' => env('APP_DEBUG', false), // the disclosure gate; follows app.debug + 'title' => env('APP_NAME', 'LaraFly'), + 'excerpt-lines' => 7, // source lines around the throw, clamped 0-40 + 'json-paths' => 'api/*', + 'views' => ['404' => 'errors.not-found', 'default' => 'errors.generic'], + ], +], +``` + +**`trace` is enforced where the data is gathered, not where it is printed.** With it off the framework never +walks the stack, never opens a source file and never copies the exception message — so there is nothing +assembled for a template mistake to leak. Production shows the status, the reason and the code: enough to +quote into a ticket and grep in a log, and nothing that names a class, a file or a row. The page's own +advice about *how* to turn traces on is suppressed outside non-production environments too, because naming +the framework and a config key to an anonymous visitor is a free hint about your stack. + +**Overriding it.** `views` hands a status — or `default` — to your own Blade view. The view receives the same +`$error` report the built-in page gets, so it is bound by the same `trace` gate and cannot print a stack +trace the settings withheld. A view that **throws** falls back to the built-in page rather than propagating: +this renders while the application is already failing, and an override is application code (a renamed +layout, a component querying the database that is down) — a white screen at that moment is the worst +possible outcome. + +**It is not a Blade view itself.** The built-in page is assembled as a string with no container lookups, no +view factory and no network font, because the failure being explained may *be* the view layer. String +building is not the elegant choice; it is the one that still works when nothing else does. diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md index c8a5139..3504684 100644 --- a/docs/modules/openapi.md +++ b/docs/modules/openapi.md @@ -74,13 +74,10 @@ self-referential DTO terminates as a `$ref` cycle instead of recursing forever. declared types, compiled constraints, docblock prose — is [its own section below](#how-a-request-dto-becomes-a-schema). -**Responses.** The success entry is keyed by the `#[Mapping]`'s declared status, and its body schema comes from the -controller method's declared **return type** — the only place the shape of a successful response is stated anywhere -in the framework, since `RouteDescriptor` records the status but not the payload. A `204` (or a `void`/`never` -return) gets no `content` at all, because emitting a content map for a status that carries no body is exactly what a -strict client generator turns into a phantom return type. A plain `array`/`iterable` return degrades to -`type: object` rather than being expanded from a `@return array{…}` docblock: parsing PHPDoc here would make the -generated document depend on comment text nothing else in the framework treats as binding. +**Responses.** The success entry is keyed by the `#[Mapping]`'s declared status, and its body schema is derived from +three sources, most specific first — see [What an endpoint returns](#what-an-endpoint-returns). A `204` (or a +`void`/`never` return) gets no `content` at all, because emitting a content map for a status that carries no body is +exactly what a strict client generator turns into a phantom return type. Beside it, every operation carries the shared `#/components/responses/Problem` as its `default`, plus a `400` when `ArgumentResolver` has something it can reject before the controller runs (a required binding, or one whose value @@ -95,6 +92,91 @@ with no edit in this package. operations, and describing one as `application/json` hands a generator a typed client for a response that is a web page. `firefly.openapi.include-html` documents them anyway, as `text/html`. +## What an endpoint returns + +Every success response used to be `{"type": "object"}` — an object with no members. A viewer renders that as a blank +panel and `openapi-generator` turns it into `any`, so the most useful sentence an API document contains was the one +sentence missing, for every endpoint of every application. + +The shape was never unavailable. It is written one line above the method, and **PHPStan at level max already checks +it against the code on every build** — which is exactly what makes reading it safe. An out-of-date `@return` is a +failing gate, not a silent lie. (It is also no different in kind from the input side: `RouteScanner` already reads +`@param list` to compile the table `ArgumentResolver` hydrates from.) + +Three sources, in order: + +```php +/** + * A page of orders. + * + * @return array{page: positive-int, size: positive-int, total: int, items: list} + */ +#[GetMapping] +public function index(): array { /* … */ } +``` + +1. **The `@return` type expression.** The only place a PHP `array` can say what is *in* it. Prose after the type + becomes the response `description` — the only response description anyone actually writes. +2. **The declared return type.** A class becomes a component `$ref`, a backed enum its value set, a scalar itself. +3. **Neither** — `type: object`, the old behaviour, kept as the *fallback* for a bare `array` return with nothing + said about it. A `@return array` parses fine and means nothing, so it is treated as saying nothing + rather than allowed to suppress what the declared type knew. + +### The type expressions it understands + +`Firefly\OpenApi\Schema\DocType` is a small recursive-descent compiler from a PHPDoc type expression to a JSON +Schema fragment. It is used for `@return`, for `@param`/`@var` on collection members, and for +`#[ApiResponse(type:)]`. + +| Written | Becomes | +|---|---| +| `list`, `Order[]`, `array` | `type: array` with `items: {$ref: Order}` | +| `array` | `type: object` with `additionalProperties: {$ref: Money}` | +| `array{a: int, b?: string}` | an object with `properties`, `required: [a]` and `additionalProperties: false` | +| `array{a: int, ...}` | the same, but open — the `...` is the only thing that lifts `additionalProperties: false` | +| `array{int, string}` | `prefixItems`, with `minItems`/`maxItems` — a tuple | +| `'draft'\|'sent'` | `type: string` with `enum` | +| `?Order`, `Order\|null` | `anyOf: [{$ref}, {type: null}]` | +| `non-empty-string`, `positive-int` | `minLength: 1`, `minimum: 1` | +| `mixed` | `{}` — the any-value schema, a real answer | +| `never`, `callable`, an unresolvable name | **nothing**, so the caller falls back to what it already knew | + +A `?` on a shape KEY (`b?: string`) means "may be absent" and becomes `required`; a `?` on the VALUE means "may be +null" and becomes the type union. Conflating them documents an omissible member as one a client must always send. + +Class names resolve through the **imports of the file the expression was written in** — reflection does not expose a +file's `use` statements, so they are read from the source. Without that, only fully-qualified names would work, +which is the one spelling nobody writes. + +### A returned class becomes a component + +`ResponseSchemaFactory` builds it from the **wire shape** — what `json_encode` emits — which is not the same thing as +the request side's constructor: + +- A class implementing `JsonSerializable` serialises as whatever `jsonSerialize()` **returns**. Give that method a + `@return array{…}` and the schema is exact. The skeleton's `App\Orders\Order` is the case that matters: it + publishes a derived `total` that is a *method*, so reflection alone would document five of the six members the API + actually sends. +- Everything else serialises as its **public properties**, which is what reflection reads. +- A declared shape only wins when it says something. `@return array` on `jsonSerialize()` means "an + object, members unknown" — strictly less than the property list it would have suppressed, so it is ignored. + +Nullability is not requiredness here. A response member is present or absent, and `?int $id` is always *present* and +sometimes null — so response members stay `required` and nullable ones widen their type. The request side's rule +would have told every client to expect an absence that never happens. + +### `#[ApiResponse]` takes a type expression + +```php +#[PostMapping(status: 201)] +#[ApiResponse(status: 409, description: 'That reference already exists.', type: Consignment::class)] +#[ApiResponse(status: 202, description: 'Accepted for later booking.', type: 'list')] +public function book(): array { /* … */ } +``` + +`type` is a full expression, not only a class or scalar name, and a short name resolves through the controller's own +imports. + ## How a request DTO becomes a schema A `#[RequestBody]` DTO is turned into a `components/schemas` entry by `DtoSchemaFactory`, from **three sources that @@ -360,6 +442,30 @@ What the element becomes depends on what it is: | A backed enum, a `DateTimeInterface`, a scalar | **inlined** — an enum is not a reusable component, and minting one per enum would hand every generated client a named type where an inline union is what the payload is | | Something `TypeSchema` cannot resolve | no `items` at all, rather than an empty `{}` — both say "any element", and the absent one avoids a later `[]`-vs-`{}` decision | +### Everything else a PHP `array` can be + +The table above answers for a list of **classes**, which is what the hydrator's compiled table knows about. Three +collections it does not cover were published as a bare `type: array` for the same reason `list` once was: + +| Written | Was | Is | +|---|---|---| +| `list $tags` | `type: array` — `Array` again | `items: {type: string}` | +| `list> $matrix` | `type: array` | nested `items` | +| `array $meta` | `type: array` — **the wrong JSON type** | `type: object` with `additionalProperties` | + +The third is the one that mattered. `array` is a JSON *object*; publishing it as an array is not +merely vague, and a generated client fails to decode the payload the server actually sends. + +The expression is read by [`DocType`](#the-type-expressions-it-understands) **after both element-type paths have +declined** — the compiled table and its reflection mirror — so the same step runs whichever path was taken, and the +hydrator's answer still wins wherever it has one. That placement is the whole design: the original reason for +publishing nothing here was drift between two implementations of one rule, and running afterwards is what makes a +third implementation impossible. + +A `#[Size]` on a map then had to stop emitting `minLength`, which is not a constraint on an object at all — a +validator ignores it, so the document would silently drop a bound the server does enforce. `lengthKeyword()` now +knows three shapes: `minItems` for a list, `minProperties` for a map, `minLength` for a string. + A list of DTOs recurses safely for the same reason a plain nested DTO does: `SchemaRegistry` reserves the component name *before* the builder runs, so `CategoryNode { list $children }` closes its own cycle on the component being built instead of expanding forever. diff --git a/packages/admin/src/Data/DataBrowser.php b/packages/admin/src/Data/DataBrowser.php index c809a0e..16a20fd 100644 --- a/packages/admin/src/Data/DataBrowser.php +++ b/packages/admin/src/Data/DataBrowser.php @@ -569,10 +569,12 @@ private function coerce(Model $entity, DataColumn $column, mixed $value): array| return match ($column->type) { DataColumn::TYPE_INT => preg_match('/^-?\d+$/', $string) === 1 ? [(int) $string] : false, - // is_numeric rather than a regex: it already accepts every spelling a number field can produce - // — a leading sign, a decimal point, exponent notation — and rejects the ones a decimal column - // would otherwise silently store as 0. - DataColumn::TYPE_FLOAT => is_numeric($string) ? [(float) $string] : false, + // VALIDATED as a number, WRITTEN as the string. is_numeric accepts every spelling a number + // field can produce — a leading sign, a decimal point, exponent notation — and rejects the ones + // a decimal column would otherwise silently store as 0. Casting to float to store it would + // reintroduce exactly the precision loss a `decimal` column exists to avoid: PHP's float cannot + // hold `12345678901234567890.12`, and the driver can bind the digits verbatim. The DB parses it. + DataColumn::TYPE_FLOAT => is_numeric($string) ? [$string] : false, DataColumn::TYPE_BOOL => $this->coerceBool($string), DataColumn::TYPE_DATETIME => strtotime($string) === false ? false : [$string], DataColumn::TYPE_JSON => $this->coerceJson($entity, $column, $string), diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 7f0e5c6..73b06e6 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -252,6 +252,32 @@ // Master gate: false unmounts every actuator route. Default: true. 'enabled' => true, + /* + | THE MANAGEMENT PORT — Spring Boot's `management.server.*`, and the same idea. + | + | With `port` set, every management surface — the actuator, and the admin dashboard with it — + | answers ONLY on that port, and a request arriving on the application's port gets a 404. That is + | what lets you bind the application to the internet and the management traffic to a private + | interface, so an operator's URL is not merely unadvertised but unreachable. + | + | 404, NEVER 403. A 403 would confirm that a management surface exists on some other port, which is + | one more fact than an unauthenticated scan of the public port deserves. + | + | `address` is a BIND address for your process manager, not a request-time check: nothing here can + | make PHP listen on a second socket, so setting `port` tells the framework which requests to + | ACCEPT and your web server or `artisan serve --port` decides what actually listens. Setting + | `port` to the application's own port is rejected at boot rather than silently doing nothing. + | + | Defaults: port null (same port as the application), address null, base-path ''. + */ + // 'server' => [ + // 'port' => (int) env('MANAGEMENT_PORT', 9001), + // 'address' => env('MANAGEMENT_ADDRESS', '127.0.0.1'), + // // A prefix in FRONT of `endpoints.web.base-path`: '/internal' makes the health endpoint + // // '/internal/actuator/health'. Default: ''. + // 'base-path' => '', + // ], + 'endpoints' => [ 'web' => [ // Default: '/actuator'. @@ -405,6 +431,36 @@ // // How many source lines to show around a throwing line, clamped to 0-40. 0 shows none. // // Default: 7. // 'excerpt-lines' => 7, + // + // /* + // | CSV of path patterns that answer with `application/problem+json` WHATEVER the caller's + // | Accept header says. Checked BEFORE the header, because the header says who is asking and + // | the path says what the URL is. + // | + // | Without it, a developer opening an API URL in a browser is shown a styled page instead of + // | the payload their client will receive — and so is anything that follows a link into the API + // | with a copied browser header. Patterns use Laravel's `Str::is` wildcards. + // | + // | Default: 'api/*'. + // */ + // 'json-paths' => 'api/*,webhooks/*', + // + // /* + // | Your OWN Blade view for a status, or for everything. The view is handed the same + // | `$error` report the built-in page gets — so it is bound by the same `trace` gate above and + // | cannot print a stack trace the settings withheld — plus `$settings`. + // | + // | A view that THROWS falls back to the built-in page rather than propagating: this renders + // | while the application is already failing, and an override is application code (a renamed + // | layout, a component querying the database that is down). A white screen at that moment is + // | the worst possible outcome. + // | + // | Default: [] (the framework's page for every status). + // */ + // 'views' => [ + // '404' => 'errors.not-found', + // 'default' => 'errors.generic', + // ], // ], // ], @@ -514,6 +570,83 @@ // | Default: '' (nothing excluded). // */ // 'exclude' => 'order', + // + // /* + // | Whether to discover an entity's RELATIONS, so a record links to the rows it references and + // | the Entity map page has edges to draw. + // | + // | Discovery CALLS the model methods that declare a relation, because a method's name says + // | nothing and only the call reveals which columns it joins on. Only a public, no-argument + // | method whose DECLARED RETURN TYPE is an Eloquent Relation is ever called — the same signal + // | Laravel's own tooling relies on, and one an accessor cannot claim without lying about its + // | signature. This key exists so an application with an unusual model base can switch that off + // | without losing the rest of the browser. + // | + // | Default: true. + // */ + // 'relations' => true, + // ], + // + // /* + // | THE DATASOURCE PAGE — /firefly/datasource + // | + // | Connections (with secrets masked), whether PDO holds them open between requests, and the + // | compiled #[Transactional] contract. It needs no key to appear; these two govern the parts that + // | do something rather than report something. + // */ + // 'datasource' => [ + // /* + // | Whether the page may OPEN a configured connection to report that it answers. One is probed + // | per page load — the default, or the one named by `?probe=` — because opening a socket can + // | hang against a firewalled host, and a page that opened every configured connection would + // | take the slowest one's timeout to render, on the page you opened because something is wrong. + // | + // | Default: true. + // */ + // 'probe' => true, + // + // /* + // | The connection WIZARD: a form that opens a connection you have not configured yet and + // | reports the server version or the driver's own error, plus the config block to paste. It + // | writes nothing. + // | + // | OFF BY DEFAULT, and refused outright when `app.env` is production — a check no key lifts. A + // | form that opens a socket to a host somebody typed is a request-forgery primitive, and its + // | errors distinguish "refused" from "timed out" well enough to map a private network. It is a + // | convenience for a developer's machine and should be unreachable anywhere else. + // | + // | Default: false. + // */ + // 'wizard' => env('FIREFLY_ADMIN_DATASOURCE_WIZARD', false), + // ], + // + // /* + // | THE FEATURE-SWITCH CONSOLE — /firefly/settings + // | + // | Every framework switch this application is running with, where each value came from, and — + // | outside production — a control to change it. + // | + // | IT IS THE ONE PAGE THAT CHANGES THE APPLICATION rather than describing it, which is why it is + // | off by default while the rest of the dashboard follows `app.debug`. A surface that alters a + // | running system should never appear because somebody left a debug flag on. + // | + // | A change is written to ONE json file under bootstrap/cache and merged over configuration at + // | boot; deleting that file restores your configured values exactly. Nothing is ever written to + // | `.env` — a config cache would disagree with it until someone cleared it, the file is routinely + // | read-only in a container image, and a web form that edits the file holding your database + // | password is not a feature. + // | + // | Only a fixed, framework-owned list of switches can be written. A crafted POST naming `app.key` + // | or a database host finds nothing to write, which is what keeps this a feature switch rather + // | than a remote configuration endpoint. + // */ + // 'settings' => [ + // // Default: false. + // 'enabled' => env('FIREFLY_ADMIN_SETTINGS_ENABLED', false), + // + // // Whether the page has controls as well as readings. Ineffective in production, where every + // // write is refused whatever this says. Default: false. + // 'writable' => env('FIREFLY_ADMIN_SETTINGS_WRITABLE', false), // ], // ], @@ -584,10 +717,15 @@ // // 'cdn' => false, // ], // - // // Info Object members, written verbatim into the document. + // /* + // | Info Object members, written verbatim into the document. `summary` is 3.1's short one-line + // | form (3.0 had only `description`); `terms-of-service` must be a URL if you set it. + // */ // 'title' => env('APP_NAME', 'API'), // 'version' => '1.0.0', // 'description' => '', + // 'summary' => 'Orders, customers and fulfilment.', + // 'terms-of-service' => 'https://example.test/terms', // // /* // | Server Objects. Both spellings a real config file uses are accepted — a bare URL string, and diff --git a/tests/ConfigReferenceTest.php b/tests/ConfigReferenceTest.php new file mode 100644 index 0000000..61d2cf4 --- /dev/null +++ b/tests/ConfigReferenceTest.php @@ -0,0 +1,80 @@ + $files */ + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS)); + + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + // Only keys read through the Config PORT. A bare `firefly.*` string elsewhere is as likely to be + // a route name or a container flag, and demanding a config entry for those would make this test + // noise rather than signal. + preg_match_all( + "/(?:bool|string|int|array|get|has)\(\s*'(firefly\.[a-z0-9.\-]+)'/", + (string) file_get_contents($file->getPathname()), + $matches, + ); + + foreach ($matches[1] as $key) { + $keys[$key] = true; + } + } + } + + expect($keys)->not->toBeEmpty(); + + $undocumented = []; + foreach (array_keys($keys) as $key) { + $leaf = substr($key, (int) strrpos($key, '.') + 1); + + if (! str_contains($reference, "'".$leaf."'")) { + $undocumented[] = $key; + } + } + + sort($undocumented); + + expect($undocumented)->toBe([]); +}); + +/** + * The reference has to be a PHP file that parses. + * + * Checked with `php -l` rather than by requiring it: the file calls `app_path()`, so evaluating it needs a + * booted Laravel application, and this suite runs against a bare container. The syntax is the half that can + * rot from an edit here — a stray quote inside one of the long commented blocks — and it is the half a + * linter answers exactly. + */ +it('ships a reference that parses', function () { + $file = dirname(__DIR__).'/skeleton/config/firefly.php'; + + exec(sprintf('%s -l %s 2>&1', escapeshellarg(PHP_BINARY), escapeshellarg($file)), $output, $status); + + expect($status)->toBe(0, implode("\n", $output)); +}); From a9a207eecb7ac02cf575421c6247fb6854dc571e Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 20:01:28 -0700 Subject: [PATCH 27/31] docs(book): both editions carry this session's work, and the listings still lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHAPTER 4A gained "The success body: what an endpoint actually returns", which had to argue against the chapter's own earlier position. Its two worked examples both showed `{"type": "object"}` as the success schema and the text explained why that was correct — parsing a `@return array{...}` would make the document depend on comment text nothing else treats as binding. That had already stopped being true: RouteScanner reads `@param list` to compile the table ArgumentResolver hydrates from, and PHPStan at level max checks these expressions against the code on every build, which is what makes reading them safe. The section covers the three sources in order, the type-expression table, why it is a parser and not another regex, and why a returned class is built from its WIRE shape rather than its constructor — with the skeleton's Order as the case that decides it, since `total` is a derived method and reflection alone would publish five of the six members the API sends. CHAPTER 11 gained "Walking the model" and "Two more pages, and the one that changes things", and its "There is no create" warning became "Create exists for Eloquent, and is refused for everything else" — the constructor-invariants argument kept verbatim for the case it was right about. Three stale counts went with it: the chapter said "thirteen pages" in three places and "twelve of the thirteen are tables". The book has a linter for its PHP listings and it caught both of mine — an attribute stack and a method body with no class around them. Fixed rather than excluded: 223 listings check, 0 failing, in both editions. Both editions are edited in parallel throughout; the Spanish is translated, not machine-passed. README and docs/README also carried claims that had gone stale within this session — "the dashboard page is not routed yet" for a page that has been routed for several commits, and "no create" — plus the same page-count drift. 2063 tests pass, PHPStan max clean, deptrac 0, Pint clean, book tests 9/9. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- README.md | 31 ++++--- book/src-es/04a-openapi.md | 109 +++++++++++++++++++++- book/src-es/11-observability-actuator.md | 40 ++++++-- book/src/04a-openapi.md | 112 ++++++++++++++++++++++- book/src/11-observability-actuator.md | 40 ++++++-- docs/README.md | 2 +- docs/modules/data-browser.md | 16 ++-- docs/modules/data.md | 4 +- 8 files changed, 315 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 1c037e7..3211007 100644 --- a/README.md +++ b/README.md @@ -626,23 +626,28 @@ built-in indicators, `firefly:health`/`firefly:metrics` actuator-over-CLI — se ### Two browser surfaces, neither of which needs npm or a CDN `firefly/admin` — which arrives with the runtime family — mounts a server-rendered dashboard at `/firefly` -behind `firefly.admin.enabled` (default: `app.debug`): thirteen pages over the -actuator's own endpoints — health, metrics, HTTP traffic, beans, a drawn +behind `firefly.admin.enabled` (default: `app.debug`): health, metrics, HTTP traffic, beans, a drawn [**bean graph**](docs/modules/bean-graph.md), conditions, routes, scheduled tasks, environment, config -properties, caches and loggers. It reads those endpoints **in-process** rather than over HTTP, so it renders -pages the JSON surface deliberately keeps unexposed — which makes its own URL the entire security boundary. -`firefly.admin.enabled` therefore defaults to `app.debug`, and an application that enables it with debug off -**must put the route behind its own auth middleware**. Read -[the access model](docs/modules/admin.md#access-the-whole-security-boundary) before you do. +properties, caches, loggers, and a [**datasource**](docs/modules/admin.md#the-datasource-page) page carrying +your connections, what PDO does about holding them open, and the compiled `#[Transactional]` contract. It +reads those endpoints **in-process** rather than over HTTP, so it renders pages the JSON surface deliberately +keeps unexposed — which makes its own URL the entire security boundary. `firefly.admin.enabled` therefore +defaults to `app.debug`, and an application that enables it with debug off **must put the route behind its own +auth middleware**. Read [the access model](docs/modules/admin.md#access-the-whole-security-boundary) first. The package also ships a Django-admin-style [**data browser**](docs/modules/data-browser.md) over your own -`CrudRepository` beans — discovered from the compiled bean catalogue, so nothing is registered by hand. It is -gated *separately*: `firefly.admin.data.enabled` defaults to **`false`** and deliberately does **not** follow +`CrudRepository` beans — discovered from the compiled bean catalogue, so nothing is registered by hand — with +filtering, sorting, paging, full CRUD, relations you can walk in both directions, and an +[**entity map**](docs/modules/admin.md#the-entity-map) that draws the foreign keys between them. It is gated +*separately*: `firefly.admin.data.enabled` defaults to **`false`** and deliberately does **not** follow `app.debug` or `firefly.admin.enabled`, because beans and configuration are facts about the application while -this page shows facts about its **users**. Writes need `firefly.admin.data.writable` on top of that, and there -is deliberately no create — an aggregate's invariants live in its constructor, not in a column list. Discovery, -reads and both writes are complete and usable from your own code today; the dashboard page that renders them is -[not routed yet](docs/modules/data-browser.md#known-latent). +this page shows facts about its **users**. Writes need `firefly.admin.data.writable` on top of that, and +creating a record is offered only for an Eloquent-backed resource — for a hand-written aggregate the +invariants live in its constructor, not in a column list, so that case is refused by name. + +One more page **changes** the application rather than describing it: a +[feature-switch console](docs/modules/admin.md#the-feature-switch-console) with three gates, the third of +which is not a configuration key — in production every write is refused whatever the other two say. `composer require firefly/openapi` mounts `GET /openapi.json` and a console at `/openapi`, both generated from the same `RouteManifest` the dispatcher dispatches from and the same `ConstraintManifest` the validator diff --git a/book/src-es/04a-openapi.md b/book/src-es/04a-openapi.md index 50a24c3..68029c5 100644 --- a/book/src-es/04a-openapi.md +++ b/book/src-es/04a-openapi.md @@ -445,7 +445,111 @@ Qué estados enumera una operación es algo **derivado, no adivinado**. Compara El `400` aparece exactamente cuando la operación tiene algo que `ArgumentResolver` pueda rechazar *antes* de que corra el controlador — un cuerpo que decodificar y enlazar, una subida que validar, una query o cabecera obligatoria que el cliente puede omitir, o un parámetro no-`string` que hay que coercer desde la cadena del cable. Está deliberadamente ausente de `balance`: nada de esa petición puede fallar el enlace, porque un segmento de ruta ausente no casa con la ruta en absoluto, y un `400` documentado que el endpoint no puede producir es ruido que un cliente generado convierte en una rama de error muerta. El `422` aparece exactamente cuando algún enlace lleva `#[Valid]`, porque esa es la única forma de que `BeanValidator` corra y por tanto la única forma de que se lance la `ValidationException` del Capítulo 4. Y `default` cubre todo lo que el propio manejador pueda levantar — un `404` de una `ResourceNotFoundException`, un `409` de una `ConflictException`, un `403` de un `#[PreAuthorize]` denegado — que no puede enumerarse desde el manifiesto de rutas sin leer el cuerpo del controlador, y que de todos modos se renderiza todo a través del mismo `ProblemDetailsRenderer`. -El cuerpo de éxito sale del tipo de **retorno** declarado del método del controlador, el único sitio del framework donde se enuncia la forma de una respuesta correcta. Un `204`, o un retorno `void`/`never`, no obtiene contenido alguno, porque emitir un mapa de contenido para un estado que no lleva cuerpo es exactamente lo que un generador de clientes estricto convierte en un tipo de retorno fantasma. El habitual `array` de LaraFly degrada a `type: object` en lugar de expandirse desde un docblock `@return array{...}`: analizar PHPDoc aquí haría que el documento generado dependiera de un texto de comentario que ninguna otra parte del framework trata como vinculante. +Un `204`, o un retorno `void`/`never`, no obtiene contenido alguno, porque emitir un mapa de contenido para un estado que no lleva cuerpo es exactamente lo que un generador de clientes estricto convierte en un tipo de retorno fantasma. El cuerpo de éxito de todo lo demás es el asunto de la siguiente sección. + +--- + +## El cuerpo de éxito: lo que un endpoint devuelve de verdad + +Mira otra vez las dos operaciones de arriba. Ambas respuestas correctas son `{"type": "object"}` — un objeto sin miembros. + +Esa era toda respuesta correcta en todo documento que este generador producía, y es la que más importa: un visor la dibuja como un panel en blanco y `openapi-generator` la convierte en `any`, así que la frase más útil que contiene un documento de API — *esto es lo que recibes de vuelta* — era la única que faltaba, en todos los endpoints de todas las aplicaciones. + +El razonamiento había sido que un `@return array{...}` es texto de comentario que ninguna otra parte del framework trata como vinculante. Eso ya había dejado de ser cierto. `RouteScanner` lee `@param list` para compilar la tabla desde la que `ArgumentResolver` **hidrata**, así que una expresión de tipo en un docblock es exactamente igual de vinculante que un tipo declarado a la *entrada*. Y hay un argumento aún más fuerte: **PHPStan en nivel max ya comprueba estas expresiones contra el código en cada build**, que es lo que hace seguro leerlas. Un `@return` desactualizado es una puerta que falla, no una mentira silenciosa. + +Así que el cuerpo de éxito sale ahora de tres fuentes, de la más específica a la menos: + +```php +final class OrderController +{ + /** + * Una página de pedidos. + * + * @return array{page: positive-int, size: positive-int, total: int, items: list} + */ + #[GetMapping] + public function index(int $page, int $size): array + { + return $this->orders->page($page, $size); + } +} +``` + +```json +{ + "type": "object", + "properties": { + "page": { "type": "integer", "minimum": 1 }, + "size": { "type": "integer", "minimum": 1 }, + "total": { "type": "integer" }, + "items": { "type": "array", "items": { "$ref": "#/components/schemas/Order" } } + }, + "required": ["page", "size", "total", "items"], + "additionalProperties": false +} +``` + +1. La **expresión de tipo de `@return`** — el único sitio donde un `array` de PHP puede decir qué lleva dentro. La prosa escrita después del tipo se convierte en la `description` de la respuesta, que es la única descripción de respuesta que alguien escribe de verdad. +2. El **tipo de retorno declarado** — una clase se convierte en un `$ref` a un componente, un enum respaldado en su conjunto de valores, un escalar en sí mismo. +3. **Ninguno de los dos** — `type: object`, el comportamiento anterior, conservado como *reserva* para un retorno `array` sin nada dicho sobre él. Un `@return array` se analiza sin problema y no significa nada, así que se trata como que no dice nada en lugar de dejar que suprima lo que el tipo declarado sí sabía. + +### Un analizador, no otra expresión regular + +El paquete ya tenía dos regex para la única forma que manejaba, `list` y `X[]`, y no se pueden extender al resto. `array{items: list>}` necesita `<>` y `{}` balanceados y una coma que solo separa en el nivel exterior. Eso es una gramática, y una gramática quiere un analizador — unas doscientas líneas de descenso recursivo en `DocType`, frente a las cuatro dependencias transitivas que `phpstan/phpdoc-parser` metería en toda aplicación que instale este paquete. + +| Escrito | Se convierte en | +|---|---| +| `list`, `Order[]`, `array` | `type: array` con `items: {$ref: Order}` | +| `array` | `type: object` con `additionalProperties` | +| `array{a: int, b?: string}` | un objeto, `required: [a]`, `additionalProperties: false` | +| `array{a: int, ...}` | lo mismo, abierto — el `...` es lo único que lo levanta | +| `array{int, string}` | `prefixItems` — una tupla | +| `'draft'\|'sent'` | `type: string` con `enum` | +| `?Order` | `anyOf: [{$ref}, {type: null}]` | +| `non-empty-string`, `positive-int` | `minLength: 1`, `minimum: 1` | +| `never`, `callable`, un nombre irresoluble | **nada** — quien llama vuelve a lo que ya sabía | + +Un `?` sobre una **clave** de shape significa «puede estar ausente» y se convierte en `required`; un `?` sobre el **valor** significa «puede ser null». Confundir ambos documenta como obligatorio un miembro omitible. Los nombres de clase se resuelven a través de los imports del fichero donde se escribió la expresión, porque la reflexión no expone las sentencias `use` de un fichero — sin eso solo funcionarían los nombres completamente cualificados, que es la única forma que nadie escribe. + +### Una clase devuelta se construye desde su forma de cable + +`ResponseSchemaFactory` no es `DtoSchemaFactory`, y la diferencia es el asunto. Esa fábrica deriva los miembros del **constructor** y las reglas del `ConstraintManifest` — las dos fuentes correctas para una carga que el servidor enlaza y valida, y las dos equivocadas a la salida. Una respuesta nunca se valida, y sus miembros son lo que `json_encode` emite. + +Que PHP escribe de dos maneras. Una clase que implementa `JsonSerializable` se serializa como lo que `jsonSerialize()` **devuelve**; todo lo demás como sus **propiedades públicas**. El `App\Orders\Order` del esqueleto es el caso que decide el diseño: + +```php +final readonly class Order implements JsonSerializable +{ + /** @return array{id: int|null, customer: string, email: string, shipTo: Address, lines: list, total: float} */ + public function jsonSerialize(): array + { + return [/* … */ 'total' => $this->total()]; + } +} +``` + +`total` es un **método** derivado, no una propiedad. Reflejar solo las propiedades publicaría cinco de los seis miembros que la API envía de verdad. El array shape enuncia los seis, PHPStan lo comprueba contra el método, y el generador lo lee — borra la anotación y `total` desaparece en silencio del documento mientras la API sigue enviándolo. + +Una forma declarada solo gana cuando dice algo: `@return array` en `jsonSerialize()` significa «un objeto, miembros desconocidos», que es estrictamente menos que la lista de propiedades que habría suprimido, así que se ignora en favor de la reflexión. + +Una regla se invierte a la salida. **Ser nullable no es ser opcional aquí.** Un miembro de respuesta está presente o ausente, y `?int $id` está siempre *presente* y a veces es null — así que los miembros de respuesta siguen siendo `required` y los nullables ensanchan su tipo. La regla del lado de la petición habría dicho a todo cliente que esperase una ausencia que nunca ocurre. + +### `#[ApiResponse]` también acepta una expresión de tipo + +```php +final class ConsignmentController +{ + #[PostMapping(status: 201)] + #[ApiResponse(status: 409, description: 'Esa referencia ya existe.', type: Consignment::class)] + #[ApiResponse(status: 202, description: 'Aceptado para reservar más tarde.', type: 'list')] + public function book(): array + { + return $this->consignments->book(); + } +} +``` + +`type` es una expresión completa, no solo el nombre de una clase o de un escalar, y un nombre corto se resuelve a través de los propios imports del controlador. --- @@ -673,6 +777,9 @@ final class ApiDocsConfiguration | `x-firefly-constraints` | Registra lo que JSON Schema no sabe enunciar (`after:now`, un dígito de control, un PCRE con banderas, una regla de terceros) en lugar de descartarlo | | `ProblemSchema` | La única respuesta compartida `application/problem+json`; documenta `code`/`category`/`severity`/`errors` de Firefly, con los enums leídos de los propios casos del kernel | | Conjunto de errores derivado | `400` solo cuando algo es rechazable antes de que corra el controlador, `422` solo bajo `#[Valid]`, `default` siempre | +| `DocType` | Compila una expresión de tipo PHPDoc a un fragmento de JSON Schema — shapes, genéricos, tuplas, uniones de literales, pseudo-tipos de PHPStan — y devuelve *nada* en lugar de adivinar cuando no puede leer una | +| `ResponseSchemaFactory` | Construye una clase devuelta desde su forma de CABLE: el `@return` declarado de `jsonSerialize()` cuando lo hay, las propiedades públicas si no. Los miembros de respuesta siguen siendo `required` y los nullables ensanchan su tipo | +| `#[ApiResponse(type:)]` | Una expresión de tipo completa (`'list'`), resuelta con los propios imports del controlador | | `$route->html` | Las rutas HTML `#[Controller]` quedan excluidas por defecto; `firefly.openapi.include-html` las documenta como `text/html`, nunca como JSON | | `firefly.openapi.viewer.style` | `swagger` (por defecto) \| `builtin` \| `cdn`. Solo `cdn` hace una petición a un tercero en cada visita; un valor no reconocido cae de vuelta a `swagger` | | `SwaggerAssets` | Sirve el Swagger UI OFICIAL desde tu propio origen, desde el paquete de composer `swagger-api/swagger-ui` — siete nombres de fichero en lista blanca, cada uno comprobado con `realpath()` dentro del directorio dist | diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index f5275a2..7077de2 100644 --- a/book/src-es/11-observability-actuator.md +++ b/book/src-es/11-observability-actuator.md @@ -2,7 +2,7 @@ # Observabilidad: Salud, Métricas y el Actuator {.chtitle} -Al terminar este capítulo conocerás el SPI `HealthIndicator` de `firefly/actuator` y los indicadores integrados `Ping`/`DiskSpace`/`Db`, cómo `HealthEndpoint` los agrega en una única respuesta `/actuator/health` — y cómo un **grupo** de sondeo (el mecanismo que hay detrás de "liveness" y "readiness") no es más que un subconjunto de indicadores con nombre y configurado, cómo toda la superficie de gestión está **sin exponer por defecto** de modo que un endpoint olvidado falla cerrado como un 404 en lugar de una fuga de información, y el `MeterRegistry` en PHP puro de `firefly/observability`, su exportador Prometheus a prueba de locale, y el truco exacto de precedencia `#[Order(500)]` — el mismo que el Capítulo 10 te mostró para la seguridad — que permite a `MeterRegistryCqrsMetrics` reemplazar el `NoOpCqrsMetrics` del bus de CQRS sin ningún cambio de código en `firefly/cqrs`. El capítulo cierra con `firefly/admin`, el panel de administración renderizado en el servidor sobre esos mismos endpoints — trece páginas, incluido un **grafo de beans** dibujado que resuelve cada dependencia de constructor a través de la interfaz por la que está cableada y reporta los ciclos con los que, si no, un arranque moriría sin mensaje. Lee esos endpoints **en proceso**, de modo que renderiza páginas que la superficie JSON deliberadamente mantiene sin exponer, lo que convierte a su propia URL en toda la frontera de seguridad y a su valor por defecto (`app.debug`) en la línea más importante del paquete. +Al terminar este capítulo conocerás el SPI `HealthIndicator` de `firefly/actuator` y los indicadores integrados `Ping`/`DiskSpace`/`Db`, cómo `HealthEndpoint` los agrega en una única respuesta `/actuator/health` — y cómo un **grupo** de sondeo (el mecanismo que hay detrás de "liveness" y "readiness") no es más que un subconjunto de indicadores con nombre y configurado, cómo toda la superficie de gestión está **sin exponer por defecto** de modo que un endpoint olvidado falla cerrado como un 404 en lugar de una fuga de información, y el `MeterRegistry` en PHP puro de `firefly/observability`, su exportador Prometheus a prueba de locale, y el truco exacto de precedencia `#[Order(500)]` — el mismo que el Capítulo 10 te mostró para la seguridad — que permite a `MeterRegistryCqrsMetrics` reemplazar el `NoOpCqrsMetrics` del bus de CQRS sin ningún cambio de código en `firefly/cqrs`. El capítulo cierra con `firefly/admin`, el panel de administración renderizado en el servidor sobre esos mismos endpoints — un **grafo de beans** dibujado que resuelve cada dependencia de constructor a través de la interfaz por la que está cableada y reporta los ciclos con los que, si no, un arranque moriría sin mensaje. Lee esos endpoints **en proceso**, de modo que renderiza páginas que la superficie JSON deliberadamente mantiene sin exponer, lo que convierte a su propia URL en toda la frontera de seguridad y a su valor por defecto (`app.debug`) en la línea más importante del paquete. !!! note "Término nuevo: actuator" Un **actuator** es un endpoint de gestión que informa sobre el *proceso en ejecución en sí* — si está sano, con qué arrancó, cuán rápidas son sus peticiones — en lugar de sobre el dominio de negocio al que sirve el proceso. El término y la forma provienen ambos de Spring Boot Actuator; `firefly/actuator` es un análogo PHP de primera parte y con pocas dependencias: endpoints de framework montados directamente sobre el mismo `Router` de Illuminate que usan tus propios controladores, no un proceso de administración separado. @@ -660,7 +660,7 @@ composer require firefly/admin Después abre `/firefly`. No hay paso de npm en la instalación ni CDN en tiempo de petición — las vistas son Blade puro con CSS en línea y tipografías del sistema, porque un paquete de Composer no puede dar por hecho que npm se ha ejecutado, y un panel que necesita la red es inútil precisamente en los entornos aislados donde más quieres mirar uno. -Trece páginas, cada una una vista sobre la carga útil de un endpoint. El menú las agrupa como piensa un operador y no como están dispuestos los paquetes — qué está haciendo ahora mismo, qué cableó en el arranque, y cómo está configurado — porque una lista plana de trece enlaces es peor menú que tres cortas: +La mayoría de las páginas son una vista sobre la carga útil de un endpoint; cuatro leen el contenedor en su lugar. El menú las agrupa como piensa un operador y no como están dispuestos los paquetes — qué está haciendo ahora mismo, qué cableó en el arranque, cuáles son sus datos, y cómo está configurado — porque una lista plana de diecisiete enlaces es peor menú que cuatro cortas: | Grupo | Página | Lee | Responde | |---|---|---|---| @@ -739,7 +739,7 @@ Un endpoint que lanza se captura y se reporta como `null` en vez de dejar que se ### El grafo de beans -Doce de las trece páginas son tablas. La decimotercera dibuja una imagen, y es la que amortiza el paquete el día en que algo está mal cableado. +La mayoría de las páginas son tablas. Dos dibujan una imagen — esta, y el [mapa de entidades](#recorrer-el-modelo-relaciones-filtros-y-un-mapa) más adelante en el capítulo — y esta es la que amortiza el paquete el día en que algo está mal cableado. `/actuator/beans` te dice *qué* beans existen. No puede decirte a qué está **cableado** cada uno, que es lo que realmente quieres cuando un `#[ConditionalOnMissingBean]` no se disparó como esperabas, cuando un ciclo entre singletons ansiosos ha colgado un arranque sin mensaje alguno, o cuando intentas averiguar a qué se enganchó el paquete que acabas de instalar. `/firefly/graph` responde a eso, como un diagrama SVG por capas más una tabla de relaciones filtrable. @@ -959,8 +959,10 @@ Esta página muestra hechos sobre los **usuarios** de la aplicación. Esa es una Las escrituras necesitan entonces una *segunda* clave, y por sí sola no sirve de nada. Leer la fila equivocada es una divulgación; borrarla es pérdida de datos sin deshacer, desde un formulario, sobre una sesión que puede no ser más que «debug estaba encendido». Encender el navegador es una decisión sobre **visibilidad**; encender las escrituras es una decisión sobre **custodia**. Si las colapsas en una sola clave, quien quería mirar una tabla ha armado también el botón de borrar. -!!! warning "No hay create, y no es un hueco que se rellene más adelante" - Un formulario de creación genérico sobre una entidad arbitraria es una promesa que el navegador no puede cumplir, y el Capítulo 6 explica por qué: **el constructor de un agregado es donde viven sus invariantes.** Un `Order` que debe tener al menos una línea, un `Wallet` cuyo saldo empieza a cero en la divisa en que se abrió, un objeto de valor que rechaza un IBAN mal formado — un formulario construido a partir de una lista de columnas no conoce ninguno. Solo hay dos maneras de construir la fila: llamar al constructor, que necesita argumentos que el formulario no puede aportar con los tipos ni el orden correctos; o escribir las columnas directamente en la tabla, lo que produce una fila que el modelo de dominio considera imposible y con la que toda lectura posterior tiene que apañarse. Lo segundo es lo que hace de verdad una implementación de «simplemente inserta las columnas», y es *peor que no tener botón*, porque parece que funcionó. `update()` sí se ofrece porque opera sobre una fila que ya satisface sus invariantes; `delete()` porque eliminar no necesita invariante alguna. Crear pertenece a tu propio código, donde está el constructor. +!!! warning "Create existe para Eloquent, y se rechaza para todo lo demás" + El navegador no tuvo `create()` durante un tiempo, y el argumento era medio cierto. Un formulario de creación genérico sobre una entidad arbitraria es una promesa que no puede cumplir, y el Capítulo 6 explica por qué: **el constructor de un agregado es donde viven sus invariantes.** Un `Order` que debe tener al menos una línea, un `Wallet` cuyo saldo empieza a cero en la moneda con la que se abrió, un objeto valor que rechaza un IBAN mal formado — un formulario construido a partir de una lista de columnas no conoce ninguno. Solo hay dos formas de construir esa fila: llamar al constructor, que necesita argumentos que el formulario no puede suministrar con los tipos ni en el orden correctos; o escribir las columnas directamente en la tabla, lo que produce una fila que el modelo de dominio considera imposible. Lo segundo es lo que hace una implementación de «pues inserta las columnas», y es *peor que no tener botón*, porque parece que funcionó. **Ese caso se sigue rechazando, por su nombre.** + + Nunca fue cierto para un modelo **Eloquent**, que se construye vacío y se rellena por atributo — exactamente lo que `update()` lleva haciendo siempre sobre una fila que ya existe. Create rechazaba por un riesgo que update ya estaba asumiendo, y la inconsistencia le costaba a toda aplicación una superficie CRUD que se quedaba en RUD. Así que se ofrece para un recurso respaldado por Eloquent bajo los dos mismos interruptores, con el identificador y cualquier columna enmascarada *omitidos del formulario* en lugar de deshabilitados en él: un campo que el navegador se negaría a escribir no debería aparentar que lo acepta. Merece la pena llevarse otras dos decisiones de esta sección, porque ambas parecen un detalle y no lo son. @@ -971,6 +973,28 @@ Merece la pena llevarse otras dos decisiones de esta sección, porque ambas pare !!! note "La ruta de listado que obtienes depende de la interfaz que implementaste" Un `PagingAndSortingRepository` se pagina **en la base de datos**: el repositorio hace el desplazamiento, el límite, el `ORDER BY` y el `COUNT`, y el coste es independiente del tamaño de la tabla. Un `CrudRepository` simple no puede expresar nada de eso, así que el navegador llama a `findAll()`, ordena y corta **en PHP**, y descarta todas las filas menos 25 — lo que con diez mil filas es una página lenta y con diez millones es un agotamiento de memoria que mata al worker, al *primer* clic. La interfaz no tiene límite, ni desplazamiento, ni conteo con predicado, así que las opciones honestas eran «negarse a navegar repositorios que no pueden paginar» o «navegarlos y decir lo que cuesta». LaraFly hace lo segundo, y esto es el decirlo. Implementa `PagingAndSortingRepository` en todo lo que pretendas navegar contra una tabla real. +### Recorrer el modelo: relaciones, filtros y un mapa + +Un listado que solo puedes desplazar es un volcado de tabla. Tres cosas lo convierten en algo que exploras. + +**Las relaciones se descubren LLAMANDO a los métodos que declaran una**, porque es la única forma de saber por qué columnas unen — el nombre de un método no dice nada y su tipo de retorno solo dice de qué clase es. Lo que convierte «qué es seguro llamar» en la pregunta que sostiene todo, y la respuesta es el **tipo de retorno declarado**: solo se llama a un método público, no estático y sin argumentos cuyo tipo de retorno sea una subclase de `Relation` de Eloquent. Un método que anuncia `: HasMany` es una definición de relación por construcción — la misma señal en la que se apoyan la validación de `with()` y las herramientas de IDE de Laravel — y un accesor no puede reclamarla sin mentir sobre su propia firma. La llamada no ejecuta consulta alguna; Eloquent la difiere hasta `get()`. + +Un `belongsTo` abre el único registro padre; un `hasMany` abre el listado hijo **filtrado por la clave de esta fila**, que es para lo que la página de registro necesita un filtro. Una relación cuyo otro extremo no es un recurso navegable se sigue mostrando — te dice la forma del modelo — pero no se enlaza, y esa distinción vive en el modelo y no en la plantilla para que una vista no pueda acuñar una URL que da 404. + +**Filtrar son ocho comparaciones sobre las columnas que el recurso ya publica** — `es`, `no es`, `contiene`, `empieza por`, `mayor que`, `menor que`, `está vacío`, `no está vacío` — expresadas como una URL GET que puedes guardar en marcadores o pegar en un ticket. Toda comparación enlaza su valor como parámetro, incluidas las de `LIKE`, donde los comodines van alrededor de un valor *escapado* en vez de meter el valor dentro de un patrón. Una columna que el esquema no publica y un operador fuera de ese conjunto se **descartan** en lugar de pasarse al driver: ambos llegan en una URL que un operador puede editar a mano, y una consulta que alcanza el driver con un identificador arbitrario dentro es, como poco, un oráculo de nombres de columna. Las condiciones se combinan con AND entre sí y con la caja de búsqueda, así que estrechar el listado de una relación no puede escaparse de ella. + +Las mismas ocho están implementadas para el camino de reserva en PHP, porque un repositorio que no puede paginar debe filtrarse por las mismas reglas que uno que sí. Dos implementaciones de un predicado divergen, y la divergencia se manifiesta como un filtro que significa cosas distintas en recursos distintos. + +**`/firefly/data-map` lo dibuja.** Cada entidad navegable como una caja con sus columnas, cada clave foránea como una arista etiquetada, y cada caja enlazando a sus propios registros. Un `hasMany` y el `belongsTo` que lo mira de frente son *una* clave vista desde dos extremos, así que cada una se dibuja una vez — apuntando desde la tabla que *tiene* la clave hacia la tabla a la que referencia, que es además lo que la flecha significa. + +### Dos páginas más, y la que sí cambia cosas + +**`/firefly/datasource`** responde a lo que un volcado de configuración no puede. ¿Con qué base de datos estoy hablando (driver, host, base, con `password` enmascarada por el mismo enmascarador que usa el endpoint `env`)? ¿Está *arriba* — se prueba una conexión por carga de página, porque abrir un socket puede quedarse colgado contra un host tras un cortafuegos y una página que abriera todas las conexiones configuradas tardaría en renderizar el timeout de la más lenta, justo en la página que abriste *porque* algo va mal. ¿Qué significa el pooling aquí — PHP no tiene pool de conexiones, y en vez de inventar un indicador la página informa del `ATTR_PERSISTENT` de PDO por lo que es, y dice que bajo php-fpm el tamaño efectivo del pool es tu número de workers. Y a qué compiló `#[Transactional]`, que hasta ahora solo existía como un artefacto bajo `bootstrap/cache`. + +**`/firefly/settings`** es la única página del panel que *cambia* la aplicación en lugar de describirla, y está protegida en consecuencia: `firefly.admin.settings.enabled` (desactivada por defecto, a diferencia de todo lo demás aquí), `.writable` encima, y una tercera puerta que **no es una clave de configuración** — en producción toda escritura se rechaza diga lo que diga el resto. Eso último es deliberado. Es la diferencia entre «lo hemos hecho seguro» y «lo hemos hecho configurable para que sea seguro», y solo lo primero sobrevive a que alguien copie un `.env`. + +Es un *interruptor de funcionalidad*, no un endpoint de configuración remota: la lista es fija y propiedad del framework, así que un POST fabricado que nombre `app.key` o el host de una base de datos no encuentra nada que escribir. Un cambio va a un único fichero JSON bajo `bootstrap/cache`, filtrado tanto a la entrada como a la salida, y se mezcla sobre la configuración en el `register()` del provider — no en un boot pass, y esa distinción costó una sesión de depuración. Todo objeto de settings de este framework se construye una vez desde la configuración y se retiene, así que aplicar los overrides desde el propio pase del panel escribía el fichero y mostraba el nuevo estado en la página mientras `/openapi.json` seguía respondiendo 200. `register()` corre antes de que se resuelva ningún bean, que es el único punto en el que la mezcla es cierta. + !!! laravel "Paridad con Laravel" Laravel puro no trae ningún endpoint de comprobación de salud ni de métricas en absoluto — la mayoría de los equipos o bien improvisan una ruta `/health` a mano o recurren a un paquete de terceros, normalmente emparejado con la extensión `ext-prometheus`. `firefly/actuator` y `firefly/observability` son análogos de primera parte y con pocas dependencias de Spring Boot Actuator y Micrometer respectivamente: endpoints de framework montados sobre el mismo `Router` que tu app ya usa, comprobaciones de salud que reutilizan por debajo los propios `DB`/`Log`/config de Laravel, y un exportador Prometheus en PHP puro sin requisito de extensión. Ambos paquetes son dependencias Composer opcionales y ambos son seguros por defecto — una app que añade `firefly/actuator` obtiene `health`/`info` y nada más hasta que configure más. `firefly/admin` completa el conjunto como análogo de Spring Boot Admin, con la diferencia de que no es una aplicación de monitorización aparte que despliegas y en la que registras instancias: son vistas Blade dentro de la propia aplicación sobre la que informan, que es por lo que puede leer el registro directamente y por lo que su modelo de acceso importa tanto como importa. @@ -990,13 +1014,17 @@ Merece la pena llevarse otras dos decisiones de esta sección, porque ambas pare | `PrometheusTextFormat` | Exposición a prueba de locale — `number_format()`, nunca `sprintf('%f')` | | `MetricsFilter` | Filtro de cronometraje más externo `#[Order(-100)]`; etiqueta por la **plantilla** de la ruta, nunca la ruta en bruto — cardinalidad acotada | | `ObservabilityAutoConfiguration` `#[Order(500)]` | El mismo truco de precedencia que la costura de seguridad del Capítulo 10: registra `cqrsMetrics()` antes de que `CqrsAutoConfiguration` evalúe su `#[ConditionalOnMissingBean]` | -| `firefly/admin` | Un panel Blade renderizado en el servidor en `/firefly`; trece páginas, y una cuyo endpoint no está registrado o está apagado se oculta del menú en lugar de enlazarse | +| `firefly/admin` | Un panel Blade renderizado en el servidor en `/firefly`; una cuyo endpoint no está registrado o está apagado se oculta del menú en lugar de enlazarse | | `AdminEndpointReader` | Invoca cada `ActuatorEndpoint` **en proceso** desde el `ActuatorRegistry`, sorteando `ExposureModel` — así el panel muestra lo que la superficie HTTP no expone, y un endpoint que lanza degrada un solo panel | | `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado sobre **tres clases de nodo** — componentes, productos `#[Bean]` y DTOs `#[ConfigProperties]` — con aristas `injects`/`produces` resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y el diagrama suprimido pasados `firefly.admin.graph.max-nodes` (220) | | Los productos `#[Bean]` como nodos | El cableado de un framework vive en métodos fábrica, no en constructores; con solo las clases declarantes como nodos, un esqueleto de serie dibujaba **una** arista de 42 beans | | `ComponentDescriptor::$dependencies` | Las aristas del grafo, registradas por `ComponentScanner` en tiempo de **escaneo** — solo tipos de clase e interfaz, porque un parámetro escalar es configuración, no cableado | | `firefly.admin.enabled` | Toma por defecto `app.debug`; un valor explícito gana en ambas direcciones, y encenderlo con debug apagado te obliga a poner tu propio middleware de autenticación delante de la ruta | | `firefly.admin.data.enabled` | La puerta propia del navegador de datos, con valor por defecto **`false`** — *no* sigue a `app.debug` ni a `firefly.admin.enabled`, porque esta página muestra hechos sobre los usuarios de la aplicación y no sobre la aplicación | +| `RelationIntrospector` | Encuentra relaciones LLAMANDO solo a los métodos cuyo tipo de retorno declarado es una `Relation` de Eloquent, para que un registro enlace con lo que referencia y el mapa de entidades tenga aristas que dibujar | +| `DataFilter` | Ocho comparaciones sobre las columnas que el recurso publica, siempre enlazadas como parámetro, y con una columna u operador desconocido descartado en lugar de pasado al driver | +| `/firefly/datasource` | Conexiones con los secretos enmascarados, una probada por carga, la persistencia de PDO contada por lo que es, y el contrato `#[Transactional]` compilado | +| `/firefly/settings` | La única página que cambia la aplicación: desactivada por defecto, escribible con una segunda clave, y rechazada en producción por una puerta que ninguna clave levanta | | `firefly.admin.data.writable` | Una **segunda** puerta, también `false` e inútil por sí sola: visibilidad y custodia son decisiones distintas, y una sola clave armaría el botón de borrar para quien solo quería mirar una tabla | | Sin `create()` | Permanente, no pendiente: las invariantes de un agregado viven en su constructor, y un formulario construido a partir de una lista de columnas no puede satisfacerlas — escribir las columnas de todos modos produce una fila que el dominio considera imposible | diff --git a/book/src/04a-openapi.md b/book/src/04a-openapi.md index 56c1c6e..e2ef7eb 100644 --- a/book/src/04a-openapi.md +++ b/book/src/04a-openapi.md @@ -445,7 +445,111 @@ Which statuses an operation lists is **derived, not guessed**. Compare two real `400` appears exactly when the operation has something `ArgumentResolver` can reject *before* the controller runs — a body to decode and bind, an upload to validate, a required query or header the client may omit, or a non-`string` parameter that has to be coerced out of the wire's string. It is deliberately absent from `balance`: nothing about that request can fail binding, because a missing path segment does not match the route at all, and a documented `400` an endpoint cannot produce is noise a generated client turns into a dead error branch. `422` appears exactly when some binding carries `#[Valid]`, because that is the only way `BeanValidator` runs and so the only way Chapter 4's `ValidationException` can be thrown. And `default` covers everything the handler itself may raise — a `404` from a `ResourceNotFoundException`, a `409` from a `ConflictException`, a `403` from a denied `#[PreAuthorize]` — which cannot be enumerated from the route manifest without reading the controller's body, and which all render through the same `ProblemDetailsRenderer` anyway. -The success body comes from the controller method's declared **return type**, the only place the shape of a successful response is stated anywhere in the framework. A `204`, or a `void`/`never` return, gets no content at all, because emitting a content map for a status that carries no body is exactly what a strict client generator turns into a phantom return type. LaraFly's common `array` return degrades to `type: object` rather than being expanded from a `@return array{...}` docblock: parsing PHPDoc here would make the generated document depend on comment text nothing else in the framework treats as binding. +A `204`, or a `void`/`never` return, gets no content at all, because emitting a content map for a status that carries no body is exactly what a strict client generator turns into a phantom return type. The success body of everything else is the subject of the next section. + +--- + +## The success body: what an endpoint actually returns + +Look again at the two operations above. Both success responses are `{"type": "object"}` — an object with no members. + +That was every success response in every document this generator produced, and it is the one that matters most: a viewer renders it as a blank panel and `openapi-generator` turns it into `any`, so the single most useful sentence an API document contains — *here is what you get back* — was the one sentence missing, for every endpoint of every application. + +The reasoning had been that a `@return array{...}` is comment text nothing else in the framework treats as binding. That had already stopped being true. `RouteScanner` reads `@param list` to compile the table `ArgumentResolver` **hydrates** from, so a docblock type expression is exactly as binding as a declared type on the way *in*. And there is a stronger argument still: **PHPStan at level max already checks these expressions against the code on every build**, which is what makes reading them safe. An out-of-date `@return` is a failing gate, not a silent lie. + +So the success body now comes from three sources, most specific first: + +```php +final class OrderController +{ + /** + * A page of orders. + * + * @return array{page: positive-int, size: positive-int, total: int, items: list} + */ + #[GetMapping] + public function index(int $page, int $size): array + { + return $this->orders->page($page, $size); + } +} +``` + +```json +{ + "type": "object", + "properties": { + "page": { "type": "integer", "minimum": 1 }, + "size": { "type": "integer", "minimum": 1 }, + "total": { "type": "integer" }, + "items": { "type": "array", "items": { "$ref": "#/components/schemas/Order" } } + }, + "required": ["page", "size", "total", "items"], + "additionalProperties": false +} +``` + +1. The **`@return` type expression** — the only place a PHP `array` can say what is in it. Prose written after the type becomes the response `description`, which is the only response description anyone ever actually writes. +2. The **declared return type** — a class becomes a component `$ref`, a backed enum its value set, a scalar itself. +3. **Neither** — `type: object`, the old behaviour, kept as the *fallback* for a bare `array` return with nothing said about it. A `@return array` parses fine and means nothing, so it is treated as saying nothing rather than allowed to suppress what the declared type knew. + +### A parser, not another regular expression + +The package already had two regexes for the one shape it handled, `list` and `X[]`, and they cannot be extended to the rest. `array{items: list>}` needs balanced `<>` and `{}` and a comma that separates only at the outer level. That is a grammar, and a grammar wants a parser — about two hundred lines of recursive descent in `DocType`, against the four transitive dependencies `phpstan/phpdoc-parser` would put in every application that installs this package. + +| Written | Becomes | +|---|---| +| `list`, `Order[]`, `array` | `type: array` with `items: {$ref: Order}` | +| `array` | `type: object` with `additionalProperties` | +| `array{a: int, b?: string}` | an object, `required: [a]`, `additionalProperties: false` | +| `array{a: int, ...}` | the same, open — the `...` is the only thing that lifts it | +| `array{int, string}` | `prefixItems` — a tuple | +| `'draft'\|'sent'` | `type: string` with `enum` | +| `?Order` | `anyOf: [{$ref}, {type: null}]` | +| `non-empty-string`, `positive-int` | `minLength: 1`, `minimum: 1` | +| `never`, `callable`, an unresolvable name | **nothing** — the caller falls back to what it knew | + +A `?` on a shape **key** means "may be absent" and becomes `required`; a `?` on the **value** means "may be null". Conflating the two documents an omissible member as one a client must always send. Class names resolve through the imports of the file the expression was written in, because reflection does not expose a file's `use` statements — without that, only fully-qualified names would work, which is the one spelling nobody writes. + +### A returned class is built from its wire shape + +`ResponseSchemaFactory` is not `DtoSchemaFactory`, and the difference is the point. That factory derives members from the **constructor** and rules from the `ConstraintManifest` — the right two sources for a payload the server binds and validates, and the wrong two on the way out. A response is never validated, and its members are what `json_encode` emits. + +Which PHP spells two ways. A class implementing `JsonSerializable` serialises as whatever `jsonSerialize()` **returns**; everything else as its **public properties**. The skeleton's `App\Orders\Order` is the case that decides the design: + +```php +final readonly class Order implements JsonSerializable +{ + /** @return array{id: int|null, customer: string, email: string, shipTo: Address, lines: list, total: float} */ + public function jsonSerialize(): array + { + return [/* … */ 'total' => $this->total()]; + } +} +``` + +`total` is a derived **method**, not a property. Reflecting properties alone would publish five of the six members the API actually sends. The array shape states all six, PHPStan checks it against the method, and the generator reads it — delete the annotation and `total` silently disappears from the document while the API keeps sending it. + +A declared shape only wins when it says something: `@return array` on `jsonSerialize()` means "an object, members unknown", which is strictly less than the property list it would have suppressed, so it is ignored in favour of reflection. + +One rule inverts on the way out. **Nullability is not requiredness here.** A response member is present or absent, and `?int $id` is always *present* and sometimes null — so response members stay `required` and nullable ones widen their type. The request side's rule would have told every client to expect an absence that never happens. + +### `#[ApiResponse]` takes a type expression too + +```php +final class ConsignmentController +{ + #[PostMapping(status: 201)] + #[ApiResponse(status: 409, description: 'That reference already exists.', type: Consignment::class)] + #[ApiResponse(status: 202, description: 'Accepted for later booking.', type: 'list')] + public function book(): array + { + return $this->consignments->book(); + } +} +``` + +`type` is a full expression, not only a class or a scalar name, and a short name resolves through the controller's own imports. --- @@ -673,6 +777,9 @@ final class ApiDocsConfiguration | `x-firefly-constraints` | Records what JSON Schema cannot state (`after:now`, a checksum, a flagged PCRE, a third-party rule) instead of dropping it | | `ProblemSchema` | The one shared `application/problem+json` response; documents Firefly's `code`/`category`/`severity`/`errors`, with the enums read off the kernel's own cases | | Derived error set | `400` only when something is rejectable before the controller runs, `422` only under `#[Valid]`, `default` always | +| `DocType` | Compiles a PHPDoc type expression to a JSON Schema fragment — shapes, generics, tuples, literal unions, PHPStan pseudo-types — and returns *nothing* rather than guessing when it cannot read one | +| `ResponseSchemaFactory` | Builds a returned class from its WIRE shape: `jsonSerialize()`'s declared `@return` when there is one, public properties otherwise. Response members stay `required` and nullable ones widen their type | +| `#[ApiResponse(type:)]` | A full type expression (`'list'`), resolved through the controller's own imports | | `$route->html` | `#[Controller]` HTML routes are excluded by default; `firefly.openapi.include-html` documents them as `text/html`, never as JSON | | `firefly.openapi.viewer.style` | `swagger` (default) \| `builtin` \| `cdn`. Only `cdn` makes a third-party request at page view; an unrecognised value falls back to `swagger` | | `SwaggerAssets` | Serves the OFFICIAL Swagger UI from your own origin out of the `swagger-api/swagger-ui` composer package — seven whitelisted basenames, each `realpath()`-checked inside the dist directory | @@ -685,4 +792,5 @@ final class ApiDocsConfiguration 1. **Generate Lumen's document and read it.** Run `php artisan firefly:openapi --output=openapi.json` in the sample, then open `/openapi` in a browser. Find `walletBalance` and confirm it has no `400` response, then find `walletDeposit` and confirm it has both a `400` and a `422` — and satisfy yourself, from this chapter's rules, why the two differ. 2. **Make the spec a CI gate.** Commit the generated file, then add a job that regenerates it and runs `git diff --exit-code` over it. Change a DTO — add a `#[Size(max: 32)]` to `OpenWalletRequest::$owner_id` — and watch the job fail with a diff that names the exact schema keyword that changed. 3. **Prove the default console makes no outbound request.** Open `/openapi` in the sample with the browser's network panel recording, and confirm every request is same-origin: the page, `openapi/assets/swagger-ui.css`, the two bundles, and `openapi.json`. Then set `firefly.openapi.viewer.style` to `cdn`, reload, and watch `cdn.jsdelivr.net` appear in the same panel — that request is the entire difference, and it is what a strict CSP or an air-gapped host would block. -4. **Watch a constraint fall through to the extension.** Add `#[Future]` to a `string` property on a request DTO, regenerate, and find the property's `x-firefly-constraints` array carrying `after:now` beside a perfectly ordinary `format: date-time`. Then add `#[Pattern('/^[a-z]+$/i')]` to another property and compare: the pattern *is* published, and the original rule is recorded beside it because the `i` flag could not survive the translation. +4. **Delete an annotation and watch the document lose a member.** In the skeleton, remove the `@return array{...}` from `App\Orders\Order::jsonSerialize()`, regenerate, and find `total` gone from the `Order` schema while `GET /orders/1` still returns it. Put it back, then change `total: float` to `total: string` and run PHPStan: the gate that keeps the document honest is the one that fails. +5. **Watch a constraint fall through to the extension.** Add `#[Future]` to a `string` property on a request DTO, regenerate, and find the property's `x-firefly-constraints` array carrying `after:now` beside a perfectly ordinary `format: date-time`. Then add `#[Pattern('/^[a-z]+$/i')]` to another property and compare: the pattern *is* published, and the original rule is recorded beside it because the `i` flag could not survive the translation. diff --git a/book/src/11-observability-actuator.md b/book/src/11-observability-actuator.md index 0dc7d1e..a4d92ae 100644 --- a/book/src/11-observability-actuator.md +++ b/book/src/11-observability-actuator.md @@ -2,7 +2,7 @@ # Observability: Health, Metrics, and the Actuator {.chtitle} -By the end of this chapter you will know `firefly/actuator`'s `HealthIndicator` SPI and the built-in `Ping`/`DiskSpace`/`Db` indicators, how `HealthEndpoint` aggregates them into a single `/actuator/health` response — and how a probe **group** (the mechanism behind "liveness" and "readiness") is nothing more than a named, configured subset of indicators, how the whole management surface is **unexposed by default** so a forgotten endpoint fails closed as a 404 rather than an information leak, and `firefly/observability`'s pure-PHP `MeterRegistry`, its locale-safe Prometheus exporter, and the exact `#[Order(500)]` precedence trick — the same one Chapter 10 showed you for security — that lets `MeterRegistryCqrsMetrics` replace the CQRS bus's `NoOpCqrsMetrics` with no code change to `firefly/cqrs` at all. The chapter closes on `firefly/admin`, the server-rendered browser dashboard over those same endpoints — thirteen pages including a drawn **bean graph** that resolves every constructor dependency through the interface it is wired by and reports the cycles a boot would otherwise die on with no message. It reads those endpoints **in-process**, so it renders pages the JSON surface deliberately keeps unexposed, which makes its own URL the entire security boundary and its default (`app.debug`) the most important line in the package. +By the end of this chapter you will know `firefly/actuator`'s `HealthIndicator` SPI and the built-in `Ping`/`DiskSpace`/`Db` indicators, how `HealthEndpoint` aggregates them into a single `/actuator/health` response — and how a probe **group** (the mechanism behind "liveness" and "readiness") is nothing more than a named, configured subset of indicators, how the whole management surface is **unexposed by default** so a forgotten endpoint fails closed as a 404 rather than an information leak, and `firefly/observability`'s pure-PHP `MeterRegistry`, its locale-safe Prometheus exporter, and the exact `#[Order(500)]` precedence trick — the same one Chapter 10 showed you for security — that lets `MeterRegistryCqrsMetrics` replace the CQRS bus's `NoOpCqrsMetrics` with no code change to `firefly/cqrs` at all. The chapter closes on `firefly/admin`, the server-rendered browser dashboard over those same endpoints — a drawn **bean graph** that resolves every constructor dependency through the interface it is wired by and reports the cycles a boot would otherwise die on with no message. It reads those endpoints **in-process**, so it renders pages the JSON surface deliberately keeps unexposed, which makes its own URL the entire security boundary and its default (`app.debug`) the most important line in the package. !!! note "New term: actuator" An **actuator** is a management endpoint that reports on the *running process itself* — is it healthy, what did it boot with, how fast are its requests — rather than on the business domain the process serves. The term and the shape both come from Spring Boot Actuator; `firefly/actuator` is a first-party, dependency-light PHP analogue: framework endpoints mounted directly on the same Illuminate `Router` your own controllers use, not a separate admin process. @@ -660,7 +660,7 @@ composer require firefly/admin Then open `/firefly`. There is no npm step at install time and no CDN at request time — the views are plain Blade with inline CSS and system fonts, because a Composer package cannot assume npm has run, and a dashboard that needs the network is useless in exactly the isolated environments where you most want to look at one. -Thirteen pages, each a view over one endpoint's payload. The menu groups them the way an operator thinks rather than the way the packages are laid out — what is it doing right now, what did it wire at boot, and how is it configured — because a flat list of thirteen links is a worse menu than three short ones: +Most pages are a view over one endpoint's payload; four read the container instead. The menu groups them the way an operator thinks rather than the way the packages are laid out — what is it doing right now, what did it wire at boot, what is its data, and how is it configured — because a flat list of seventeen links is a worse menu than four short ones: | Group | Page | Reads | Answers | |---|---|---|---| @@ -739,7 +739,7 @@ A throwing endpoint is caught and reported as `null` rather than allowed to take ### The bean graph -Twelve of the thirteen pages are tables. The thirteenth draws a picture, and it is the one that pays for the package on the day something is wired wrongly. +Most of the pages are tables. Two draw a picture — this one, and the [entity map](#walking-the-model-relations-filters-and-a-map) later in the chapter — and this is the one that pays for the package on the day something is wired wrongly. `/actuator/beans` tells you *which* beans exist. It cannot tell you what each one is **wired to**, which is what you actually want when a `#[ConditionalOnMissingBean]` did not fire the way you expected, when an eager singleton cycle has hung a boot with no message, or when you are trying to work out what a package you just installed attached itself to. `/firefly/graph` answers that, as a layered SVG diagram plus a filterable relations table. @@ -959,8 +959,10 @@ This page shows facts about the application's **users**. That is a categorically Writes then need a *second* key, and it is ineffective on its own. Reading the wrong row is a disclosure; deleting it is data loss with no undo, from a form, over a session that may be nothing more than "debug was on". Turning on the browser is a decision about **visibility**; turning on writes is a decision about **custody**. Collapse them into one key and the operator who wanted to look at a table has also armed the delete button. -!!! warning "There is no create, and that is not a gap to be filled in later" - A generic create form over an arbitrary entity is a promise the browser cannot keep, and Chapter 6 is the reason why: **an aggregate's constructor is where its invariants live.** An `Order` that must have at least one line, a `Wallet` whose balance starts at zero in the currency it was opened in, a value object that rejects a malformed IBAN — a form built from a column list knows none of them. There are only two ways to build the row: call the constructor, which needs arguments the form cannot supply in the right types or the right order; or write the columns straight to the table, which produces a row the domain model considers impossible and which every later read then has to cope with. The second is what a "just insert the columns" implementation actually does, and it is *worse than having no button*, because it looks like it worked. `update()` is offered because it operates on a row that already satisfies its invariants; `delete()` because removal needs no invariant at all. Creation belongs in your own code, where the constructor is. +!!! warning "Create exists for Eloquent, and is refused for everything else" + The browser had no `create()` at all for a while, and the argument was half right. A generic create form over an arbitrary entity is a promise it cannot keep, and Chapter 6 is why: **an aggregate's constructor is where its invariants live.** An `Order` that must have at least one line, a `Wallet` whose balance starts at zero in the currency it was opened in, a value object that rejects a malformed IBAN — a form built from a column list knows none of them. There are only two ways to build such a row: call the constructor, which needs arguments the form cannot supply in the right types or the right order; or write the columns straight to the table, which produces a row the domain model considers impossible. The second is what a "just insert the columns" implementation does, and it is *worse than having no button*, because it looks like it worked. **That case is still refused, by name.** + + It was never true for an **Eloquent model**, which is constructed empty and filled by attribute — precisely what `update()` has always done to a row that exists. Create was refusing on a risk update was already taking, and the inconsistency cost every application a CRUD surface that stopped at RUD. So it is offered for an Eloquent-backed resource under the same two switches, with the identifier and any masked column *omitted from the form* rather than disabled in it: a field the browser would refuse to write should not appear to accept. Two more decisions are worth carrying out of this section, because both are the sort of thing that reads as a detail and is not. @@ -971,6 +973,28 @@ Two more decisions are worth carrying out of this section, because both are the !!! note "The listing path you get depends on the interface you implemented" A `PagingAndSortingRepository` is paged **in the database**: the repository does the offset, the limit, the `ORDER BY` and the `COUNT`, and the cost is independent of table size. A plain `CrudRepository` cannot express any of that, so the browser calls `findAll()`, sorts and slices **in PHP**, and throws away all but 25 rows — which on ten thousand rows is a slow page and on ten million is an out-of-memory that kills the worker, on the *first* click. The interface has no limit, no offset and no count-with-predicate, so the honest options were "refuse to browse repositories that cannot page" or "browse them and say what it costs". LaraFly does the second, and this is the saying. Implement `PagingAndSortingRepository` on anything you intend to browse against a real table. +### Walking the model: relations, filters, and a map + +A listing you can only scroll is a table dump. Three things turn it into something you explore. + +**Relations are discovered by CALLING the methods that declare one**, because that is the only way to learn which columns they join on — a method's name says nothing and its return type says only the kind. Which makes "what is safe to call" the load-bearing question, and the answer is the **declared return type**: only a public, non-static, no-argument method returning an Eloquent `Relation` subclass is ever called. A method announcing `: HasMany` is a relation definition by construction — the same signal Laravel's own `with()` validation and IDE tooling rely on — and an accessor cannot claim it without lying about its signature. The call executes no query; Eloquent defers until `get()`. + +A `belongsTo` opens the one parent record; a `hasMany` opens the child listing **filtered to this row's key**, which is what the record page needs a filter for. A relation whose other end is not a browsable resource is still shown — it tells you the shape of the model — but is not linked, and that distinction lives in the model rather than the template so a view cannot mint a URL that 404s. + +**Filtering is eight comparisons over the columns the resource already publishes** — `is`, `is not`, `contains`, `starts with`, `greater`, `less`, `is empty`, `is not empty` — expressed as a GET URL you can bookmark or paste into a ticket. Every comparison binds its value, including the `LIKE` ones, where the wildcards go around an *escaped* value rather than the value going into a pattern. A column the schema does not publish and an operator outside that set are **dropped** rather than passed to the driver: both arrive in a URL an operator can hand-edit, and a query reaching the driver with an arbitrary identifier in it is a column-name oracle at best. Conditions AND with each other and with the search box, so narrowing a relation's listing cannot escape it. + +The same eight are implemented for the in-PHP fallback path, because a repository that cannot page must be filtered by the same rules as one that can. Two implementations of one predicate drift, and the drift shows up as a filter meaning different things on different resources. + +**`/firefly/data-map` draws it.** Every browsable entity as a box with its columns, every foreign key as a labelled edge, boxes linking into their own records. A `hasMany` and the `belongsTo` facing it are *one* key seen from two ends, so each is drawn once — pointing from the table that holds the key to the table it references, which is also what the arrow means. + +### Two more pages, and the one that changes things + +**`/firefly/datasource`** answers what a config dump cannot. Which database am I talking to (driver, host, database, with `password` masked by the same masker the `env` endpoint uses)? Is it *up* — one connection probed per page load, because opening a socket can hang against a firewalled host and a page that opened every configured connection would take the slowest one's timeout to render, on the page you opened *because* something is wrong. What does pooling mean here — PHP has no connection pool, and rather than invent a gauge the page reports PDO's `ATTR_PERSISTENT` for what it is, and says that under php-fpm the effective pool size is your worker count. And what did `#[Transactional]` compile to, which until now existed only as an artifact under `bootstrap/cache`. + +**`/firefly/settings`** is the only page in the dashboard that *changes* the application rather than describing it, and it is gated accordingly: `firefly.admin.settings.enabled` (off by default, unlike everything else here), `.writable` on top of it, and a third gate that is **not a configuration key** — in production every write is refused whatever the other two say. That last one is deliberate. It is the difference between "we made it safe" and "we made it configurable to be safe", and only the first survives someone copying a `.env`. + +It is a *feature switch*, not a remote configuration endpoint: the list is fixed and framework-owned, so a crafted POST naming `app.key` or a database host finds nothing to write. A change goes to one JSON file under `bootstrap/cache`, filtered on the way in as well as out, and merged over configuration in the provider's `register()` — not in a boot pass, and that distinction cost a debugging session. Every settings object in this framework is built once from config and held, so applying the overrides from the dashboard's own pass wrote the file and showed the new state on the page while `/openapi.json` kept answering 200. `register()` runs before any bean resolves, which is the only point at which the merge is true. + !!! laravel "Laravel parity" Plain Laravel ships no health-check or metrics endpoint at all — most teams either hand-roll a `/health` route or reach for a third-party package, usually paired with the `ext-prometheus` extension. `firefly/actuator` and `firefly/observability` are first-party, dependency-light analogues of Spring Boot Actuator and Micrometer respectively: framework endpoints mounted on the same `Router` your app already uses, health checks that reuse Laravel's own `DB`/`Log`/config underneath, and a pure-PHP Prometheus exporter with no extension requirement. Both packages are opt-in Composer dependencies and both are secure-by-default — an app that adds `firefly/actuator` gets `health`/`info` and nothing else until it configures more. `firefly/admin` completes the set as the analogue of Spring Boot Admin, with the difference that it is not a separate monitoring application you deploy and register instances with: it is Blade views inside the application it reports on, which is why it can read the registry directly and why its access model matters as much as it does. @@ -990,13 +1014,17 @@ Two more decisions are worth carrying out of this section, because both are the | `PrometheusTextFormat` | Locale-safe exposition — `number_format()`, never `sprintf('%f')` | | `MetricsFilter` | `#[Order(-100)]` outermost timing filter; tags by route **template**, never raw path — bounded cardinality | | `ObservabilityAutoConfiguration` `#[Order(500)]` | The same precedence trick as Chapter 10's security seam: registers `cqrsMetrics()` before `CqrsAutoConfiguration` evaluates its `#[ConditionalOnMissingBean]` | -| `firefly/admin` | A server-rendered Blade dashboard at `/firefly`; thirteen pages, and one whose endpoint is unregistered or switched off is hidden from the menu rather than linked | +| `firefly/admin` | A server-rendered Blade dashboard at `/firefly`; a page whose endpoint is unregistered or switched off is hidden from the menu rather than linked | | `AdminEndpointReader` | Invokes each `ActuatorEndpoint` **in-process** from `ActuatorRegistry`, bypassing `ExposureModel` — so the dashboard shows what the HTTP surface does not expose, and a throwing endpoint degrades one panel | | `BeanGraph` | Turns the beans catalogue into a drawn dependency graph over **three kinds of node** — components, `#[Bean]` products and `#[ConfigProperties]` DTOs — with `injects`/`produces` edges resolved through an interface index (marked `via`), longest-path layering, cycles reported rather than hung on, and the diagram suppressed past `firefly.admin.graph.max-nodes` (220) | | `#[Bean]` products as nodes | A framework's wiring lives in factory methods, not constructors; with only declaring classes as nodes a stock skeleton drew **one** edge out of 42 beans | | `ComponentDescriptor::$dependencies` | The graph's edges, recorded by `ComponentScanner` at **scan** time — class and interface types only, because a scalar parameter is configuration, not wiring | | `firefly.admin.enabled` | Defaults to `app.debug`; an explicit value wins in both directions, and turning it on with debug off obliges you to put your own auth middleware in front of the route | | `firefly.admin.data.enabled` | The data browser's own gate, defaulting to **`false`** — it does *not* follow `app.debug` or `firefly.admin.enabled`, because this page shows facts about the application's users rather than about the application | +| `RelationIntrospector` | Finds relations by CALLING only the methods whose declared return type is an Eloquent `Relation`, so a record links to what it references and the entity map has edges to draw | +| `DataFilter` | Eight comparisons over the columns the resource publishes, always bound, with an unknown column or operator dropped rather than passed to the driver | +| `/firefly/datasource` | Connections with secrets masked, one probed per load, PDO persistence reported for what it is, and the compiled `#[Transactional]` contract | +| `/firefly/settings` | The one page that changes the application: off by default, writable by a second key, and refused in production by a gate no key lifts | | `firefly.admin.data.writable` | A **second** gate, also `false` and ineffective alone: visibility and custody are different decisions, and one key would arm the delete button for whoever wanted to look at a table | | No `create()` | Permanent, not pending: an aggregate's invariants live in its constructor, and a form built from a column list cannot satisfy them — writing the columns anyway produces a row the domain considers impossible | diff --git a/docs/README.md b/docs/README.md index cab8295..f8a1aab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -92,7 +92,7 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa | [Observability](modules/observability.md) | `firefly/observability` — the `MeterRegistry`, Prometheus/Micrometer-JSON exposition, CQRS metrics | | [Admin Dashboard](modules/admin.md) | `firefly/admin` — the browser dashboard over the actuator; reads its endpoints in-process, so its own URL is the security boundary | | [Bean Graph](modules/bean-graph.md) | The dashboard's drawn dependency graph — components, `#[Bean]` products and `#[ConfigProperties]` DTOs as nodes, interface-resolved edges, longest-path layering, cycle reporting | -| [Data Browser](modules/data-browser.md) | A Django-style database browser over `CrudRepository` beans — **off by default**, writes behind a second gate, and no create. The model layer ships today; the dashboard page is not routed yet | +| [Data Browser](modules/data-browser.md) | A Django-style database browser over `CrudRepository` beans — **off by default**, writes behind a second gate, with filtering, paging, relations you can walk, and an entity map | ### Testing diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md index 98f0cae..c5e09ed 100644 --- a/docs/modules/data-browser.md +++ b/docs/modules/data-browser.md @@ -369,7 +369,7 @@ class name. The message stays in the exception, where a log can have it. | Key | Default | Meaning | |---|---|---| -| `firefly.admin.data.enabled` | **`false`** | Enable the browser at all — today that means the API, since no page is routed yet. Does **not** follow `app.debug` or `firefly.admin.enabled` — see [The two gates](#the-two-gates). | +| `firefly.admin.data.enabled` | **`false`** | Enable the browser at all — the `/firefly/data` pages, the entity map, and the `DataBrowser` API. Does **not** follow `app.debug` or `firefly.admin.enabled` — see [The two gates](#the-two-gates). | | `firefly.admin.data.writable` | **`false`** | Allow `update` and `delete`. Requires `enabled` as well; ineffective alone. | | `firefly.admin.data.page-size` | `25` | Default rows per page. Clamped into `[1, max-page-size]`. | | `firefly.admin.data.max-page-size` | `200` | Ceiling applied to any caller-supplied page size. Itself capped at **1000**, because `?perPage=1000000` on a resource that cannot page is a request to materialise the table into PHP memory. | @@ -404,16 +404,14 @@ instead. ## Known-latent -- **The browser is the model layer; the dashboard page that renders it is not wired yet.** Discovery, schema, - reads and both writes are complete and tested, and `DataBrowser::forContainer()` makes them usable from an - application's own code today. What has not landed is the Blade page and the route that would put them in - the dashboard's menu — so at present the gates below govern a library, not a URL. - **No create for a non-Eloquent repository**, permanently — see [above](#create-exists-for-eloquent-and-is-refused-for-everything-else). -- **Writes are Eloquent-only.** A plain `CrudRepository` over value objects is browsable and read-only. -- **No relationship navigation.** A foreign key renders as its value, not as a link to the row it points at: - the browser knows a column's type, not its target, and Eloquent relationships are methods rather than - metadata. +- **Writes are Eloquent-only.** A plain `CrudRepository` over value objects is browsable and read-only, for + the same reason. +- **A pivot or a polymorphic relation is listed, not walkable.** `BelongsToMany`, `HasManyThrough` and the + morph family have no single column the browser can filter on — a `MorphTo`'s other end is decided per row + by a type column — so they appear on a record page and are absent from the [entity map](admin.md#the-entity-map), + because a line with no join to name would be decoration. - **`firefly/admin` still ships no authentication of its own.** The data browser inherits the dashboard's access model exactly, which means the [route-level protection](admin.md#access-the-whole-security-boundary) is your responsibility — and matters more here than anywhere else in the dashboard. diff --git a/docs/modules/data.md b/docs/modules/data.md index 865de5d..d4426c2 100644 --- a/docs/modules/data.md +++ b/docs/modules/data.md @@ -263,4 +263,6 @@ implements `PagingAndSortingRepository` is paged **in the database** rather than is the difference between one page of rows and an out-of-memory. It is **disabled by default** and does not follow `app.debug` or `firefly.admin.enabled`; writes need a second -key on top of that, and there is deliberately no create. See [Data Browser](data-browser.md) for the reasoning. +key on top of that, and create is offered only for an Eloquent-backed resource — for a hand-written aggregate +the invariants live in its constructor, not in a column list. See [Data Browser](data-browser.md) for the +reasoning, and for the relations it walks between your entities. From 3f6b2f918e9b9ac3fd2e75c83dbc8c76aab6faf0 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 20:03:57 -0700 Subject: [PATCH 28/31] fix(web): an unrouted API path answers JSON instead of Laravel's page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declining the HTML page was only half of what `json-paths` had to do, and the other half was missing. A URL under `api/*` that matches no route throws Symfony's HttpException — not a FireflyException — and a browser's Accept header makes `expectsJson()` false. So both problem+json branches missed it and the request fell through to Laravel's own error page: an API path answering with a framework's stock HTML, which is precisely what the setting exists to prevent. Found by driving a clean `composer create-project` rather than by reading the branch, because the branch reads correctly on its own — it is the interaction of three conditions that leaves the gap. `forcesJson()` is now asked alongside the other two. Outside the declared API space nothing is forced and an unrouted URL still falls through to Laravel, because inventing a response shape for a caller that expressed no preference is not this package's decision to make. Verified on a clean install: `/api/anything` with a browser Accept is now `404 application/problem+json`, while `/no-such-page` with the same header stays `404 text/html`. FINAL SWEEP over the whole dashboard, everything switched on: 147 page × width combinations from 320px to 1920px with zero horizontal overflow, and 14 data tables with zero misaligned cells against their headers. 2065 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- packages/web/src/Error/ErrorPageRenderer.php | 16 +++++++++++++- packages/web/src/WebServiceProvider.php | 2 +- packages/web/tests/Error/ErrorPageTest.php | 23 ++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/web/src/Error/ErrorPageRenderer.php b/packages/web/src/Error/ErrorPageRenderer.php index 55bc599..0220c90 100644 --- a/packages/web/src/Error/ErrorPageRenderer.php +++ b/packages/web/src/Error/ErrorPageRenderer.php @@ -50,7 +50,7 @@ public function handles(Request $request): bool // A path the application declares as an API answers with a problem document whatever the caller // asked for. This is checked BEFORE the Accept header, not after, because it is the stronger // statement: the header says who is asking, the path says what the URL IS. - if ($this->settings->isJsonPath($request->path())) { + if ($this->forcesJson($request)) { return false; } @@ -59,6 +59,20 @@ public function handles(Request $request): bool return str_contains($accept, 'text/html') || str_contains($accept, 'application/xhtml+xml'); } + /** + * Whether this path must answer as JSON even though nothing about the REQUEST asked for it. + * + * Declining the HTML page is only half of what `json-paths` has to do. A URL under `api/*` that matches + * no route at all throws a Symfony HttpException, which is not a FireflyException, and a browser's + * Accept header means `expectsJson()` is false — so both of the problem+json branches missed it and the + * request fell through to Laravel's own error page. An API path that answers with a framework's stock + * HTML is exactly the outcome this setting exists to prevent, so the caller asks this too. + */ + public function forcesJson(Request $request): bool + { + return $this->settings->enabled && $this->settings->isJsonPath($request->path()); + } + public function render(Throwable $e, Request $request): Response { $exception = ProblemMapper::toFireflyException($e); diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index 260e006..74ded0a 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -200,7 +200,7 @@ private function registerProblemDetailsRenderable(): void return $page->render($e, $request); } - if ($e instanceof FireflyException || $request->expectsJson()) { + if ($e instanceof FireflyException || $request->expectsJson() || $page->forcesJson($request)) { return $this->app->make(ProblemDetailsRenderer::class)->render($e, $request); } diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 9af9522..83a5f86 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -165,3 +165,26 @@ ->and($renderer->handles(Request::create('/orders/9', 'GET', server: $browserAccept)))->toBeTrue() ->and($renderer->handles(Request::create('/apiary', 'GET', server: $browserAccept)))->toBeTrue(); }); + +it('answers an unrouted API path as JSON even when nothing about the request asked for it', function () { + // Declining the HTML page is only half of what json-paths has to do. A URL under `api/*` matching no + // route throws a Symfony HttpException — not a FireflyException — and a browser's Accept header makes + // `expectsJson()` false, so both problem+json branches missed it and the request fell through to + // Laravel's own error page. An API path answering with a framework's stock HTML is exactly what this + // setting exists to prevent. + $renderer = new ErrorPageRenderer(new ErrorPageSettings(enabled: true, jsonPaths: ['api/*'])); + + $browser = Request::create('/api/nope', 'GET', server: ['HTTP_ACCEPT' => 'text/html,application/xhtml+xml']); + + expect($renderer->handles($browser))->toBeFalse() + ->and($renderer->forcesJson($browser))->toBeTrue() + // Outside the API space nothing is forced, and a caller that expressed no preference still falls + // through to Laravel rather than having a shape invented for it. + ->and($renderer->forcesJson(Request::create('/orders/9', 'GET')))->toBeFalse(); +}); + +it('forces nothing at all when the page is switched off', function () { + $off = new ErrorPageRenderer(new ErrorPageSettings(enabled: false, jsonPaths: ['api/*'])); + + expect($off->forcesJson(Request::create('/api/nope', 'GET')))->toBeFalse(); +}); From 2505ad7e5040ba935fa36bd632e718e7a07552e0 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 20:26:38 -0700 Subject: [PATCH 29/31] fix(admin): three security defects an adversarial review of this branch found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pre-release review of the 606-file diff turned up three real problems, each reproduced before it was fixed and each now pinned by a regression test that fails when the fix is reverted. CSRF — EVERY DASHBOARD WRITE WAS FORGEABLE FROM ANOTHER SITE The routes were mounted on the router with NO middleware at all, which in Laravel means no session and no ValidateCsrfToken. Every `@csrf` in these views was therefore decorative: a tokenless `curl -X POST` against /firefly/loggers was accepted and changed the log level, and the same held for the data browser's edit and delete and for the settings console. A form that renders a CSRF field while the route ignores it is worse than one that renders none, because it looks protected. The first fix attached NOTHING and looked right. Naming the `web` group and guarding on `hasMiddlewareGroup('web')` produced an empty list, because this registrar runs inside the framework's boot pipeline — before the application's RouteServiceProvider defines that group. Attaching the CLASSES needs no group and no ordering assumption. Laravel's CSRF middleware returns early under `runningUnitTests()`, so no feature test can prove this either way — which is exactly how the hole survived being written. The regression test asserts the middleware is ATTACHED; the behaviour was verified over real HTTP: tokenless 419, token-with-session 302 and the write lands, stolen-token-without-session 419. Two consequences fell out. `skeleton/.env.example` moves to `SESSION_DRIVER=file` — an array session is discarded at the end of the request, so the token could never match and every dashboard POST would answer 419. And FireflyTestCase now sets a fixed app key, because EncryptCookies needs one and the harness was the only place a LaraFly application ever ran without one; 27 tests said so immediately. A FILTER ON A MASKED COLUMN WAS AN EXTRACTION ORACLE Filtering shipped over every column, which quietly re-opened the channel masking exists to close. A masked column renders as `******`, but a filter over it answers a yes/no question about the REAL value — and a yes/no question you can ask repeatedly recovers it. Twenty-one filtered requests returned `correct horse battery` from a column the listing showed only as asterisks, and `>`/`<` do it faster still by binary search. Sensitive columns are excluded from filtering now exactly as they already were from search — in the model, and in the control, so the column is not even offered. ESCAPING A `LIKE` WITHOUT AN `ESCAPE` CLAUSE SILENTLY MATCHED NOTHING `contains`/`starts with` backslash-escaped the user's `%` and `_` and then emitted a plain `LIKE ?`, which leaves the driver with no escape character declared — so the backslash was matched literally and a search for `ada_love` returned zero rows against a table holding `ada_lovelace@example.test`. Suppressing the wildcards worked; finding an underscore stopped working, which is the worse half of the two. The predicate emits an explicit ESCAPE with the column wrapped by the grammar, and the search box — which had no escaping at all, so a bare `%` matched every row — goes through the same helper. Also cuts 26.09.1: Version::VERSION, the CHANGELOG heading and the README badge move together, and the CHANGELOG's intro claim that admin and openapi sit outside the metapackage was corrected. 2072 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- CHANGELOG.md | 113 ++++++++++++++++-- README.md | 6 +- docs/modules/admin.md | 29 +++++ docs/modules/data-browser.md | 28 ++++- .../admin/resources/views/data-list.blade.php | 5 + .../admin/src/Boot/AdminRouteRegistrar.php | 35 ++++++ packages/admin/src/Data/DataBrowser.php | 15 ++- packages/admin/src/Data/DataQueryEngine.php | 49 ++++++-- packages/admin/src/Data/DataSchema.php | 23 ++++ packages/admin/tests/AdminCsrfTest.php | 56 +++++++++ .../admin/tests/Data/DataFilterSafetyTest.php | 106 ++++++++++++++++ packages/kernel/src/Version.php | 2 +- packages/testing/src/FireflyTestCase.php | 11 ++ skeleton/.env.example | 5 +- 14 files changed, 452 insertions(+), 31 deletions(-) create mode 100644 packages/admin/tests/AdminCsrfTest.php create mode 100644 packages/admin/tests/Data/DataFilterSafetyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e79d876..8e0a1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,16 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`YY.MM.Patch`). -## [Unreleased] - -Cut as `26.09.1` when released: `Firefly\Kernel\Version::VERSION`, this heading, and the README version -badge move together (see [Versioning](docs/versioning.md)), and `tests/VersionConsistencyTest.php` fails the -build if any one of the three drifts. +## [26.09.1] - 2026-09-03 A correctness release that also grew two surfaces. Several headline features were found not to work at all outside the compiled boot, and two of the failures were **fail-open** in the security sense — the application kept serving, unguarded, with nothing logged; every fix below was reproduced by a failing test first. Alongside them, LaraFly gained the two things a framework this shape is expected to have and did not: a browser dashboard over the actuator (`firefly/admin`) and an OpenAPI 3.1 document generated from the manifests it already holds -(`firefly/openapi`). Both are opt-in Composer packages, outside the `firefly/firefly` metapackage, and neither -needs npm or a CDN. +(`firefly/openapi`). Both now ship with the `firefly/firefly` metapackage — they were outside it, which meant +a `composer create-project firefly/skeleton` resolved 260 packages and neither of them was among them — and +neither needs npm or a CDN. ### BREAKING @@ -125,6 +122,54 @@ needs npm or a CDN. also gains a `#[Controller]` welcome page (nothing on it hard-coded — real bean/condition counts, the real route table, the real actuator registry) and its first test suite. +- **The OpenAPI document now says what an endpoint RETURNS.** Every success response was `{"type": "object"}` + — an object with no members, which a viewer renders as a blank panel and `openapi-generator` turns into + `any`. The shape was never unavailable: it is written in the `@return` one line above the method, where + PHPStan at level max already checks it against the code on every build, which is what makes reading it safe. + `DocType` compiles a PHPDoc type expression into a JSON Schema fragment — array shapes with optional keys, + `list`, `array` told apart as array-vs-object, tuples, literal unions, nullable references and the + PHPStan pseudo-types (`non-empty-string` → `minLength`, `positive-int` → `minimum`) — and returns *nothing* + rather than guessing when it cannot read one. `ResponseSchemaFactory` builds a returned class from its WIRE + shape: `jsonSerialize()`'s declared `@return` when there is one, public properties otherwise, because those + differ — the skeleton's `Order` publishes a derived `total` that is a method, so reflection alone documented + five of the six members the API sends. `#[ApiResponse(type:)]` takes a full expression (`'list'`), + resolved through the controller's own imports. Verified by validating live responses member-by-member + against the schema the generator wrote for them. See [OpenAPI](docs/modules/openapi.md). +- **An HTML error page, in the framework's own design.** Any `FireflyException` rendered as `problem+json` + regardless of who asked, so a person clicking a stale link in a browser was shown a raw JSON blob; a URL + matching no route missed that branch entirely and fell through to Laravel's stock page, so one application + produced two unrelated-looking 404s. The page shows the status, the reason, the stable `code` the problem + document carries, and — when permitted — the exception, its `previous` chain, the source around the throwing + line and a stack trace with *your* frames separated from your dependencies'. `firefly.web.error-page.trace` + follows `app.debug` and is enforced where the data is GATHERED: with it off nothing walks the stack, opens a + source file or copies the message, so a template mistake cannot leak what was never collected. + `firefly.web.error-page.views` hands a status to your own Blade view, bound by the same gate, falling back to + the built-in page if it throws. `json-paths` (default `api/*`) forces `problem+json` on your API space + whatever the caller's Accept header says. The page itself is built as a string with no container lookups and + no view factory, because the failure being explained may *be* the view layer. See + [Error Handling](docs/modules/error-handling.md). +- **The dashboard gained a datasource page, an entity map, and a feature-switch console.** `/firefly/datasource` + answers what a config dump cannot: which database (secrets masked), whether it is *up* (one connection probed + per load, because a page that opened every configured connection would take the slowest one's timeout to + render), what connection reuse actually means in PHP (`ATTR_PERSISTENT`, reported for what it is rather than + dressed up as a pool gauge), and what `#[Transactional]` compiled to. `/firefly/data-map` draws the entities + and the foreign keys between them. `/firefly/settings` is the only page that CHANGES the application, and has + three gates — off by default, writable by a second key, and refused outright in production by a check that is + deliberately **not** a configuration key. An optional connection wizard tests an unconfigured connection and + hands back a config block; it writes nothing, never inlines a password, is POST-only, and is unavailable in + production for the same reason. See [Admin Dashboard](docs/modules/admin.md). +- **The data browser gained filtering, real pagination, create, and relations you can walk.** Eight + comparisons over the columns a resource publishes, always bound — including the `LIKE` ones, where the + wildcards go around an escaped value — with an unknown column or operator DROPPED before reaching the driver, + so a hand-edited URL cannot probe for column names. Conditions AND with each other and with the search box. + Relations are discovered by calling only the methods whose *declared return type* is an Eloquent `Relation`, + so a record links to what it references in both directions. `create()` is now offered for an Eloquent-backed + resource under the same two switches — the constructor-invariants argument that kept it out was right for a + hand-written aggregate and was never true for a model Eloquent builds empty and fills by attribute, which is + exactly what `update()` had always done. A `float` column type joins the vocabulary: every non-integer number + used to be typed `string`, so a money column read as a string, was offered to a `LIKE` search, and let the + editor save `"abc"` into it. See [Data Browser](docs/modules/data-browser.md). + ### Changed - **`#[Qualifier]` on a parameter is honoured.** It declared `TARGET_PARAMETER` from day one and nothing read it, so `#[Qualifier('redisCache')] Cache $cache` silently received whatever `Cache::class` resolved to. It @@ -237,6 +282,60 @@ needs npm or a CDN. `make:firefly-listener` generated a class the scanner could not discover. Stub tests now generate from each stub and assert the output is valid PHP *and* discoverable by the relevant scanner. +- **SECURITY — every dashboard write was forgeable from another site.** The admin routes were mounted with no + middleware at all, which in Laravel means no session and no `ValidateCsrfToken`, so the `@csrf` field in + every dashboard form was decorative: a tokenless `curl -X POST` against `/firefly/loggers` was accepted and + changed the log level, and the same held for the data browser's edit and delete and the settings console. + A form that renders a CSRF field while the route ignores it is worse than one that renders none. Fixed by + attaching the middleware CLASSES rather than the `web` group name — naming the group and guarding on + `hasMiddlewareGroup('web')` attached nothing, because the registrar runs before the application defines + that group. `skeleton/.env.example` moves to `SESSION_DRIVER=file`: an array session is discarded at the end + of the request, so the token could never match and every POST would answer 419. Found by an adversarial + review of this branch; Laravel's CSRF middleware skips itself under tests, which is how it survived being + written, so the regression test asserts the middleware is attached and the behaviour was proven over real + HTTP. +- **SECURITY — a filter on a masked column was an extraction oracle.** Filtering shipped over every column, + which quietly re-opened the channel masking exists to close: a masked column renders as `******`, but a + filter over it answers a yes/no question about the real value, and a yes/no question you can ask repeatedly + recovers it. Proven against the fixture — twenty-one filtered requests returned `correct horse battery` from + a column the listing showed only as asterisks, and `>`/`<` do it faster by binary search. Sensitive columns + are now excluded from filtering exactly as they already were from search, in the model and in the control. +- **Escaping a `LIKE` without an `ESCAPE` clause silently matched nothing.** `contains`/`starts with` + backslash-escaped the user's `%` and `_` and then emitted a plain `LIKE ?`, which leaves the driver with no + escape character declared — so the backslash was matched literally and a search for `ada_love` returned zero + rows against a table holding `ada_lovelace@example.test`. Suppressing the wildcards worked; finding an + underscore stopped working, which is the worse half. The predicate now emits an explicit `ESCAPE`, and the + search box — which had no escaping at all, so a bare `%` matched every row — goes through the same helper. +- **`composer create-project firefly/skeleton` shipped neither the dashboard nor the API documentation.** + `firefly/admin` and `firefly/openapi` were built, tested, documented and offered by `firefly new --with` while + *nothing* required them. The welcome page checks `class_exists()` before linking, so it did not render a + broken link — it silently rendered two cards fewer, which is the worse failure because nothing looked wrong. + Fixed in the BOM rather than the skeleton, because the asymmetry was the actual bug: for eleven of thirteen + capabilities `--with` promotes an already-installed package to an explicit dependency, and for these two it + decided whether the code existed at all. `tests/MetapackageCoverageTest.php` holds both ends. +- **The skeleton's sample REST resource did not persist, and its docblock said it did.** `OrderRepository` kept + orders in an array on a singleton and claimed the state survived between requests. PHP shares nothing between + requests, so `POST /orders` returned 201 with an id and the very next `GET /orders` reported an empty store — + the first thing a new user does. The skeleton's own suite passed throughout, because Laravel reuses ONE + application across the requests of a single test. It is now an `EloquentRepository` over two tables — an + address is a value and stays an embedded json column, a line is an entity and gets a table, a foreign key and + a repository — which also earns the sample its first `#[Transactional]`, gives the data browser something to + browse, and gives the entity map an edge to draw. `migrate` joins `post-create-project-cmd`. +- **`skeleton/config/firefly.php` had drifted from the code it documents.** Three keys the framework reads were + undocumented, including `firefly.management.server.address` — half of the management-port feature. + `tests/ConfigReferenceTest.php` now checks all 84 keys read through the Config port and fails the build when + one is added without a word written about it. +- **`packages/admin` — the data grid's columns did not line up with their headers.** The listing table carried + `class="grid"`, colliding with the layout's own `.grid{display:grid}` utility, so the table became a grid + CONTAINER, `thead` and `tbody` computed to `display:block`, and the two row groups sized their columns + independently. Invisible in the markup and not findable by reading the CSS — it came out of asking the + browser what `display` the element had ended up with. +- **Tertiary text across the dashboard and the welcome page was below WCAG AA.** `#8d95a1` is 2.8:1 on the + dashboard's own background, and it painted table cells, every panel's explanatory note, the uppercase stat + labels and the namespace half of every class name — content, not decoration. Now 4.95:1 and 4.76:1 in light, + 5.6:1 and 5.3:1 in dark. The brand orange was 3.01:1 as a foreground and is no longer used as text: shapes + and text take different oranges. + ## [26.07.18] - 2026-07-28 ### Added diff --git a/README.md b/README.md index 3211007..525cbb5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ PHP 8.3+ Laravel 13 License: Apache 2.0 - Version: 26.07.18 + Version: 26.09.1 PHPStan: max Code Style: Pint

    @@ -65,7 +65,7 @@ [*PyFly by Example*](https://github.com/fireflyframework/fireflyframework-pyfly). It builds **Lumen**, the wallet-and-ledger service in [`samples/lumen/`](samples/lumen/), from an empty directory into a secured, event-driven, actuator-observed microservice, chapter by chapter — every listing drawn from that real project -(it boots and its tests pass against this framework version, `26.07.18`). +(it boots and its tests pass against this framework version, `26.09.1`). The book is **complete and bilingual (English + Spanish)**: a quick start, **thirteen chapters** across four parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD), Coordinating & @@ -332,7 +332,7 @@ and the seam that makes this possible. Nine showcases below, each an accurate snippet lifted straight from `samples/lumen/` (the wallet-and-ledger sample) or the framework itself — no invented API. Every attribute and class shown here compiles against the -shipped `26.07.18` release. +shipped `26.09.1` release. ### Attribute DI — `#[Service]` diff --git a/docs/modules/admin.md b/docs/modules/admin.md index 007ef3c..fe44d08 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -176,6 +176,35 @@ path, not that it be `firefly/security`. When the dashboard is disabled, `AdminRouteRegistrar` registers **nothing at all**: there is no route to guess at and no handler to reach, and `php artisan route:list` does not list one. +### The write surfaces are CSRF-protected, and were not + +The dashboard's routes were mounted on the router with **no middleware at all**, which in Laravel means no +session and no `ValidateCsrfToken`. Every `@csrf` in these views was therefore decorative: a `curl -X POST` +with no token against `/firefly/loggers` was accepted and changed the log level, and the same held for every +write the data browser and the settings console added. + +A form that renders a CSRF field while the route ignores it is **worse than one that renders none**, because +it looks protected. The routes now carry `EncryptCookies`, `AddQueuedCookiesToResponse`, `StartSession`, +`ShareErrorsFromSession` and `ValidateCsrfToken`. + +!!! note "The classes, not the `web` group name" + Naming the group and guarding on `hasMiddlewareGroup('web')` looked right and attached **nothing**: this + registrar runs inside the framework's boot pipeline, *before* the application's `RouteServiceProvider` + defines that group, so the guard was false at registration time and silently produced an empty list — a + fix that appeared applied and was not. Referring to the classes needs no group and no ordering + assumption, and each is skipped if the installation does not have it. + +!!! warning "Your session driver has to persist" + An `array` session driver is discarded at the end of the request, so the token a form renders can never + match the one the next request checks and **every** dashboard POST answers `419`. The skeleton now ships + `SESSION_DRIVER=file` for exactly this reason; `file` needs no service, only the storage directory the + framework already writes to. + + Laravel's CSRF middleware returns early when `runningUnitTests()` is true, so no feature test can prove + this either way — which is how the hole survived being written. `tests/AdminCsrfTest.php` therefore + asserts the middleware is *attached*, and the behaviour was verified over real HTTP: tokenless → `419`, + token with its session → `302` and the write lands. + ## How it is mounted `AdminRouteRegistrar` is a `BootPass` at `BootPhase::WiringPasses`, order **60** — one step after diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md index c5e09ed..f44625c 100644 --- a/docs/modules/data-browser.md +++ b/docs/modules/data-browser.md @@ -272,7 +272,7 @@ ticket or hand to someone else, which is most of what a data explorer is for — the whole state, because a sort that dropped the filter would widen the listing back to every row, which reads as rows appearing from nowhere. -Eight comparisons, over the columns the resource already publishes: +Eight comparisons, over the columns the resource publishes **minus the masked ones**: | Operator | Meaning | |---|---| @@ -285,10 +285,28 @@ Two spellings in the URL: `?fk=order_id&fv=7` is a single equality and is what e short enough to read in a status bar — while `?fc[]=…&fo[]=…&fv[]=…` is what the filter bar builds. Both are validated identically. -**A column the schema does not publish, and an operator outside that set, are dropped** rather than passed to the -driver. Both arrive in a URL an operator can hand-edit, and a query that reached the driver with an arbitrary -identifier in it is a column-name oracle at best. Dropping rather than erroring is deliberate too: an error that -distinguished "no such column" from "no rows" would answer the same question more slowly. +**A column the schema does not publish for filtering, and an operator outside that set, are dropped** rather +than passed to the driver. Both arrive in a URL an operator can hand-edit, and a query that reached the driver +with an arbitrary identifier in it is a column-name oracle at best. Dropping rather than erroring is deliberate +too: an error that distinguished "no such column" from "no rows" would answer the same question more slowly. + +!!! danger "A sensitive column is not filterable, and this was learned the hard way" + Filtering was first shipped over *every* column, which quietly re-opened the channel masking exists to + close. A masked column renders as `******`, but a filter over it answers a yes/no question about the real + value — and a yes/no question you can ask repeatedly is an **extraction oracle**. An adversarial review of + the branch proved it against the fixture: twenty-one filtered requests recovered `correct horse battery` + from a column the listing showed only as asterisks, and `>`/`<` do it faster still by binary search. The + filterable set is now `searchable()`'s rule applied to every type — everything except the masked columns — + and `tests/Data/DataFilterSafetyTest.php` runs the original attack as a regression test. + +!!! danger "Escaping a `LIKE` without an `ESCAPE` clause is worse than not escaping" + The same review found the other half. `contains` and `starts with` backslash-escape the user's `%` and + `_` so they cannot act as wildcards — but a plain `LIKE ?` leaves the driver with no escape character + declared, so the backslash is matched *literally*. Suppressing the wildcards worked; finding anything + containing an underscore stopped working, and it failed **silently**: a search for `ada_love` returned + zero rows against a table holding `ada_lovelace@example.test`. The predicate now emits an explicit + `ESCAPE` clause, with the column wrapped by the grammar rather than interpolated, and the search box — + which had no escaping at all, so a bare `%` matched every row — goes through the same helper. Filters **AND** with each other and with the search box, so narrowing a relation's listing cannot escape it. Every comparison binds its value, including the `LIKE` ones. diff --git a/packages/admin/resources/views/data-list.blade.php b/packages/admin/resources/views/data-list.blade.php index 7663127..198cbc1 100644 --- a/packages/admin/resources/views/data-list.blade.php +++ b/packages/admin/resources/views/data-list.blade.php @@ -82,9 +82,14 @@ @php $rows = $listing->filters; $rows[] = null; @endphp @foreach ($rows as $row)
    + {{-- filterable(), not the whole column list: a masked column is not offered here + because a filter over it answers a yes/no question about the value the page + refuses to show, which repeated is an extraction oracle. DataBrowser drops + one anyway; this is so the control never appears to accept it. --}} diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php index b37781e..6f6744d 100644 --- a/packages/admin/src/Boot/AdminRouteRegistrar.php +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -24,8 +24,14 @@ use Firefly\Context\Boot\BootPass; use Firefly\Context\Boot\BootPhase; use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; +use Illuminate\Cookie\Middleware\EncryptCookies; +use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; +use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Http\Request; use Illuminate\Routing\Router; +use Illuminate\Session\Middleware\StartSession; +use Illuminate\View\Middleware\ShareErrorsFromSession; /** * Mounts the dashboard on the illuminate Router at phase WiringPasses, order 60 — after @@ -120,9 +126,38 @@ public function run(BootContext $context): void $router = $container->make('router'); $base = $settings->basePath; + // THE `web` GROUP, AND WHY IT IS NOT OPTIONAL. These routes were mounted bare, and a bare route in + // Laravel carries NO middleware at all — no session, and no VerifyCsrfToken. Every `@csrf` in these + // views was therefore decorative: a tokenless POST to /firefly/loggers was accepted and changed the + // log level, and the same held for every write the data browser and the settings console added. A + // form that renders a CSRF field while the route ignores it is worse than one that renders none, + // because it looks protected. + // + // `web` is also what makes the rest of the page work: the session it starts is what carries the + // outcome sentence a write flashes on its way back, which is why AdminAction::redirect() had to + // guard on the session not being started at all. + // + // THE CLASSES, NOT THE `web` GROUP NAME, and that distinction is the fix working versus only + // appearing to. Naming the group and guarding on `hasMiddlewareGroup('web')` looked right and + // attached NOTHING: this pass runs inside the framework's boot pipeline, before the application's + // RouteServiceProvider has defined that group, so the guard was false at registration time and + // silently produced an empty list. Referring to the classes needs no group and no ordering + // assumption, and each is skipped if the installation does not have it. + $middleware = array_values(array_filter([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + // Laravel renamed this in 11; both spellings are accepted so the dashboard is not pinned to one + // minor version for its only line of CSRF defence. + class_exists(ValidateCsrfToken::class) ? ValidateCsrfToken::class : VerifyCsrfToken::class, + ], static fn (string $class): bool => class_exists($class))); + $router->get($base, static fn (Request $request) => $container->make(AdminAction::class)($request)) + ->middleware($middleware) ->name('firefly.admin.index'); $router->match(['GET', 'POST'], $base.'/{page}', static fn (Request $request, string $page) => $container->make(AdminAction::class)($request, $page)) + ->middleware($middleware) ->where('page', '[A-Za-z0-9\-_/]*') ->name('firefly.admin.page'); } diff --git a/packages/admin/src/Data/DataBrowser.php b/packages/admin/src/Data/DataBrowser.php index 16a20fd..87c7cda 100644 --- a/packages/admin/src/Data/DataBrowser.php +++ b/packages/admin/src/Data/DataBrowser.php @@ -167,18 +167,21 @@ public function list( /** * Filters the resource can actually answer, with everything else dropped. * - * A filter naming a column the resource does not have is DROPPED rather than passed to the database, and - * so is one naming an operator that is not in the fixed set. Both arrive in a URL an operator can - * hand-edit, and a query that reached the driver with an arbitrary identifier or comparison in it is a - * column-name oracle at best. Dropping rather than erroring is deliberate too: an error message that - * distinguished "no such column" from "no rows" would answer the same question more slowly. + * A filter naming a column the resource does not PUBLISH FOR FILTERING is dropped rather than passed to + * the database, and so is one naming an operator that is not in the fixed set. Both arrive in a URL an + * operator can hand-edit, and a query that reached the driver with an arbitrary identifier or comparison + * in it is a column-name oracle at best. Dropping rather than erroring is deliberate too: an error + * message that distinguished "no such column" from "no rows" would answer the same question more slowly. * * @param list $filters * @return list */ private function validFilters(array $filters, DataSchema $schema): array { - $columns = array_map(static fn (DataColumn $column): string => $column->name, $schema->columns); + // filterable(), NOT the whole column list. A masked column renders as `******` and a filter over it + // answers a yes/no question about the real value — which, asked repeatedly, recovers it. See + // DataSchema::filterable(). + $columns = $schema->filterable(); return array_values(array_filter( $filters, diff --git a/packages/admin/src/Data/DataQueryEngine.php b/packages/admin/src/Data/DataQueryEngine.php index d6fa9a1..d63deb9 100644 --- a/packages/admin/src/Data/DataQueryEngine.php +++ b/packages/admin/src/Data/DataQueryEngine.php @@ -229,12 +229,12 @@ private function fetch( private function filterSpecification(DataFilter $filter): Specification { return Specifications::where(static function (Builder $query) use ($filter): void { - $escaped = addcslashes($filter->value, '%_\\'); + $escaped = self::escapeLike($filter->value); match ($filter->operator) { DataFilter::NE => $query->where($filter->column, '!=', $filter->value), - DataFilter::CONTAINS => $query->where($filter->column, 'like', '%'.$escaped.'%'), - DataFilter::STARTS => $query->where($filter->column, 'like', $escaped.'%'), + DataFilter::CONTAINS => self::like($query, $filter->column, '%'.$escaped.'%'), + DataFilter::STARTS => self::like($query, $filter->column, $escaped.'%'), DataFilter::GT => $query->where($filter->column, '>', $filter->value), DataFilter::LT => $query->where($filter->column, '<', $filter->value), DataFilter::NULL => $query->whereNull($filter->column), @@ -244,6 +244,41 @@ private function filterSpecification(DataFilter $filter): Specification }); } + /** + * A LIKE that treats the user's `%` and `_` as literals. + * + * ESCAPING ALONE WAS WORSE THAN NOT ESCAPING. Backslash-escaping the wildcards and then emitting a plain + * `LIKE ?` means the driver has no escape character declared, so `ada\_love` is matched literally — a + * search for `ada_love` returned ZERO rows against a table that contained `ada_lovelace@example.test`. + * Suppressing the wildcards worked; finding anything containing an underscore stopped working, silently. + * The `ESCAPE` clause is what makes the backslash mean "the next character is a literal", and every + * driver this framework supports understands it. + * + * The COLUMN is wrapped by the grammar rather than interpolated raw. It has already been validated + * against the schema by the caller, so this is belt-and-braces — but a raw identifier inside a + * `whereRaw` is exactly the shape that stops being safe the day someone loosens the validation. + * + * @param Builder $query + */ + private static function like(Builder $query, string $column, string $pattern, string $boolean = 'and'): void + { + $wrapped = $query->getQuery()->getGrammar()->wrap($column); + + // Raw because neither `where(…, 'like', …)` nor Laravel 13's own `whereLike()` emits an ESCAPE + // clause — both compile to a bare `LIKE ?`, which is precisely the shape that made an escaped + // underscore match nothing. PHPStan wants a literal-string here and cannot see that $wrapped came + // from the grammar's own quoting of a column the caller already checked against DataSchema:: + // filterable(); the VALUE is a binding either way. + // @phpstan-ignore argument.type + $query->whereRaw($wrapped." like ? escape '\\'", [$pattern], $boolean); + } + + /** Backslash-escapes the LIKE metacharacters, for use with the ESCAPE clause above. */ + private static function escapeLike(string $value): string + { + return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); + } + /** * The fallback: materialise everything, then filter, sort and slice in PHP. See the class docblock for * what this costs — it is the price of browsing a repository that cannot page, and it is charged in full @@ -310,12 +345,10 @@ private function fetchInPhp( */ private function searchSpecification(array $columns, string $term): Specification { - $pattern = '%'.$term.'%'; - - return Specifications::where(static function (Builder $query) use ($columns, $pattern): void { - $query->where(static function (Builder $group) use ($columns, $pattern): void { + return Specifications::where(static function (Builder $query) use ($columns, $term): void { + $query->where(static function (Builder $group) use ($columns, $term): void { foreach ($columns as $column) { - $group->orWhere($column, 'like', $pattern); + self::like($group, $column, '%'.self::escapeLike($term).'%', 'or'); } }); }); diff --git a/packages/admin/src/Data/DataSchema.php b/packages/admin/src/Data/DataSchema.php index 2fe404f..7939074 100644 --- a/packages/admin/src/Data/DataSchema.php +++ b/packages/admin/src/Data/DataSchema.php @@ -93,6 +93,29 @@ public function searchable(): array )); } + /** + * The columns a FILTER may name. + * + * SENSITIVE COLUMNS ARE EXCLUDED, for exactly the reason they are excluded from search — and this had to + * be learned twice. A masked column renders as `******`, but a filter over it answers a yes/no question + * about its real value, and a yes/no question you can ask repeatedly is an extraction oracle: `starts + * with 'a'`, `starts with 'b'`, … recovers the whole secret one character at a time while the page never + * displays it. Proven against the fixture: the listing showed `******` and twenty-one filtered queries + * returned `correct horse battery`. + * + * Unlike `searchable()` this is not restricted to strings — filtering an `int` or a `datetime` is the + * ordinary case, and the comparison set includes `>` and `<` precisely for them. + * + * @return list + */ + public function filterable(): array + { + return array_values(array_map( + static fn (DataColumn $column): string => $column->name, + array_filter($this->columns, static fn (DataColumn $column): bool => ! $column->sensitive), + )); + } + /** * The columns an ORDER BY may name. JSON is excluded because ordering a serialized blob sorts its text, * which looks like it worked and means nothing. diff --git a/packages/admin/tests/AdminCsrfTest.php b/packages/admin/tests/AdminCsrfTest.php new file mode 100644 index 0000000..31dfd56 --- /dev/null +++ b/packages/admin/tests/AdminCsrfTest.php @@ -0,0 +1,56 @@ +getRoutes() as $route) { + if (str_starts_with($route->uri(), 'firefly')) { + $admin[$route->uri()] = $route->gatherMiddleware(); + } + } + + expect($admin)->not->toBeEmpty(); + + foreach ($admin as $middleware) { + // The session cookie has to survive the round trip too, or the token can never match on the way back. + expect($middleware)->toContain(StartSession::class) + ->toContain(ValidateCsrfToken::class) + ->toContain(EncryptCookies::class); + } +}); + +it('names the middleware classes rather than the web group', function () { + /** @var AdminCapstoneTestCase $this */ + $route = null; + foreach (Route::getRoutes()->getRoutes() as $candidate) { + if ($candidate->uri() === 'firefly') { + $route = $candidate; + } + } + + // Naming the group and guarding on `hasMiddlewareGroup('web')` attached NOTHING: this registrar runs + // inside the framework's boot pipeline, before the application's RouteServiceProvider defines that + // group, so the guard was false at registration time and produced an empty list — a fix that looked + // applied and was not. The classes need no group and no ordering assumption. + expect($route?->gatherMiddleware() ?? [])->not->toContain('web'); +}); diff --git a/packages/admin/tests/Data/DataFilterSafetyTest.php b/packages/admin/tests/Data/DataFilterSafetyTest.php new file mode 100644 index 0000000..ebcf768 --- /dev/null +++ b/packages/admin/tests/Data/DataFilterSafetyTest.php @@ -0,0 +1,106 @@ +seedRecords(); + $browser = $this->browser(); + + // THE ORIGINAL ATTACK, verbatim. A masked column renders as `******`, but a filter over it answers a + // yes/no question about the REAL value — and a yes/no question you can ask repeatedly is an extraction + // oracle. Before the fix this loop recovered `correct horse battery` in twenty-one rounds while the + // listing showed nothing but asterisks. + $alphabet = array_merge(range('a', 'z'), [' ']); + $recovered = ''; + + for ($i = 0; $i < 21; $i++) { + foreach ($alphabet as $character) { + $listing = $browser->list('admin-record', filters: [ + new DataFilter('recovery_phrase', DataFilter::STARTS, $recovered.$character), + ]); + + if ($listing->total === 1) { + $recovered .= $character; + break; + } + } + } + + expect($recovered)->toBe('') + // The filter is DROPPED, so the listing widens rather than erroring — which also means the attacker + // learns nothing from the difference between "no such column" and "no rows". + ->and($browser->list('admin-record')->rows[0]['recovery_phrase'])->toBe('******'); +}); + +it('drops a filter on any sensitive column, whatever the comparison', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(); + + // `>` and `<` are an oracle too, and a cheaper one: a binary search over the value space needs far fewer + // rounds than walking the alphabet. Every operator is refused, not just the LIKE ones. + foreach ([DataFilter::EQ, DataFilter::NE, DataFilter::CONTAINS, DataFilter::STARTS, DataFilter::GT, DataFilter::LT, DataFilter::NULL, DataFilter::NOT_NULL] as $operator) { + foreach (['api_token', 'recovery_phrase'] as $column) { + $listing = $browser->list('admin-record', filters: [new DataFilter($column, $operator, 'sk_live')]); + + expect($listing->filters)->toBe([]) + ->and($listing->total)->toBe(5); + } + } +}); + +it('still filters on the ordinary columns, including the non-string ones', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + $browser = $this->browser(); + + // The fix must not have closed the feature. `filterable()` is deliberately wider than `searchable()`: + // filtering an int or a datetime is the ordinary case, and `>`/`<` exist for exactly them. + expect($browser->list('admin-record', filters: [new DataFilter('amount', DataFilter::GT, '200')])->total)->toBe(3) + ->and($browser->list('admin-record', filters: [new DataFilter('active', DataFilter::EQ, '1')])->total)->toBe(3) + ->and($browser->list('admin-record', filters: [new DataFilter('email', DataFilter::CONTAINS, 'grace')])->total)->toBe(1) + ->and($browser->list('admin-record', filters: [new DataFilter('meta', DataFilter::NULL)])->total)->toBe(3); +}); + +it('treats a LIKE metacharacter in the value as a literal, and still finds it', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + DB::table('admin_records')->where('id', 1)->update(['email' => 'ada_lovelace@example.test']); + $browser = $this->browser(); + + // ESCAPING WITHOUT AN `ESCAPE` CLAUSE IS WORSE THAN NOT ESCAPING. Backslash-escaping `_` and then + // emitting a plain `LIKE ?` leaves the driver with no escape character declared, so `ada\_love` is + // matched literally and a search for `ada_love` returned ZERO rows against a table that contained + // `ada_lovelace@example.test`. Suppressing the wildcards worked; finding an underscore stopped working, + // silently — which is the worse of the two failures. + expect($browser->list('admin-record', filters: [new DataFilter('email', DataFilter::CONTAINS, 'ada_love')])->total)->toBe(1) + // And the wildcards are still suppressed: a `%` matches a literal percent sign, not everything. + ->and($browser->list('admin-record', filters: [new DataFilter('email', DataFilter::CONTAINS, '%')])->total)->toBe(0) + ->and($browser->list('admin-record', filters: [new DataFilter('email', DataFilter::CONTAINS, 'ada%love')])->total)->toBe(0) + ->and($browser->list('admin-record', filters: [new DataFilter('email', DataFilter::STARTS, 'ada_love')])->total)->toBe(1); +}); + +it('applies the same escaping to the search box', function () { + /** @var DataBrowserTestCase $this */ + $this->seedRecords(); + DB::table('admin_records')->where('id', 1)->update(['email' => 'ada_lovelace@example.test']); + $browser = $this->browser(); + + // The search path had no escaping at all, so a `%` matched every row. Leaving one path escaped and the + // other not would have been worse than either: the same term would mean different things in two boxes on + // the same page. + expect($browser->list('admin-record', search: '%')->total)->toBe(0) + ->and($browser->list('admin-record', search: 'ada_love')->total)->toBe(1) + ->and($browser->list('admin-record', search: "o'brien")->total)->toBe(1); +}); diff --git a/packages/kernel/src/Version.php b/packages/kernel/src/Version.php index 0f3cf47..f4fe33b 100644 --- a/packages/kernel/src/Version.php +++ b/packages/kernel/src/Version.php @@ -15,5 +15,5 @@ */ final class Version { - public const string VERSION = '26.07.18'; + public const string VERSION = '26.09.1'; } diff --git a/packages/testing/src/FireflyTestCase.php b/packages/testing/src/FireflyTestCase.php index 24748dc..0c70703 100644 --- a/packages/testing/src/FireflyTestCase.php +++ b/packages/testing/src/FireflyTestCase.php @@ -84,6 +84,17 @@ protected function resolveApplicationConfiguration($app): void $config->set('logging.default', 'errorlog'); $config->set('logging.channels.errorlog', ['driver' => 'errorlog', 'level' => 'debug']); + // A FIXED APPLICATION KEY, because every real application has one — `key:generate` runs in the + // skeleton's post-create-project-cmd — and a test app that does not is a test app that cannot + // exercise anything touching the encrypter. That is not hypothetical: putting the admin dashboard + // behind EncryptCookies (its routes had no CSRF protection at all) turned twenty-seven passing + // tests into MissingAppKeyException, because the harness was the only place a LaraFly application + // ever runs without a key. Fixed, not random, so a failure is reproducible from the output alone. + if (! $config->has('app.key') || $config->get('app.key') === null || $config->get('app.key') === '') { + // Exactly 32 bytes: aes-256-cbc, Laravel's default cipher, accepts nothing else. + $config->set('app.key', 'base64:'.base64_encode(str_pad('firefly-testing-key', 32, '.'))); + } + if (! $config->has('firefly')) { $config->set('firefly', []); } diff --git a/skeleton/.env.example b/skeleton/.env.example index 35b0d08..bb8288a 100644 --- a/skeleton/.env.example +++ b/skeleton/.env.example @@ -8,7 +8,10 @@ DB_CONNECTION=sqlite DB_DATABASE=database/database.sqlite CACHE_STORE=array -SESSION_DRIVER=array +# `file`, not `array`: an array session is discarded at the end of the request, so the CSRF token a form +# renders can never match the one the next request checks — every POST in the admin dashboard would answer +# 419. A file session needs no service, only the storage directory the framework already writes to. +SESSION_DRIVER=file QUEUE_CONNECTION=sync LOG_CHANNEL=stderr From 04ecd5c083462713b7fd1ba2897a2a81a5549bb7 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 20:34:41 -0700 Subject: [PATCH 30/31] fix: the rest of what the pre-release review confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-two findings survived adversarial verification. The three biggest were fixed in the previous commit; these are the remainder, and two of them are security. PROBLEM+JSON PUBLISHED AN UNHANDLED EXCEPTION'S MESSAGE IN PRODUCTION A QueryException stringifies the failing SQL *and its bindings*; a TypeError names an absolute path on the server; a PDOException names the host it could not reach. All three were copied verbatim into `detail` and serialised with no `app.debug` gate anywhere on that path — while the HTML page beside it, added this session, correctly withheld everything. The reviewer reproduced it live in production mode. Both renderings now share one switch, `firefly.web.error-page.trace`, which follows `app.debug`. Only the THIRD mapper case is gated: a FireflyException's message was written by the application FOR the client ("Order 42 does not exist.") and an `abort(404, '…')` message is equally author-supplied — gating those would turn every deliberate business error into "An unexpected error occurred." and defeat the taxonomy. With no settings object bound at all, the default is the SAFE one: an absent gate must not mean an open one. THE CONNECTION WIZARD COULD CREATE A FILE ANYWHERE THE WORKER COULD WRITE sqlite's "database" is a PATH and PDO creates it, so forwarding that form field to the driver made a class documented as "never writes anything" into a write primitive: `database=/var/www/html/x.php`, or a `file:` URI with `?mode=rwc`, drops an attacker-named file at an attacker-chosen location. sqlite is now always tested against `:memory:` — the only sqlite connection with no host and no credentials to get wrong, so the path taught nothing the memory database does not. DOCUMENTATION THAT HAD GONE STALE WITHIN THIS BRANCH Nine places still described the state before their own commits: data-browser.md's headline warning said the page "has not landed" (it landed six commits earlier); docs/index.md repeated it; openapi.md said firefly/openapi is not in the metapackage seven lines after saying it is, and its Known-latent still said a `@return` docblock is "deliberately not read"; README and getting-started said both packages "stay separate"; error-handling.md described ProblemMapper as two cases when it has three, and promised problem+json "always"; admin.md counted thirteen links against its own seventeen-row table; bean-graph.md linked a #the-thirteen-pages anchor that no longer exists; versioning.md still showed 26.07.18. DataBrowser's own class docblock was the worst of them — it still opened "WHY THERE IS NO create(), AND WHY THAT IS NOT AN OMISSION TO BE FILLED IN LATER" directly above a public create(), with update()'s docblock forward-referencing the argument. The prose docs and both book editions had been rewritten for that change and the source of truth for the class was missed. mkdocs.yml never gained the four new pages, so admin, bean-graph, data-browser and openapi were unreachable on the published site — the largest documents in the branch, invisible. The Spanish edition of Chapter 4A was missing one exercise the English gained, because the replace targeted wording that edition does not use. Both book editions rebuilt. 2077 tests pass, PHPStan max clean, deptrac 0, Pint clean, book listings 223/223. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- README.md | 12 ++-- book/README.md | 4 +- book/src-es/04a-openapi.md | 3 +- docs/README.md | 2 +- docs/getting-started.md | 6 +- docs/index.md | 6 +- docs/modules/admin.md | 7 ++- docs/modules/bean-graph.md | 2 +- docs/modules/data-browser.md | 11 ++-- docs/modules/error-handling.md | 32 ++++++++--- docs/modules/openapi.md | 10 ++-- docs/versioning.md | 4 +- mkdocs.yml | 4 ++ packages/admin/src/Data/ConnectionWizard.php | 17 ++++-- packages/admin/src/Data/DataBrowser.php | 32 ++++++----- .../admin/tests/Data/ConnectionWizardTest.php | 29 ++++++++-- packages/web/src/Error/ProblemMapper.php | 28 ++++++--- .../src/Exception/ProblemDetailsRenderer.php | 15 ++++- packages/web/src/WebServiceProvider.php | 6 ++ packages/web/tests/Error/ErrorPageTest.php | 57 +++++++++++++++++++ 20 files changed, 215 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 525cbb5..3de9c33 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ wallet-and-ledger service in [`samples/lumen/`](samples/lumen/), from an empty d event-driven, actuator-observed microservice, chapter by chapter — every listing drawn from that real project (it boots and its tests pass against this framework version, `26.09.1`). -The book is **complete and bilingual (English + Spanish)**: a quick start, **thirteen chapters** across four +The book is **complete and bilingual (English + Spanish)**: a quick start, **fourteen chapters** across four parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD), Coordinating & Securing the App (CQRS, EDA + transactional outbox, `#[Transactional]`, security), and Observability, Testing & Delivery (actuator, testing, the CLI + zero-reflection cache) — plus a Laravel→LaraFly cheat-sheet and a @@ -685,9 +685,9 @@ analogue) that pulls in the whole runtime family, developer console included, wi composer require firefly/firefly ``` -The broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`), the browser dashboard -(`firefly/admin`), the API-documentation package (`firefly/openapi`) and the test kit -(`firefly/testing`) stay separate — require them only if you use them. +The browser dashboard (`firefly/admin`) and the API-documentation package (`firefly/openapi`) come with it. The +broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`) and the test kit +(`firefly/testing`) stay separate — each binds you to an infrastructure choice or belongs in `require-dev`. ```bash composer require firefly/admin # /firefly — the dashboard over the actuator (see its access model first) @@ -788,7 +788,7 @@ Start at the **[documentation table of contents](docs/README.md)** — it groups - [Laravel ↔ Spring Boot Comparison](docs/laravel-comparison.md) — concept-by-concept mapping for both audiences. - [Versioning](docs/versioning.md) · [Contributing](docs/contributing.md) · [Publishing](docs/publishing.md). - Every [module guide](#modules) above. -- [*LaraFly by Example*](book/README.md) — the complete bilingual book (13 chapters + appendices, PDF + EPUB). +- [*LaraFly by Example*](book/README.md) — the complete bilingual book (14 chapters + appendices, PDF + EPUB). - [`samples/lumen/`](samples/lumen/) — the wallet-and-ledger sample this README's showcases are drawn from; run its own test suite with `vendor/bin/pest samples/lumen/tests`. @@ -821,7 +821,7 @@ still ahead, accurately: today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event sourcing/snapshots/projections is future work, as it is in PyFly. - **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *LaraFly by Example* - [book](book/README.md) (13 chapters + appendices, EN + ES, PDF + EPUB), and a + [book](book/README.md) (14 chapters + appendices, EN + ES, PDF + EPUB), and a [docs table of contents](docs/README.md) all shipped with the documentation-parity milestone. Deeper guides (more recipes, more diagrams) continue to grow from here. diff --git a/book/README.md b/book/README.md index 75e2cd6..121eff8 100644 --- a/book/README.md +++ b/book/README.md @@ -93,7 +93,7 @@ book/ src/ # EN manuscript (Markdown) 00-front/ # title/copyright/dedication/preface/conventions 00-quickstart.md # "Build Lumen step by step" quick start - 01..13-*.md # the thirteen chapters (Parts I-IV) + 01..13-*.md # the fourteen chapters, 4A included (Parts I-IV) 90-appendix-a-laravel.md # Laravel -> LaraFly cheat-sheet 94-glossary.md # glossary src-es/ # ES manuscript, same structure/filenames @@ -119,7 +119,7 @@ book/ ## Manuscript status The manuscript is **complete** in both languages: a five-file front matter, a -"Build Lumen step by step" quick start, thirteen chapters across four parts — +"Build Lumen step by step" quick start, fourteen chapters across four parts — - **Part I — Foundations**: Why LaraFly, Dependency Injection & Auto-Configuration, Configuration/Profiles/Secrets, Your First HTTP API diff --git a/book/src-es/04a-openapi.md b/book/src-es/04a-openapi.md index 68029c5..41955d6 100644 --- a/book/src-es/04a-openapi.md +++ b/book/src-es/04a-openapi.md @@ -792,4 +792,5 @@ final class ApiDocsConfiguration 1. **Genera el documento de Lumen y léelo.** Ejecuta `php artisan firefly:openapi --output=openapi.json` en el sample y abre `/openapi` en un navegador. Busca `walletBalance` y confirma que no tiene respuesta `400`; luego busca `walletDeposit` y confirma que tiene tanto un `400` como un `422` — y convéncete, con las reglas de este capítulo, de por qué difieren. 2. **Convierte la especificación en una puerta de CI.** Versiona el fichero generado y añade un job que lo regenere y ejecute `git diff --exit-code` sobre él. Cambia un DTO — añade un `#[Size(max: 32)]` a `OpenWalletRequest::$owner_id` — y observa al job fallar con un diff que nombra la palabra clave de esquema exacta que cambió. 3. **Demuestra que la consola por defecto no hace ninguna petición saliente.** Abre `/openapi` en el sample con el panel de red del navegador grabando, y confirma que todas las peticiones son del mismo origen: la página, `openapi/assets/swagger-ui.css`, los dos bundles y `openapi.json`. Luego pon `firefly.openapi.viewer.style` a `cdn`, recarga, y observa aparecer `cdn.jsdelivr.net` en ese mismo panel — esa petición es toda la diferencia, y es lo que una CSP estricta o un host aislado bloquearía. -4. **Observa a una restricción caer hasta la extensión.** Añade `#[Future]` a una propiedad `string` de un DTO de petición, regenera, y encuentra el array `x-firefly-constraints` de la propiedad llevando `after:now` junto a un `format: date-time` perfectamente corriente. Luego añade `#[Pattern('/^[a-z]+$/i')]` a otra propiedad y compara: el patrón *sí* se publica, y la regla original se registra a su lado porque la bandera `i` no pudo sobrevivir a la traducción. +4. **Borra una anotación y mira cómo el documento pierde un miembro.** En el esqueleto, quita el `@return array{...}` de `App\Orders\Order::jsonSerialize()`, regenera, y encuentra `total` ausente del esquema `Order` mientras `GET /orders/1` lo sigue devolviendo. Vuelve a ponerlo, luego cambia `total: float` por `total: string` y ejecuta PHPStan: la puerta que mantiene honesto al documento es la que falla. +5. **Observa a una restricción caer hasta la extensión.** Añade `#[Future]` a una propiedad `string` de un DTO de petición, regenera, y encuentra el array `x-firefly-constraints` de la propiedad llevando `after:now` junto a un `format: date-time` perfectamente corriente. Luego añade `#[Pattern('/^[a-z]+$/i')]` a otra propiedad y compara: el patrón *sí* se publica, y la regla original se registra a su lado porque la bandera `i` no pudo sobrevivir a la traducción. diff --git a/docs/README.md b/docs/README.md index f8a1aab..8ad037e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -147,7 +147,7 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa --- -*The guided, book-style [*LaraFly by Example*](../book/README.md) book — 13 chapters plus appendices, +*The guided, book-style [*LaraFly by Example*](../book/README.md) book — 14 chapters plus appendices, bilingual (English + Spanish), rendered to PDF + EPUB — is available now, alongside the step-by-step [Tutorial](tutorial.md).* diff --git a/docs/getting-started.md b/docs/getting-started.md index d1b71d6..a7cf947 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -33,9 +33,9 @@ analogue) that requires every runtime package, `firefly/cli` included, so `firef composer require firefly/firefly ``` -The broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`), the browser dashboard -(`firefly/admin`) and the test kit -(`firefly/testing`) stay separate — require them only if you use them. +The browser dashboard (`firefly/admin`) and the API-documentation package (`firefly/openapi`) come with it. The +broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`) and the test kit +(`firefly/testing`) stay separate — each binds you to an infrastructure choice or belongs in `require-dev`. Then point LaraFly at your app's classes and compile it: diff --git a/docs/index.md b/docs/index.md index 0b54600..418bf12 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,8 +29,8 @@ request after that runs against plain PHP arrays — no runtime reflection on th - **Production-ready out of the box** — an Actuator surface (health/info/beans) and a Prometheus/Micrometer-style metrics core, both secured by the same config as everything else, plus a server-rendered [admin dashboard](modules/admin.md) over them with a drawn [bean graph](modules/bean-graph.md), and an - opt-in, off-by-default [data browser](modules/data-browser.md) over your own repositories — shipping today as - a library, with its dashboard page still to land. + opt-in, off-by-default [data browser](modules/data-browser.md) over your own repositories, with filtering, + full CRUD, relations you can walk and a drawn [entity map](modules/admin.md#the-entity-map). - **An API document that cannot drift** — [`firefly/openapi`](modules/openapi.md) generates OpenAPI 3.1 from the same compiled manifests the dispatcher and the validator read, and serves the official Swagger UI from your own origin — no annotation dialect, no npm, no CDN. @@ -82,7 +82,7 @@ Want to see it all running together? The [Lumen sample](https://github.com/fireflyframework/fireflyframework-php/tree/main/samples/lumen) is a runnable digital-wallet & ledger vertical slice exercising `#[Transactional]`, CQRS, domain events over EDA, method security, and a REST layer with RFC-7807 problem-details. The guided, book-style *LaraFly by Example* -book — 13 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available +book — 14 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available in [`book/`](https://github.com/fireflyframework/fireflyframework-php/tree/main/book). ## Quick Links diff --git a/docs/modules/admin.md b/docs/modules/admin.md index fe44d08..c4cb463 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -27,9 +27,10 @@ install line above. ## The pages -Every page is a view over one `ActuatorEndpoint`'s payload. The menu groups them the way an operator thinks rather -than the way the packages are laid out — *what is it doing right now*, *what did it wire at boot*, *how is it -configured* — because a flat list of thirteen links is a worse menu than three short ones. +Most pages are a view over one `ActuatorEndpoint`'s payload; four read the container instead. The menu groups +them the way an operator thinks rather than the way the packages are laid out — *what is it doing right now*, +*what did it wire at boot*, *what is its data*, *how is it configured* — because a flat list of seventeen links +is a worse menu than four short ones. | Group | Page | Path | Endpoint | Answers | |---|---|---|---|---| diff --git a/docs/modules/bean-graph.md b/docs/modules/bean-graph.md index 47a3d59..4f31f29 100644 --- a/docs/modules/bean-graph.md +++ b/docs/modules/bean-graph.md @@ -172,7 +172,7 @@ check. ## Reading it against the Conditions page -The graph and [Conditions](admin.md#the-thirteen-pages) answer complementary questions, and the pair is the fastest +The graph and [Conditions](admin.md#the-pages) answer complementary questions, and the pair is the fastest way to diagnose an auto-configuration surprise: 1. **Conditions** says *whether* a framework bean was registered or backed off, and on which condition. diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md index f44625c..80a1d78 100644 --- a/docs/modules/data-browser.md +++ b/docs/modules/data-browser.md @@ -6,13 +6,10 @@ The data browser is a Django-admin-style view over your application's own data, It is **off by default, and it does not inherit the dashboard's default.** Read [The two gates](#the-two-gates) before you switch it on — that section is the point of this page. -!!! warning "Today this is a library, not a URL" - Discovery, schema derivation, reads and both writes are complete, tested and usable from your own code via - `DataBrowser::forContainer()`. The Blade page and the route that would put it in the dashboard's menu have - **not landed** — `firefly/admin` registers no data-browser page, and `admin.md`'s page list is still - thirteen. Setting `firefly.admin.data.enabled` therefore opens an API, not a screen. See - [Known-latent](#known-latent). - +!!! tip "Two switches, and neither follows `app.debug`" + `firefly.admin.data.enabled` turns the browser on and `firefly.admin.data.writable` permits writes on top + of it. Both default to **false** and neither is implied by `app.debug` or by `firefly.admin.enabled` — + see [The two gates](#the-two-gates), which is the point of this page. ```bash composer require firefly/admin # already required by firefly/firefly; the browser is a part of the dashboard, not a package of its own ``` diff --git a/docs/modules/error-handling.md b/docs/modules/error-handling.md index e56a17d..6aa58bf 100644 --- a/docs/modules/error-handling.md +++ b/docs/modules/error-handling.md @@ -80,11 +80,27 @@ $payload = $response->toArray(); // omits null/empty optionals `FireflyException` thrown while handling a request is turned into an `application/problem+json` response by `Firefly\Web\Exception\ProblemDetailsRenderer`, via `ErrorResponse::fromException(...)`, at the exception's own `httpStatus()` — the shape is exactly the payload above, produced by the same -kernel-level `ErrorResponse` this page documents. A generic (non-`FireflyException`) `Throwable` is -first wrapped as a category-`Internal`, HTTP-500 `FireflyException` before being rendered the same way. -That wrapping rule lives in one place — `Firefly\Web\Error\ProblemMapper` — because the HTML page below -needs the same answer, and two copies of it would eventually tell a browser and a client different things -about one failure. +kernel-level `ErrorResponse` this page documents. + +`Firefly\Web\Error\ProblemMapper` owns the rule for turning *any* throwable into that shape, in one place, +because the HTML page below needs the same answer and two copies of it would eventually tell a browser and a +client different things about one failure. It has **three** cases, and only the third is a disclosure: + +| Throwable | Status | Whose message is it? | +|---|---|---| +| A `FireflyException` | its own `httpStatus()` | the application's, written **for** the client | +| An `HttpExceptionInterface` (the router's own 404, `abort(409, '…')`) | its real status | the author's, via `abort()` | +| Anything else | 500 `INTERNAL_ERROR` | **an accident**, and withheld — see below | + +!!! danger "A generic throwable's message is not for the client" + A `QueryException` stringifies the failing SQL *and its bindings*; a `TypeError` names an absolute path on + the server; a `PDOException` names the host it could not reach. All three were copied verbatim into + `detail` and published as problem+json — in production, with no `app.debug` gate anywhere on that path, + while the HTML page beside it withheld everything. Both renderings are now gated by the same switch, + `firefly.web.error-page.trace`, which follows `app.debug`: with it off an unhandled throwable answers + `An unexpected error occurred.` and its real message stays on the exception, where the log has it. When + no settings object is bound at all — a JSON-only deployment that never constructed one — the default is + the **safe** one; an absent gate must not mean an open one. Before that generic rendering happens, LaraFly gives the application a chance to handle the exception itself: @@ -98,9 +114,9 @@ itself: handler for a more-derived exception class outranks one for an ancestor class. - A matched handler's return value is content-negotiated like any other controller return, but rendered at the **exception's** `httpStatus()` rather than the route's default status. -- If no handler matches at any scope, the exception propagates to the RFC-7807 renderer described above — - so an unhandled 404/422/500 always still comes back as `application/problem+json`, never an uncaught - framework error page. +- If no handler matches at any scope, the exception propagates to the renderers described above — so an + unhandled 404/422/500 comes back as `application/problem+json`, or as the LaraFly error page when the + caller asked for HTML (see the next section), and never as an uncaught framework error page. ## Who gets JSON, and who gets a page diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md index 3504684..0623b85 100644 --- a/docs/modules/openapi.md +++ b/docs/modules/openapi.md @@ -16,8 +16,8 @@ directly if you took the packages à la carte: composer require firefly/openapi ``` -It is *not* part of the `firefly/firefly` metapackage — like `firefly/admin` and the broker adapters, it is an -opt-in dependency. +`firefly/firefly` requires it, so a project built from the skeleton already has it; the line above is for an +application that took the packages à la carte. ## What you get @@ -762,9 +762,9 @@ compiled route manifest of *every* application for the benefit of one optional p ## Known-latent -- **A success body typed `array` documents as `type: object`.** The success schema comes from the declared return - type, and LaraFly controllers commonly return `array`. Return a DTO (or a backed scalar) where the response - shape matters to a generated client; a `@return array{…}` docblock is deliberately not read. +- **A success body typed `array` with no `@return` documents as `type: object`.** That is the fallback, not the + rule — see [What an endpoint returns](#what-an-endpoint-returns). Write the shape in a `@return array{…}` (or + return a DTO) and the generator publishes it. - **`x-firefly-constraints` is the escape hatch, not a vocabulary.** Anything JSON Schema cannot state lands there verbatim; no attempt is made to translate a checksum rule or a temporal predicate into an approximation that would be wrong. diff --git a/docs/versioning.md b/docs/versioning.md index 4b02105..5218685 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -20,7 +20,7 @@ The single place the current version *is* asserted in code is: // packages/kernel/src/Version.php final class Version { - public const string VERSION = '26.07.18'; + public const string VERSION = '26.09.1'; } ``` @@ -38,7 +38,7 @@ actually cut. ```php use Firefly\Kernel\Version; -echo Version::VERSION; // "26.07.18" +echo Version::VERSION; // "26.09.1" ``` This is the only version string LaraFly itself exposes; there is no runtime version-detection mechanism diff --git a/mkdocs.yml b/mkdocs.yml index e9e3bfc..51b656d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Web & API: - Web Layer: modules/web.md - Web Filters: modules/web-filters.md + - OpenAPI: modules/openapi.md - Resilience & Scheduling: - Resilience: modules/resilience.md - Scheduling: modules/scheduling.md @@ -59,6 +60,9 @@ nav: - Operations: - Actuator: modules/actuator.md - Observability: modules/observability.md + - Admin Dashboard: modules/admin.md + - Bean Graph: modules/bean-graph.md + - Data Browser: modules/data-browser.md - Testing: - Testing: modules/testing.md - Integration Testing: modules/integration-testing.md diff --git a/packages/admin/src/Data/ConnectionWizard.php b/packages/admin/src/Data/ConnectionWizard.php index 1c2ee88..fbf8624 100644 --- a/packages/admin/src/Data/ConnectionWizard.php +++ b/packages/admin/src/Data/ConnectionWizard.php @@ -27,9 +27,12 @@ * feature-switch console, for the same reason: a convenience that is only ever wanted on a developer's * machine should be impossible to reach anywhere else. * - * IT NEVER WRITES ANYTHING. The result is a config snippet to paste, not a file edit. Persisting a - * connection would mean writing credentials from a browser form into a file on disk, and the wizard's value - * — telling you whether the settings work — does not need that. + * IT NEVER WRITES ANYTHING, and that took two attempts to be true. The result is a config snippet to paste, + * not a file edit — persisting a connection would mean writing credentials from a browser form into a file + * on disk, and the wizard's value (telling you whether the settings work) does not need that. But the sqlite + * branch forwarded the caller's `database` straight to PDO, which CREATES the file it names: a field on this + * form could drop an attacker-named file anywhere the worker could write. sqlite is now always tested + * against `:memory:`, which is the only sqlite connection that has nothing to get wrong. */ final class ConnectionWizard { @@ -160,7 +163,13 @@ private function normalise(array $input): array $get = static fn (string $key, string $fallback = ''): string => trim($input[$key] ?? '') !== '' ? trim($input[$key]) : $fallback; if ($driver === 'sqlite') { - return ['driver' => 'sqlite', 'database' => $get('database', ':memory:'), 'prefix' => '', 'foreign_key_constraints' => true]; + // ONLY `:memory:`. Every other sqlite "database" is a PATH, and PDO CREATES it — so a form field + // that reached the driver was a write primitive: `database=/var/www/html/x.php` (or a `file:` + // URI with `?mode=rwc`) puts an attacker-named, attacker-located file on disk, which is a long + // way from "test a connection" and flatly contradicts this class's promise to write nothing. + // Testing a sqlite connection has no host and no credentials to get wrong, so there is nothing + // the path would teach that :memory: does not. + return ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => true]; } return [ diff --git a/packages/admin/src/Data/DataBrowser.php b/packages/admin/src/Data/DataBrowser.php index 87c7cda..5942ffc 100644 --- a/packages/admin/src/Data/DataBrowser.php +++ b/packages/admin/src/Data/DataBrowser.php @@ -13,20 +13,23 @@ use Throwable; /** - * The database browser's single entry point: discovery, schema, reads and the two writes, behind the gates. + * The database browser's single entry point: discovery, schema, reads and the three writes, behind the gates. * - * WHY THERE IS NO `create()`, AND WHY THAT IS NOT AN OMISSION TO BE FILLED IN LATER. A generic create form - * over an arbitrary entity is a promise the browser cannot keep. An aggregate's constructor is where its - * invariants live — an Order that must have at least one line, a Wallet whose balance starts at zero in the - * currency it was opened in, a value object that rejects a malformed IBAN — and a form built from a column - * list knows none of them. There are only two ways to build the row: call the constructor, which needs - * arguments the form cannot supply in the right types or the right order and will fail on the first entity - * with a non-trivial signature; or write the columns straight to the table, which produces a row the domain - * model considers impossible and which every later read then has to cope with. The second is what a "just - * insert the columns" implementation actually does, and it is worse than having no button, because it looks - * like it worked. Creation belongs to the application's own code, where the constructor is. `update()` is - * offered because it operates on a row that ALREADY satisfies its invariants and changes named columns on it; - * `delete()` because removal needs no invariant at all. + * WHICH ENTITIES MAY BE CREATED, AND WHY THE ANSWER IS NOT "ALL OF THEM". A generic create form over an + * ARBITRARY entity is a promise the browser cannot keep. An aggregate's constructor is where its invariants + * live — an Order that must have at least one line, a Wallet whose balance starts at zero in the currency it + * was opened in, a value object that rejects a malformed IBAN — and a form built from a column list knows + * none of them. There are only two ways to build such a row: call the constructor, which needs arguments the + * form cannot supply in the right types or the right order and will fail on the first entity with a + * non-trivial signature; or write the columns straight to the table, which produces a row the domain model + * considers impossible and which every later read then has to cope with. The second is what a "just insert + * the columns" implementation actually does, and it is worse than having no button, because it looks like it + * worked. That case is refused by name. + * + * It was never the case for an ELOQUENT model, which is constructed empty and filled by attribute — exactly + * what `update()` has always done to a row that exists. `create()` was therefore refusing on a risk + * `update()` was already taking, and the inconsistency cost every application a CRUD surface that stopped at + * RUD. `delete()` needs no invariant at all. * * EVERY OPERATION IS GATED TWICE — once by `firefly.admin.data.enabled` and, for writes, again by * `firefly.admin.data.writable`, both default false. See DataBrowserSettings for the argument about why this @@ -324,7 +327,8 @@ public function delete(string $slug, int|string $id): DataWriteResult * * WHY ONLY ELOQUENT-BACKED RESOURCES. Mutating a plain entity means either calling setters the browser * cannot know about or reflecting values into promoted `readonly` properties, which is exactly the - * invariant-bypassing that `create()` is refused for (see the class docblock) — with the additional + * invariant-bypassing `create()` still refuses for a NON-Eloquent entity (see the class docblock) — with + * the additional * problem that on a readonly property it is not even possible. A resource whose entities are value * objects is browsable and deletable, and its edit is refused with a reason. * diff --git a/packages/admin/tests/Data/ConnectionWizardTest.php b/packages/admin/tests/Data/ConnectionWizardTest.php index 1aaa6bc..b9610f4 100644 --- a/packages/admin/tests/Data/ConnectionWizardTest.php +++ b/packages/admin/tests/Data/ConnectionWizardTest.php @@ -64,13 +64,15 @@ // and no reconnector available" for a wrong password, a closed port and a typo in the host alike. The // wizard forces the PDO open first and unwraps to the innermost exception, so the message that comes // back is the one that tells you where to look. - $result = $wizard()->test(['driver' => 'sqlite', 'database' => '/no/such/directory/at/all.sqlite']); + // Port 1 is refused immediately by the loopback stack, so this is deterministic and fast — and it is a + // network driver, which is the case sqlite (always :memory: now) cannot exercise. + $result = $wizard()->test(['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '1', 'database' => 'x', 'username' => 'u', 'password' => 'p']); expect($result['ok'])->toBeFalse() ->and($result['message'])->not->toContain('no reconnector') - // Specific enough to act on: it names the path it tried, which is the whole difference from the - // wrapper's one-size-fits-all sentence. - ->and($result['message'])->toContain('/no/such/directory/at/all.sqlite'); + // Specific enough to act on: the driver names what it tried and why it failed, which is the whole + // difference from the wrapper's one-size-fits-all sentence. + ->and(strtolower($result['message']))->toContain('refused'); }); it('refuses a driver it does not know rather than handing it to a connector', function () use ($wizard) { @@ -86,3 +88,22 @@ ->not->toContain('persist') ->not->toContain('write'); }); + +it('cannot be used to create a file anywhere on disk', function () use ($wizard) { + // sqlite's "database" is a PATH and PDO CREATES it, so forwarding the form field to the driver made this + // a write primitive — `database=/tmp/planted.php`, or a `file:` URI with `?mode=rwc`, puts an + // attacker-named file wherever the worker can write. That is a long way from "test a connection", and it + // contradicted this class's own promise to write nothing. + $planted = sys_get_temp_dir().'/firefly-wizard-planted-'.bin2hex(random_bytes(6)).'.php'; + $uri = sys_get_temp_dir().'/firefly-wizard-uri-'.bin2hex(random_bytes(6)).'.php'; + + $wizard()->test(['driver' => 'sqlite', 'database' => $planted]); + $wizard()->test(['driver' => 'sqlite', 'database' => 'file:'.$uri.'?mode=rwc']); + + expect(is_file($planted))->toBeFalse() + ->and(is_file($uri))->toBeFalse(); + + // And it still does the job: sqlite is tested against :memory:, which has no host, no credentials and + // nothing a path would have taught. + expect($wizard()->test(['driver' => 'sqlite', 'database' => $planted])['ok'])->toBeTrue(); +}); diff --git a/packages/web/src/Error/ProblemMapper.php b/packages/web/src/Error/ProblemMapper.php index 6b11f15..603b261 100644 --- a/packages/web/src/Error/ProblemMapper.php +++ b/packages/web/src/Error/ProblemMapper.php @@ -19,15 +19,29 @@ * disagree about the status of an HttpExceptionInterface or the code of an unhandled RuntimeException, and * the symptom would be a support ticket quoting an error code that appears nowhere in the logs. * - * THREE CASES. A FireflyException already carries its status, code, category and severity and is returned - * untouched. A Symfony/Illuminate HttpExceptionInterface — the router's own NotFoundHttpException for a URL - * with no matching route at all, which is a different thing from a matched handler throwing - * ResourceNotFoundException — keeps its REAL status; without this branch every unrouted URL rendered as a - * 500. Anything else is a genuine 500. + * THREE CASES, AND ONLY THE THIRD IS A DISCLOSURE. A FireflyException already carries its status, code, + * category and severity and is returned untouched — its message was written by the application FOR the + * client ("Order 42 does not exist."), which is the whole point of the taxonomy. A Symfony/Illuminate + * HttpExceptionInterface — the router's own NotFoundHttpException for a URL with no matching route at all, + * which is a different thing from a matched handler throwing ResourceNotFoundException — keeps its REAL + * status, and its message is whatever `abort(404, '…')` supplied, so it is equally intended. + * + * ANYTHING ELSE IS AN ACCIDENT, AND ITS MESSAGE IS NOT FOR THE CLIENT. A QueryException stringifies the + * failing SQL *and its bindings*; a TypeError names an absolute path on the server; a PDOException names the + * host it could not reach. All three were being copied verbatim into `detail` and published as + * problem+json — in production, with no `app.debug` gate anywhere on that path, while the HTML page next to + * it withheld everything. `$disclose` closes that: with it false an unhandled throwable answers with a fixed + * sentence and its real message stays in the exception, where the log has it. */ final class ProblemMapper { - public static function toFireflyException(Throwable $e): FireflyException + /** What an unhandled throwable says when its own message may not be published. */ + public const string OPAQUE = 'An unexpected error occurred.'; + + /** + * @param bool $disclose whether an UNHANDLED throwable's own message may reach the client + */ + public static function toFireflyException(Throwable $e, bool $disclose = true): FireflyException { return match (true) { $e instanceof FireflyException => $e, @@ -40,7 +54,7 @@ public static function toFireflyException(Throwable $e): FireflyException $e, ), default => new FireflyException( - $e->getMessage() !== '' ? $e->getMessage() : 'Internal Server Error', + $disclose && $e->getMessage() !== '' ? $e->getMessage() : self::OPAQUE, 'INTERNAL_ERROR', 500, ErrorCategory::Internal, diff --git a/packages/web/src/Exception/ProblemDetailsRenderer.php b/packages/web/src/Exception/ProblemDetailsRenderer.php index 3c90311..f295206 100644 --- a/packages/web/src/Exception/ProblemDetailsRenderer.php +++ b/packages/web/src/Exception/ProblemDetailsRenderer.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use DateTimeInterface; use Firefly\Kernel\Error\ErrorResponse; +use Firefly\Web\Error\ErrorPageSettings; use Firefly\Web\Error\ProblemMapper; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -22,9 +23,21 @@ */ final class ProblemDetailsRenderer { + /** + * ONE DISCLOSURE SWITCH FOR BOTH RENDERINGS. The HTML page has always been gated by + * `firefly.web.error-page.trace` (which follows `app.debug`); this path had no gate at all, so the same + * failure withheld everything from a browser and published a QueryException's SQL and bindings to a + * client. The settings object is optional so a JSON-only deployment that never bound one still renders — + * and when it is absent the default is the SAFE one. + */ + public function __construct(private readonly ?ErrorPageSettings $settings = null) {} + public function render(Throwable $e, Request $request): Response { - $exception = ProblemMapper::toFireflyException($e); + // An absent settings object means the SAFE answer, not the open one — see the constructor. + $disclose = $this->settings instanceof ErrorPageSettings && $this->settings->trace; + + $exception = ProblemMapper::toFireflyException($e, $disclose); $payload = ErrorResponse::fromException( $exception, diff --git a/packages/web/src/WebServiceProvider.php b/packages/web/src/WebServiceProvider.php index 74ded0a..8ddeb6c 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -61,6 +61,12 @@ public function passes(): array private function registerBindings(): void { + if (! $this->app->bound(ProblemDetailsRenderer::class)) { + $this->app->singleton(ProblemDetailsRenderer::class, static fn (Container $app): ProblemDetailsRenderer => new ProblemDetailsRenderer( + $app->make(ErrorPageSettings::class), + )); + } + if (! $this->app->bound(ErrorPageSettings::class)) { $this->app->singleton(ErrorPageSettings::class, static fn (Container $app): ErrorPageSettings => ErrorPageSettings::fromConfig($app->make(Config::class))); } diff --git a/packages/web/tests/Error/ErrorPageTest.php b/packages/web/tests/Error/ErrorPageTest.php index 83a5f86..d81fc9b 100644 --- a/packages/web/tests/Error/ErrorPageTest.php +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -7,6 +7,8 @@ use Firefly\Web\Error\ErrorPageRenderer; use Firefly\Web\Error\ErrorPageSettings; use Firefly\Web\Error\ErrorReport; +use Firefly\Web\Error\ProblemMapper; +use Firefly\Web\Exception\ProblemDetailsRenderer; use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -188,3 +190,58 @@ expect($off->forcesJson(Request::create('/api/nope', 'GET')))->toBeFalse(); }); + +it('withholds an unhandled exception message from problem+json in production', function () { + // The HTML page has always been gated by `trace`; this path had none, so the SAME failure withheld + // everything from a browser and published a QueryException's SQL and bindings to a client. A generic + // Throwable's message is an accident — a table name, a bound value, an absolute path on the server — + // and is never written for the caller. + $leak = new RuntimeException("SQLSTATE[42S02]: no such table (SQL: select * from users where email = 'ada@example.test')"); + + $withheld = ProblemMapper::toFireflyException($leak, disclose: false); + $shown = ProblemMapper::toFireflyException($leak, disclose: true); + + expect($withheld->getMessage())->toBe(ProblemMapper::OPAQUE) + ->not->toContain('SQLSTATE') + ->not->toContain('ada@example.test') + // The real message is still on the exception, where a log can have it: it is withheld from the + // response, not thrown away. + ->and($withheld->getPrevious()?->getMessage())->toBe($leak->getMessage()) + ->and($shown->getMessage())->toContain('SQLSTATE'); +}); + +it('keeps publishing a FireflyException\'s own message, which was written for the caller', function () { + // The taxonomy exists so an application can say "Order 42 does not exist." to a client. Gating that + // would turn every deliberate business error into "An unexpected error occurred." — the opposite of the + // point. + $business = new ResourceNotFoundException('Order 42 does not exist.', 'ORDER_NOT_FOUND'); + + expect(ProblemMapper::toFireflyException($business, disclose: false)->getMessage()) + ->toBe('Order 42 does not exist.'); + + // An abort(404, '…') message is equally author-supplied, so it survives too. + expect(ProblemMapper::toFireflyException(new NotFoundHttpException('No such tenant.'), disclose: false)->getMessage()) + ->toBe('No such tenant.'); +}); + +it('renders problem+json with the message withheld when the settings say so', function () { + $renderer = new ProblemDetailsRenderer(new ErrorPageSettings(trace: false)); + $body = (string) $renderer->render(new RuntimeException('internal detail: /srv/app/.env'), Request::create('/api/x'))->getContent(); + + expect($body)->not->toContain('/srv/app/.env') + ->toContain(ProblemMapper::OPAQUE) + ->toContain('INTERNAL_ERROR'); + + // And with the gate open — a developer's machine — the real message comes through. + $debug = new ProblemDetailsRenderer(new ErrorPageSettings(trace: true)); + expect((string) $debug->render(new RuntimeException('internal detail: /srv/app/.env'), Request::create('/api/x'))->getContent()) + ->toContain('/srv/app/.env'); +}); + +it('defaults to withholding when no settings object was bound at all', function () { + // A JSON-only deployment may never construct ErrorPageSettings. The default has to be the safe one: + // an absent gate must not mean an open one. + expect((string) (new ProblemDetailsRenderer)->render(new RuntimeException('leak me'), Request::create('/api/x'))->getContent()) + ->not->toContain('leak me') + ->toContain(ProblemMapper::OPAQUE); +}); From a73d65689b7630792b54672dc7183110af1b9630 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 3 Sep 2026 20:42:47 -0700 Subject: [PATCH 31/31] fix(ci): the branch used PHP 8.4 syntax in a framework that supports 8.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI went red on all three PHP jobs, for two different reasons, and both came from the same blind spot: the root composer.lock is deliberately not committed, so CI resolves dev tools fresh while a developer keeps whatever they installed. Locally everything was green on PHP 8.5 with Pint 1.29.3 and larastan 2.x. `new Foo()->bar()` IS A PARSE ERROR ON 8.3 PHP 8.4 allows dropping the parentheses around a constructor before an arrow. Ten call sites across two openapi test files used it. Every package declares `php: ^8.3`, so those files were unloadable for a third of the supported range while analysing, testing and linting perfectly on 8.5 — and the failure a contributor got was `Parse error: syntax error` with no hint why the same file was fine on their machine. Neither existing gate could have caught it. PHPStan's `phpVersion` governs SEMANTIC analysis — which functions exist — and never the parser, which always reads the newest grammar; I set it to 80300 anyway, since that half is worth having, and then verified it does NOT catch this. `php -l` only catches it if the runner happens to be on the oldest version, which is exactly the coincidence that let it through. tests/MinimumPhpSyntaxTest.php scans for it instead, and runs on every machine regardless of what PHP is installed. It uses token_get_all() rather than a regex — the first version matched the example inside its own docblock and reported itself as the first offender — and brace-matches the argument list, because `new Money(max(1, $n))->amount` would otherwise be measured to the wrong parenthesis. A NEWER LARASTAN PROVED AN `is_array()` REDUNDANT `--destination` is declared as an array option, so Laravel always returns an array and the guard could never be false. Removed rather than suppressed; the element-by-element validation next to it was the check that was ever doing work. Verified by upgrading the local toolchain to what CI resolves (Pint 1.30.5, PHPStan 2.2.13) and re-running: 2078 tests pass, PHPStan max clean, deptrac 0, Pint clean. Claude-Session: https://claude.ai/code/session_01MCTyVciS2A5pfPv5xAthPd --- composer.json | 2 +- .../eda/src/Console/ConsumeEventsCommand.php | 7 +- .../tests/Schema/ProblemSchemaTest.php | 10 +- packages/openapi/tests/Web/ViewerPageTest.php | 14 +- phpstan.neon.dist | 6 + tests/MinimumPhpSyntaxTest.php | 162 ++++++++++++++++++ 6 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 tests/MinimumPhpSyntaxTest.php diff --git a/composer.json b/composer.json index 6b7b1c3..88b5159 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "kwn/php-rdkafka-stubs": "^2.2", "larastan/larastan": "^3.9", "laravel/octane": "^2.0", - "laravel/pint": "^1.20", + "laravel/pint": "^1.30", "orchestra/testbench": "^11.1", "pestphp/pest": "^3.7", "phpstan/phpstan": "^2.1", diff --git a/packages/eda/src/Console/ConsumeEventsCommand.php b/packages/eda/src/Console/ConsumeEventsCommand.php index 1e0ee25..ff304e7 100644 --- a/packages/eda/src/Console/ConsumeEventsCommand.php +++ b/packages/eda/src/Console/ConsumeEventsCommand.php @@ -91,8 +91,13 @@ public function handle(): int */ private function requestedDestinations(): array { + // `--destination` is declared as an array option, so Laravel always hands back an array — and a + // newer larastan proves it, which is why the `is_array()` that used to guard this line is gone + // rather than merely unnecessary. The element-by-element validation in strings() is the check that + // was ever doing work: an int or a nested array here is a subscription gap, and a subscription gap + // in this command is invisible at runtime. $option = $this->option('destination'); - if (is_array($option) && $option !== []) { + if ($option !== []) { return $this->strings($option, '--destination'); } diff --git a/packages/openapi/tests/Schema/ProblemSchemaTest.php b/packages/openapi/tests/Schema/ProblemSchemaTest.php index 997928e..8c701ac 100644 --- a/packages/openapi/tests/Schema/ProblemSchemaTest.php +++ b/packages/openapi/tests/Schema/ProblemSchemaTest.php @@ -14,13 +14,13 @@ * in the abstract — the two differ, and the extension members are the half a client branches on. */ it('declares exactly the members ErrorResponse always emits as required', function () { - $payload = new ErrorResponse( + $payload = (new ErrorResponse( status: 422, title: 'Unprocessable Entity', code: 'VALIDATION_FAILED', category: ErrorCategory::Validation, severity: ErrorSeverity::Warning, - )->toArray(); + ))->toArray(); /** @var list $required */ $required = ProblemSchema::schema()['required']; @@ -29,7 +29,7 @@ }); it('describes every optional member ErrorResponse can add', function () { - $payload = new ErrorResponse( + $payload = (new ErrorResponse( status: 422, title: 'Unprocessable Entity', code: 'VALIDATION_FAILED', @@ -41,7 +41,7 @@ traceId: 'abc123', errors: [new FieldError('reference', 'must not be blank', 'NotBlank', '')], timestamp: '2026-09-03T00:00:00+00:00', - )->toArray(); + ))->toArray(); /** @var array $properties */ $properties = ProblemSchema::schema()['properties']; @@ -52,7 +52,7 @@ }); it('mirrors FieldError::toArray() in the errors item schema', function () { - $field = new FieldError('reference', 'must not be blank', 'NotBlank', 'x')->toArray(); + $field = (new FieldError('reference', 'must not be blank', 'NotBlank', 'x'))->toArray(); /** @var array> $properties */ $properties = ProblemSchema::schema()['properties']; diff --git a/packages/openapi/tests/Web/ViewerPageTest.php b/packages/openapi/tests/Web/ViewerPageTest.php index c7e334f..4702771 100644 --- a/packages/openapi/tests/Web/ViewerPageTest.php +++ b/packages/openapi/tests/Web/ViewerPageTest.php @@ -6,7 +6,7 @@ use Firefly\OpenApi\Web\ViewerPage; it('renders a self-contained page that never reaches the network', function () { - $html = new ViewerPage('Orders API')->render('/openapi.json', 'builtin'); + $html = (new ViewerPage('Orders API'))->render('/openapi.json', 'builtin'); // The ONE network call the default viewer makes is to the spec route it was handed, so no element may // FETCH from another origin. Asserting on src/href rather than on the raw substring "http://" is the @@ -23,7 +23,7 @@ }); it('resolves $ref pointers client-side so a reader sees members, not pointers', function () { - $html = new ViewerPage('Orders API')->render('/openapi.json', 'builtin'); + $html = (new ViewerPage('Orders API'))->render('/openapi.json', 'builtin'); expect($html)->toContain('function deref') // The JSON Pointer walk itself: a local "#/a/b" pointer split and followed into the loaded document. @@ -35,8 +35,8 @@ }); it('only reaches a CDN under the explicit cdn style', function () { - $builtin = new ViewerPage('Orders API')->render('/openapi.json', 'builtin'); - $cdn = new ViewerPage('Orders API')->render('/openapi.json', 'cdn'); + $builtin = (new ViewerPage('Orders API'))->render('/openapi.json', 'builtin'); + $cdn = (new ViewerPage('Orders API'))->render('/openapi.json', 'cdn'); expect($builtin)->not->toContain('swagger-ui') ->and($cdn)->toContain('swagger-ui-bundle.js') @@ -48,7 +48,7 @@ // The default style is the OFFICIAL Swagger UI served from this application's own origin — the full // console, with no third-party request at page view. it('serves the official Swagger UI from local assets by default', function () { - $html = new ViewerPage('Orders API')->render('/openapi.json', 'swagger', '/openapi/assets'); + $html = (new ViewerPage('Orders API'))->render('/openapi.json', 'swagger', '/openapi/assets'); expect($html)->toContain('/openapi/assets/swagger-ui-bundle.js') ->and($html)->toContain('/openapi/assets/swagger-ui.css') @@ -68,14 +68,14 @@ it('escapes the configured spec path into the inline script', function () { // The path comes from application config, not from a request, so this is defence in depth — but a page // that renders a config value into inline script has no business relying on that distinction. - $html = new ViewerPage('Orders API')->render('/openapi.json"', 'builtin'); + $html = (new ViewerPage('Orders API'))->render('/openapi.json"', 'builtin'); expect($html)->not->toContain('') ->and(substr_count($html, 'toBe(1); }); it('escapes the document title into the page markup', function () { - $html = new ViewerPage('')->render('/openapi.json', 'builtin'); + $html = (new ViewerPage(''))->render('/openapi.json', 'builtin'); expect($html)->not->toContain('and($html)->toContain('<img src=x'); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0700f8e..3611d81 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,6 +4,12 @@ includes: parameters: level: max + # PARSE AT THE MINIMUM SUPPORTED VERSION, not at whatever the developer happens to run. Every package + # declares `php: ^8.3`, and PHP 8.4's `new Foo()->bar()` (no parentheses) is a PARSE ERROR on 8.3 — so a + # file using it is broken for the framework's own floor while analysing and testing perfectly on 8.5. + # That is exactly what happened: ten call sites across two test files shipped green locally and turned + # the 8.3 CI job red with "Parse error", which is the least actionable failure a contributor can get. + phpVersion: 80300 universalObjectCratesClasses: - Pest\Mixins\Expectation paths: diff --git a/tests/MinimumPhpSyntaxTest.php b/tests/MinimumPhpSyntaxTest.php new file mode 100644 index 0000000..a9cfc57 --- /dev/null +++ b/tests/MinimumPhpSyntaxTest.php @@ -0,0 +1,162 @@ +bar()` without parentheses around the constructor, and on 8.3 that is a **parse error**, not a + * deprecation. So a file using it analyses clean, tests clean and lints clean on 8.5 while being unloadable + * for a third of the supported range. + * + * That is not hypothetical: ten call sites across two test files shipped green locally and turned the 8.3 CI + * job red with `Parse error: syntax error`, which is the least actionable failure a contributor can be + * handed — it names a file and a column, and nothing about why the same file is fine on their machine. + * + * PHPSTAN CANNOT DO THIS. Its `phpVersion` parameter governs semantic analysis — which functions and + * behaviours exist — and not the parser, which always reads the newest grammar. `php -l` cannot either, + * unless the CI runner happens to be on the oldest version, which is exactly the coincidence that let this + * through. A scan is the only check that runs on every developer's machine regardless of what they have + * installed. + */ +it('uses no syntax newer than the minimum supported PHP version', function () { + $root = dirname(__DIR__); + $offenders = []; + + foreach (['packages', 'skeleton', 'samples', 'tests'] as $directory) { + $path = $root.'/'.$directory; + if (! is_dir($path)) { + continue; + } + + /** @var iterable $files */ + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS)); + + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php' || str_contains($file->getPathname(), '/vendor/')) { + continue; + } + + foreach (newWithoutParentheses((string) file_get_contents($file->getPathname())) as $line) { + $offenders[] = substr($file->getPathname(), strlen($root) + 1).':'.$line; + } + } + } + + sort($offenders); + + expect($offenders)->toBe([]); +}); + +/** + * Lines holding `new Foo(…)->` — the arrow attached directly to the constructor's own closing parenthesis. + * + * TOKENISED, NOT PATTERN-MATCHED, and the first version of this function proved why: a regex over the raw + * text matched the example inside this very docblock and reported the guard as its own first offender. + * `token_get_all()` sees code and never comments or string literals, so the scan cannot be fooled by prose + * that happens to describe the thing it is looking for. + * + * Parentheses are brace-matched because a constructor argument may contain its own — `new Money(max(1, $n))` + * — and counting to the first `)` would report the wrong sites. An already-correct `(new Foo(…))->` is + * skipped by checking the token before the `new`. + * + * @return list + */ +function newWithoutParentheses(string $source): array +{ + $tokens = token_get_all($source); + $lines = []; + + /** @var list $tokens */ + foreach ($tokens as $index => $token) { + if (! is_array($token) || $token[0] !== T_NEW) { + continue; + } + + // Already wrapped: the token before `new` is the opening parenthesis of `(new Foo(…))->`. + $previous = previousCode($tokens, $index); + if ($previous === '(') { + continue; + } + + $cursor = openingParenthesis($tokens, $index); + if ($cursor === null) { + continue; + } + + $depth = 0; + for ($i = $cursor; $i < count($tokens); $i++) { + $current = $tokens[$i]; + if ($current === '(') { + $depth++; + } elseif ($current === ')') { + $depth--; + if ($depth === 0) { + $after = nextCode($tokens, $i); + if (is_array($after) && $after[0] === T_OBJECT_OPERATOR) { + $lines[] = $token[2]; + } + break; + } + } + } + } + + return $lines; +} + +/** + * The `(` that opens the constructor's argument list, or null when the `new` is followed by something this + * scan does not model (an anonymous class, a variable class name with no call). + * + * @param list $tokens + */ +function openingParenthesis(array $tokens, int $from): ?int +{ + for ($i = $from + 1; $i < count($tokens); $i++) { + $token = $tokens[$i]; + + if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NS_SEPARATOR], true)) { + continue; + } + + return $token === '(' ? $i : null; + } + + return null; +} + +/** + * @param list $tokens + * @return array{0: int, 1: string, 2: int}|string|null + */ +function previousCode(array $tokens, int $from): array|string|null +{ + for ($i = $from - 1; $i >= 0; $i--) { + if (is_array($tokens[$i]) && in_array($tokens[$i][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + return $tokens[$i]; + } + + return null; +} + +/** + * @param list $tokens + * @return array{0: int, 1: string, 2: int}|string|null + */ +function nextCode(array $tokens, int $from): array|string|null +{ + for ($i = $from + 1; $i < count($tokens); $i++) { + if (is_array($tokens[$i]) && in_array($tokens[$i][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + return $tokens[$i]; + } + + return null; +}