diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4657505..0f10401 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,8 @@ jobs: - { local: packages/security, split: firefly-security } - { 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/.gitignore b/.gitignore index f19d381..6a1003c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ Thumbs.db *.log /coverage/ /.claude/ + +# Local design-review artifacts, at the repo ROOT only — book/art and docs/assets are tracked, so this +# must never be a blanket *.png. +/*.png +/.playwright-mcp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1c869..8e0a1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,340 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`YY.MM.Patch`). +## [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 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 + +- **`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/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 + 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. 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. + +- **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 + 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, 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 + 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 + 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. + +- **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 9b3a84d..3de9c33 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,9 +65,9 @@ [*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 +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 @@ -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. @@ -330,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]` @@ -621,6 +623,42 @@ 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`): health, metrics, HTTP traffic, beans, a drawn +[**bean graph**](docs/modules/bean-graph.md), conditions, routes, scheduled tasks, environment, config +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 — 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 +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 +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 — @@ -641,11 +679,19 @@ 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 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) +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: @@ -672,10 +718,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 @@ -690,7 +736,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) | @@ -701,8 +747,9 @@ 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` | +| 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` | @@ -716,16 +763,21 @@ 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` | +| 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` | `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. --- +## 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. @@ -736,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`. @@ -769,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/book.es.yaml b/book/book.es.yaml index 5878a3d..3c9a410 100644 --- a/book/book.es.yaml +++ b/book/book.es.yaml @@ -19,6 +19,23 @@ labels: # (Capítulos 7-10: CQRS, EDA/outbox, transacciones, seguridad). Tarea B5: Parte IV # añadida (Capítulos 11-13: observabilidad, pruebas, CLI/caché) + Apéndices # (Apéndice A + Glosario). Esto completa el alcance de ~13 capítulos del libro. +# Tarea B6: firefly/openapi añadido como Capítulo 4A, justo después del capítulo HTTP sobre el +# que se apoya. El orden de los capítulos sale de ESTA lista, no de los nombres de fichero, y +# `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. +# 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} @@ -35,6 +52,7 @@ parts: - {id: ch02, file: 02-dependency-injection.md, num: 2, title: "Inyección de Dependencias y Auto-Configuración"} - {id: ch03, file: 03-configuration.md, num: 3, title: "Configuración, Perfiles y Secretos"} - {id: ch04, file: 04-first-http-api.md, num: 4, title: "Tu Primera API HTTP"} + - {id: ch04a, file: 04a-openapi.md, num: "4A", title: "Documentar la API: OpenAPI 3.1 desde los Manifiestos"} - title: "Parte II — Modelar y Persistir el Dominio" chapters: - {id: ch05, file: 05-persistence-repositories.md, num: 5, title: "Persistencia y el Patrón Repositorio"} diff --git a/book/book.yaml b/book/book.yaml index 6296670..298c805 100644 --- a/book/book.yaml +++ b/book/book.yaml @@ -18,6 +18,23 @@ labels: # Task B4: Part II completed with Part III opened (Chapters 7-10: CQRS, EDA/outbox, transactions, security). # Task B5: Part IV added (Chapters 11-13: observability, testing, CLI/cache) + Appendices (Appendix A + Glossary). # This completes the book's ~13-chapter scope. +# Task B6: firefly/openapi added as Chapter 4A, immediately after the HTTP chapter it builds on. +# Chapter order comes from THIS list, not from the file names, and `num` is a free-form label +# (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. +# 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} @@ -34,6 +51,7 @@ parts: - {id: ch02, file: 02-dependency-injection.md, num: 2, title: "Dependency Injection & Auto-Configuration"} - {id: ch03, file: 03-configuration.md, num: 3, title: "Configuration, Profiles & Secrets"} - {id: ch04, file: 04-first-http-api.md, num: 4, title: "Your First HTTP API"} + - {id: ch04a, file: 04a-openapi.md, num: "4A", title: "Documenting the API: OpenAPI 3.1 from the Manifests"} - title: "Part II — Modeling & Persisting the Domain" chapters: - {id: ch05, file: 05-persistence-repositories.md, num: 5, title: "Persistence & the Repository Pattern"} diff --git a/book/src-es/04a-openapi.md b/book/src-es/04a-openapi.md new file mode 100644 index 0000000..41955d6 --- /dev/null +++ b/book/src-es/04a-openapi.md @@ -0,0 +1,796 @@ +Parte I — Fundamentos · Capítulo 4A + +# 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. 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. + +--- + +## No hay nada que anotar + +Todas las cadenas de herramientas OpenAPI en PHP anteriores a esta te piden escribir el documento dos veces: una como el código que ejecuta el servidor, y otra como anotaciones, atributos o un fichero YAML que *describen* el código que ejecuta el servidor. Las dos divergen la primera vez que alguien añade un campo con prisa, y la divergencia es invisible — el documento sigue validando, simplemente ya no coincide con el servidor. + +`firefly/openapi` no tiene ese modo de fallo disponible, porque no tiene una segunda fuente. El Capítulo 4 terminó con el `RouteManifest`: la tabla compilada de cada `RouteDescriptor` desde la que sirve el dispatcher, que lleva el verbo, la ruta, el estado declarado, el nombre de ruta, la clase y el método del controlador, y el *plan de enlace* por parámetro. El Capítulo 4 también presentó el `ConstraintManifest`: la lista de reglas compilada que `BeanValidator` ejecuta sobre un cuerpo `#[Valid]`. Esos dos artefactos, más el `ErrorResponse` de `firefly/kernel`, son toda la entrada: + +```bash +composer require firefly/openapi +``` + +Esa es toda la instalación. Arranca la app y `GET /openapi.json` queda servido; `GET /openapi` renderiza una consola de referencia sobre él. No se anotó nada, y nada puede divergir, porque cada hecho del documento se lee del mismo artefacto compilado que lee el dispatcher. + +--- + +## `firefly:openapi`, y rutas que no son rutas por atributo + +El documento también es un fichero que puedes versionar: + +```bash +php artisan firefly:openapi --output=docs/openapi.json # escribe el fichero e imprime una línea de resumen +php artisan firefly:openapi > openapi.json # escribe el documento en crudo a stdout +``` + +El comando existe para que el documento pueda ser un **artefacto de compilación** y no solo un endpoint vivo. Versionar el fichero generado es lo que permite a un job de CI diferenciarlo y hacer fallar un pull request que cambió la API pública sin decirlo, y lo que permite a un repositorio de front-end regenerar su cliente tipado desde una especificación versionada sin arrancar la aplicación PHP en absoluto. Es además la única forma de obtener un documento de un despliegue que mantiene `firefly.openapi.enabled` apagado en producció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 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 +{ + 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'); + + // 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 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 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. + +--- + +## Del `RouteManifest` a los Operation Objects + +`OpenApiGenerator::generate()` es el punto de entrada — memoiza una única pasada privada `build()` (`$this->document ??= $this->build()`), y `toJson()` la envuelve para un fichero o un cuerpo HTTP. Esa pasada recorre el manifiesto una vez, salta las rutas excluidas, y entrega cada superviviente a `OperationFactory`. Todo lo relativo al resultado es determinista a propósito: + +```php +final class OpenApiGenerator +{ + private const array VERB_ORDER = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + + 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]; + } +} +``` + +Las rutas se ordenan, los verbos dentro de una ruta se ordenan en el orden canónico en que la propia especificación OpenAPI los enumera, y el registro de esquemas ordena los componentes por nombre. Esto no es pulcritud por la pulcritud. El orden de descubrimiento de rutas depende de la iteración del sistema de ficheros, así que un documento *sin ordenar* se rebarajaría entre máquinas y convertiría cada regeneración en un diff irrevisable — que es exactamente lo que hace que los equipos dejen de versionar el fichero generado, que es lo que hace que se quede obsoleto. + +Dentro de una operación, el plan de enlace hace el trabajo de verdad. `OperationFactory` despacha sobre el mismo discriminador `kind` que usa `ArgumentResolver` en tiempo de petición: + +```php +final class OperationFactory +{ + 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; + } + } + + // …the operation's own prose (operationId, summary, description, tags) is assembled here… + + 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; + } +} +``` + +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: + +- **Plantilla de ruta.** La grafía de parámetro opcional de Laravel, `{id?}`, no tiene equivalente en OpenAPI — allí un parámetro de ruta es obligatorio, punto — así que el marcador se elimina y el parámetro queda obligatorio. Emitir dos Path Items en su lugar describiría una superficie de router que no existe y duplicaría cada operación así en un cliente generado. +- **`operationId`.** El `name` de la ruta cuando lo tiene, y si no, derivado como el nombre corto del controlador menos un `Controller` final, en minúscula inicial, más el nombre del método: `Lumen\Web\WalletController::balance()` se convierte en `walletBalance`. Como `operationId` debe ser único en todo el documento — y un duplicado es el único fallo que hace que la mayoría de generadores de clientes aborten en lugar de degradar — una reclamación repetida se sufija (`walletBalance_2`) en lugar de permitirse que sobrescriba. +- **`tags`.** El mismo nombre corto, de modo que cada operación de `WalletController` se agrupa bajo "Wallet" en un visor. +- **`summary`.** Separado del nombre del método: `getBalance` se lee como "Get balance". El nombre de un método es la única etiqueta escrita por un humano que lleva una ruta — el `name` de un `#[Mapping]` es un nombre de ruta de Laravel, no prosa — así que es la fuente honesta. + +--- + +## Cuerpos de petición: un componente por DTO, alcanzado por `$ref` + +El `OpenWalletRequest` del Capítulo 4 vuelve a ser el ejemplo conductor: + +```php +final class OpenWalletRequest +{ + public function __construct( + #[NotBlank] + public readonly string $owner_id, + #[NotBlank] + #[CurrencyCode] + public readonly string $currency, + ) {} +} +``` + +`POST /api/v1/wallets` lo enlaza con `#[Valid] #[RequestBody]`, y la operación generada lo referencia en lugar de repetirlo: + +```json +{ + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OpenWalletRequest" } + } + } + } +} +``` + +`SchemaRegistry` es lo que hace posible ese `$ref`, y resuelve dos problemas que tiene un generador ingenuo del tipo "incrusta el esquema en cada sitio de uso". El primero es la **duplicación**: un DTO usado por seis operaciones se emitiría seis veces, y cada cliente generado acuñaría seis tipos anónimos estructuralmente idénticos con seis nombres distintos. Registrarlo una vez y referirlo por puntero es lo que hace que `openapi-generator`, `orval` y `kiota` produzcan *un* tipo con nombre por DTO — que es todo el sentido de generar el documento en primer lugar. El segundo es la **recursión**: un DTO con un miembro `#[Valid] ?self $parent` no puede incrustarse en absoluto, porque la expansión no termina. Por eso el registro reserva el nombre del componente *antes* de invocar al constructor del esquema, de modo que una llamada anidada para la misma clase encuentra el nombre ya tomado y devuelve el puntero de inmediato, cerrando el ciclo. Un ciclo de `$ref` es legal y útil en un documento donde una lista de reglas aplanada sería infinita. + +Los nombres de componente son el nombre corto de la clase, porque es lo que lee un humano en un visor y lo que un generador convierte en un nombre de tipo. Dos DTOs que compartan nombre corto entre espacios de nombres — `Order\Dto\Address` y `Billing\Dto\Address` — chocarían y uno sobrescribiría al otro en silencio, así que el **segundo** reclamante de un nombre cae a su nombre completamente cualificado con puntos: feo, inequívoco y raro. Gana el primer reclamante, de modo que añadir un segundo `Address` en otro punto de la app nunca renombra el que ya estaba publicado. + +Dos propiedades del esquema emitido merecen decirse explícitamente porque ambas son decisiones y no omisiones. La lista de miembros es la **lista de parámetros del constructor en orden de declaración**, porque es exactamente de donde hidrata `ArgumentResolver` — pero una restricción asociada a un miembro sin parámetro de constructor se documenta igualmente, porque `BeanValidator` valida el array decodificado en bruto y por tanto la exige en la entrada de todos modos. Y **nunca se emite `additionalProperties: false`**: el servidor genuinamente ignora las claves fuera de la lista del constructor, así que una especificación que afirmara lo contrario haría que clientes conformes rechazaran peticiones que el servidor habría servido. + +--- + +## Las restricciones se convierten en palabras clave de esquema + +Ninguna de las dos mitades de un DTO basta por sí sola. El `ConstraintManifest` conoce el contrato de *validación* pero nada sobre tipos, porque una lista de reglas es, por construcción, sin tipos. El constructor conoce el contrato de *tipos* — `?int`, un enum respaldado, un DTO anidado, un valor por defecto — pero nada sobre las restricciones. Solo con los tipos, `#[NotBlank] string $name` se documentaría como una cadena sin límites; solo con las restricciones, `int $quantity` se documentaría como una cadena. `DtoSchemaFactory` las fusiona, y `ConstraintSchemaMapper` mapea la segunda mitad. + +Mapea el **manifiesto compilado**, nunca los atributos `#[Constraint]`. Leer los atributos de vuelta desde el DTO sería la ruta obvia hacia "`#[Email]` → `format: email`", y documentaría un validador que no existe: el manifiesto ya ha aplicado el contrato de nulos Jakarta de `ConstraintScanner`, ya ha expandido `#[Size]` en un *objeto* regla de primera parte en vez de las cadenas polimórficas `min:`/`max:` de Laravel, y ya ha aplanado un nivel de `#[Valid]` en claves con puntos. Generar desde los atributos volvería a derivar todo eso a mano y divergiría la primera vez que cambiara un cuerpo de `toRules()`. Generar desde el manifiesto no puede divergir, porque el manifiesto *es* el contrato. + +Este es el mapeo real, restricción a restricción, con las reglas compiladas en la columna central para que veas que el mapeador lee reglas y no atributos: + +| Restricción | Compila a | JSON Schema | +|---|---|---| +| `#[NotBlank]` | `required`, `string`, `regex:/\S/` | miembro añadido a `required`; `type: string`; `pattern: \S` | +| `#[NotEmpty]` | `required` | miembro añadido a `required` | +| `#[NotNull]` | `present` + un objeto regla `NotNull` | miembro añadido a `required` **y** `null` retirado de la unión de tipos | +| `#[Min(n)]` / `#[Max(n)]` | `numeric`, `gte:n` / `lte:n` | `minimum` / `maximum` | +| `#[Positive]` / `#[Negative]` | `numeric`, `gt:0` / `lt:0` | `exclusiveMinimum: 0` / `exclusiveMaximum: 0` | +| `#[PositiveOrZero]` / `#[NegativeOrZero]` | `numeric`, `gte:0` / `lte:0` | `minimum: 0` / `maximum: 0` | +| `#[Email]` | `email` | `type: string`, `format: email` | +| `#[Pattern(re)]` | `regex:re` | `pattern`, con los delimitadores PCRE y las banderas inocuas `D`/`u` retiradas | +| `#[Digits(i, f)]` | `numeric`, un `regex:` anclado | `type: number` + `pattern` | +| `#[AssertTrue]` / `#[AssertFalse]` | `accepted` / `declined` | `type: boolean` + `const: true` / `const: false` | +| `#[Future]` / `#[Past]` | `date`, `after:now` / `before:now` | `type: string`, `format: date-time` (la mitad `after:now` se registra, no se mapea) | +| `#[Size(min, max)]` | un objeto regla `Size` | `minLength`/`maxLength`, o `minItems`/`maxItems` cuando el tipo es un array | +| `#[UuidValue]` | un objeto regla `Uuid` | `format: uuid` + el `pattern` de UUID | +| `#[Phone]` | un objeto regla `E164` | `format: phone` + `pattern: ^\+[1-9]\d{1,14}$` | +| `#[CurrencyCode]` / `#[CountryCode]` | objetos regla `Currency` / `CountryCode` | `format: currency` + `^[A-Z]{3}$` / `format: country-code` + `^[A-Z]{2}$` | +| `#[LanguageTag]` / `#[PostalCode]` | objetos regla | `format: bcp47` / `format: postal-code`, cada uno con su patrón | +| `#[Iban]` / `#[Swift]` / `#[Bic]` | objetos regla | `format: iban` / `swift` / `bic`, **patrón retenido** | +| `#[Cusip]` / `#[Isin]` / `#[Luhn]` / `#[RoutingNumber]` | objetos regla | solo `format`; el dígito de control se registra, no se mapea | +| `#[Percentage]` | un objeto regla `Percentage` | `type: number`, `minimum: 0`, `maximum: 100` | +| `#[Money]` | un objeto regla `PositiveMoney` | `type: number`, `exclusiveMinimum: 0`, `multipleOf: 0.01` | +| `#[DecimalScale(n)]` | un objeto regla `DecimalScale` | `multipleOf` — la escala 2 se convierte en `0.01` | + +Tres filas de esa tabla compensan una mirada más detenida. + +**El patrón se retiene allí donde la regla normaliza primero.** `Iban` quita espacios y pasa a mayúsculas antes de comparar; `Bic`, `Swift`, `Cusip` e `Isin` pasan a mayúsculas; `Luhn` y `RoutingNumber` quitan separadores. Publicar el patrón posterior a la normalización rechazaría cargas que el servidor acepta encantado, lo cual es peor que subespecificar — así que se emite el `format` y el patrón no. + +**`format` es un vocabulario abierto.** En JSON Schema 2020-12 un `format` desconocido es una anotación, no un error. IBAN, BIC, ISIN, CUSIP y E.164 no tienen nombre de formato registrado, así que se emiten unos autodescriptivos (`iban`, `bic`, …) en lugar de nada. + +**La nulabilidad se escribe a la manera de 3.1.** OpenAPI 3.1 *es* JSON Schema 2020-12, que eliminó la palabra clave `nullable: true` de 3.0 en favor de una unión de tipos. Un miembro nulable es por tanto `"type": ["string", "null"]`, y un `enum` gana además un miembro `null` — ensanchar solo el tipo dejaría a `null` fallando la enumeración, y la propiedad quedaría, en la práctica, indocumentable como nula. + +La única regla que decide toda la fusión es **gana quien escribe primero**, aplicada primero al tipo declarado y luego a las reglas en orden de declaración — el mismo orden en que las aplica el validador: + +```php +final class MapperState +{ + public function keyword(string $keyword, mixed $value): void + { + if (! array_key_exists($keyword, $this->schema)) { + $this->schema[$keyword] = $value; + } + } +} +``` + +Sembrar el tipo PHP declarado *antes* de ver ninguna regla es la razón de que `#[Min(1)] int $quantity` se quede en `type: integer` en vez de ensancharse a `number` por la cadena de regla `numeric` que emite `#[Min]` — un ensanchamiento que documentaría erróneamente `1.5` como aceptable. + +Aquí está toda esa tubería sobre un DTO real. Es el propio fixture de pruebas del generador, elegido porque abarca deliberadamente cada ruta de mapeo que tiene el generador — una cadena con límite de longitud, un nulable del contrato de nulos Jakarta, un entero con límites numéricos, un decimal con escala, un enum respaldado, un DTO anidado con `#[Valid]`, un patrón PCRE y un objeto regla: + +```php +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, + ) {} +} +``` + +…y este es el componente que genera, literalmente: + +```json +{ + "type": "object", + "title": "CreateOrderRequest", + "properties": { + "reference": { "type": "string", "maxLength": 64, "pattern": "\\S" }, + "email": { "type": "string", "format": "email" }, + "quantity": { "type": "integer", "minimum": 1, "maximum": 999 }, + "amount": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01 }, + "currency": { "type": "string", "enum": ["EUR", "USD"] }, + "shipTo": { "$ref": "#/components/schemas/AddressPayload" }, + "coupon": { "type": ["string", "null"], "pattern": "^[A-Z]{3}-\\d{4}$" }, + "idempotencyKey": { + "type": ["string", "null"], + "format": "uuid", + "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}$" + } + }, + "required": ["reference", "email", "quantity", "amount", "currency", "shipTo"] +} +``` + +Cada miembro de ese objeto es rastreable hasta algo de la clase de arriba: el enum vino de los propios casos del enum respaldado, el `$ref` de `#[Valid]`, las dos uniones nulables de `?string`, el `multipleOf` de `#[DecimalScale(2)]`, y `required` de las reglas de presencia — `coupon` e `idempotencyKey` están ausentes de él porque nada afirma su presencia. + +Los DTOs de Lumen muestran la misma maquinaria a menor escala, y uno de ellos muestra un caso que la tabla anterior no puede: una propiedad que lleva **dos** patrones. + +```json +{ + "owner_id": { "type": "string", "pattern": "\\S" }, + "currency": { + "type": "string", + "format": "currency", + "allOf": [{ "pattern": "\\S" }, { "pattern": "^[A-Z]{3}$" }] + } +} +``` + +`#[NotBlank] #[CurrencyCode] string $currency` produce genuinamente dos patrones — `\S` de la regla de no-blanco y `^[A-Z]{3}$` de la regla de divisa — y JSON Schema tiene exactamente una ranura `pattern` por objeto de esquema. Colapsarlos quedándose con el último descartaría en silencio la garantía de no-blanco, así que varios patrones se convierten en un `allOf` de subesquemas de un patrón cada uno. Un solo patrón sigue siendo un `pattern` liso; el `allOf` aparece únicamente cuando hace falta. + +--- + +## Nada se descarta en silencio + +Algunas restricciones no tienen equivalente alguno en JSON Schema, y unas pocas se mapean solo de forma aproximada. Descartarlas calladamente produciría un documento que promete *menos* validación de la que realiza el servidor — un cliente enviaría una carga que la especificación llama válida y recibiría un `422` de vuelta. El caso por defecto del mapeador dice qué ocurre en su lugar: + +```php +final class ConstraintSchemaMapper +{ + private function applyObject(MapperState $state, ValidationRule $rule): void + { + switch (true) { + // ... every recognised first-party rule object is matched above. + 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); + } + } +} +``` + +Todo lo no reconocido — `after:now` y `before:now`, `exists:` y `unique:`, un dígito de control Luhn o CUSIP sin más, un límite que nombra a otro campo (`gte:other_field`) en vez de a un número, y cualquier `ValidationRule` de terceros — se registra bajo `x-firefly-constraints`. También un patrón que el mapeador solo pudo aproximar: `D` y `u` se descartan por ser inocuas de verdad, pero cualquier otra bandera — `i` sobre todo, para la que ECMA-262 no tiene sintaxis en línea dentro de una cadena de patrón — no puede trasladarse, así que el patrón se emite igualmente (es la afirmación verdadera más próxima disponible) *y* la regla original se registra, de modo que un lector puede ver que el patrón publicado es más estricto que el del servidor. + +Tres propiedades, tres desenlaces: + +```json +{ + "deliverAfter": { "type": "string", "format": "date-time", "x-firefly-constraints": ["after:now"] }, + "slug": { "type": "string", "pattern": "^[a-z]+$", "x-firefly-constraints": ["regex:/^[a-z]+$/i"] }, + "account": { "type": "string", "format": "iban", "x-firefly-constraints": ["iban:checksum"] } +} +``` + +Las herramientas conformes ignoran las tres extensiones y ven un documento válido. Un humano, o un generador que escribas tú, puede leer la verdad completa. + +--- + +## Respuestas: un componente de problema, y un conjunto de errores derivado + +Cada operación termina en el mismo componente de error, y ese componente describe lo que LaraFly *realmente* devuelve, no lo que la RFC 9457 describe en abstracto: + +```php +final class ProblemSchema +{ + public const string MEDIA_TYPE = 'application/problem+json'; + + public static function response(): array + { + return [ + 'description' => 'Error response in RFC 9457 problem+json form.', + 'content' => [self::MEDIA_TYPE => ['schema' => ['$ref' => self::REF]]], + ]; + } +} +``` + +La distinción importa porque las dos formas difieren. El `ErrorResponse::toArray()` del Capítulo 4 emite `status`, `title`, `code`, `category` y `severity` incondicionalmente, luego `detail`/`type`/`instance`/`traceId`/`timestamp` solo cuando no son nulos, y luego `errors` solo cuando no está vacío. Así que `code`, `category`, `severity` y `errors` son miembros de Firefly encima de los cinco de la RFC; `type` es opcional aquí, donde la RFC le da un valor por defecto; e `instance` lleva una *ruta* de petición y no una referencia URI. Documentar la forma de la RFC en vez de esta le entregaría a cada cliente generado un decodificador que descarta en silencio los tres miembros sobre los que un llamante realmente ramifica. + +Las dos enumeraciones se leen directamente de los propios enums del kernel, de modo que un caso añadido en `firefly/kernel` aparece en la especificación en la siguiente generación sin ninguna edición en `firefly/openapi`: + +```json +{ + "category": { + "type": "string", + "enum": ["business", "validation", "security", "infrastructure", + "external", "framework", "plugin", "internal"] + }, + "severity": { "type": "string", "enum": ["info", "warning", "error", "critical"] } +} +``` + +Qué estados enumera una operación es algo **derivado, no adivinado**. Compara dos operaciones reales de Lumen. `GET /api/v1/wallets/{id}/balance` toma una variable de ruta `string` y ningún cuerpo: + +```json +{ + "200": { "description": "Successful response.", + "content": { "application/json": { "schema": { "type": "object" } } } }, + "default": { "$ref": "#/components/responses/Problem" } +} +``` + +`POST /api/v1/wallets/{id}/deposit` toma la misma variable de ruta más un `#[Valid] #[RequestBody] AmountRequest`: + +```json +{ + "200": { "description": "Successful response.", + "content": { "application/json": { "schema": { "type": "object" } } } }, + "400": { "$ref": "#/components/responses/Problem" }, + "422": { "$ref": "#/components/responses/Problem" }, + "default": { "$ref": "#/components/responses/Problem" } +} +``` + +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`. + +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. + +--- + +## Las rutas HTML `#[Controller]` no son operaciones + +El Capítulo 4 presentó `#[RestController]` junto a su hermano HTML `#[Controller]`, y solo el primero es una API JSON. El generador honra esa distinción por defecto: + +```php +final class OpenApiGenerator +{ + 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; + } +} +``` + +Una ruta `#[Controller]` renderiza una página. Forma parte de la superficie HTTP de la aplicación, pero no es una operación JSON, y describirla como `application/json` haría que un generador emitiera un cliente tipado para una respuesta que es una página web — la propia página de bienvenida del framework estaba en la especificación exactamente así antes de que existiera esta regla. Pon `firefly.openapi.include-html` a `true` y la ruta se documenta igualmente, pero con honestidad: la operación se produce entonces con contenido `text/html` y un esquema `type: string`, no con un esquema JSON que sería una mentira sobre la que un generador de clientes actuaría fielmente. + +La segunda mitad de ese método es el instrumento romo para todo lo demás: `firefly.openapi.exclude` es un CSV de prefijos de ruta — `'/internal,/admin'` — para rutas que son JSON pero no son la API pública de nadie. + +--- + +## 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. + +!!! 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. + +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. + +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' => ['style' => 'cdn']], +]; +``` + +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. + +--- + +## Configuración, y asegurar la superficie + +Todo lo que el documento y sus rutas necesitan vive bajo una sola clave de configuración: + +```php + [ + 'enabled' => true, // master gate: off means every route is genuinely unrouted + 'path' => '/openapi.json', // spec route + 'viewer' => [ + 'enabled' => true, + '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 + ], +]; +``` + +`servers` acepta las dos grafías que usa un fichero de configuración real — una lista de URLs sueltas, y la propia forma de objeto de OpenAPI con una `description` — y una entrada que no sea ninguna de las dos se descarta en lugar de emitirse, porque un Server Object sin `url` es inválido bajo el esquema 3.1 y envenenaría un documento por lo demás correcto. + +Un despliegue público se asegura como cualquier otra ruta. Las reglas de `HttpSecurity` del Capítulo 10 — más adelante, en la Parte III — cubren las rutas de especificación y visor sin ningún borde de código, porque `HttpSecurityFilter` es un middleware global y corre para las rutas registradas nativamente exactamente igual que corre para tus controladores: + +```php + [ + 'enabled' => true, + 'http' => [ + 'enabled' => true, + 'rules' => [ + ['pattern' => 'openapi', 'access' => 'hasRole:DEVELOPER'], + ['pattern' => 'openapi/*', 'access' => 'hasRole:DEVELOPER'], + ['pattern' => 'openapi.json', 'access' => 'hasRole:DEVELOPER'], + ], + ], + ], +]; +``` + +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. + +--- + +## Reemplazar una pieza de la tubería + +Cada colaborador del paquete — `OpenApiProperties`, `ConstraintSchemaMapper`, `DtoSchemaFactory`, `OperationFactory`, `OpenApiGenerator` y `ViewerPage` — es un `#[Bean]` tras un `#[ConditionalOnMissingBean]`, el mecanismo del Capítulo 2. Enseñarle al generador tu propia `ValidationRule` es por tanto una `#[Configuration]` corta en tu aplicación y nunca un fork: + +```php +#[Configuration] +final class ApiDocsConfiguration +{ + #[Bean] + public function constraintSchemaMapper(): ConstraintSchemaMapper + { + return new HouseConstraintSchemaMapper; // teaches the generator your own ValidationRules + } +} +``` + +!!! note "La reflexión de aquí es real, y no está en el camino de petición" + `DtoSchemaFactory` refleja el constructor de un DTO para conocer los tipos de sus propiedades, lo que parece una violación de la regla que el Capítulo 13 enunciará por completo: nada en el camino de petición cacheado refleja. No lo es. Esa reflexión corre cuando `firefly:openapi` genera un fichero, o ante un impacto en la ruta de la especificación — cuyo resultado el generador memoiza durante toda la vida del proceso — y nunca mientras se despacha una petición de la aplicación. Es la misma categoría de trabajo que `RouteScanner` y `ConstraintScanner`, que ambos reflejan solo en tiempo de compilación. La alternativa, enseñar a `RouteScanner` a emitir tipos por propiedad dentro de cada `RouteDescriptor`, se rechazó porque haría crecer el manifiesto de rutas compilado de *todas* las apps en beneficio de un único paquete opcional. + +!!! laravel "Paridad con Laravel" + Laravel de serie no trae soporte OpenAPI. Las respuestas habituales son un paquete de terceros gobernado por su propio dialecto de anotaciones (los bloques `@OA\` de `zircote/swagger-php`, o las clases de atributos de `vyuldashev/laravel-openapi`) o un fichero YAML mantenido a mano — ambos son una *segunda* descripción de la API, situada junto a las rutas y los FormRequests que realmente la imponen, y ambos divergen. `firefly/openapi` no tiene dialecto que aprender porque no tiene segunda descripción: lee el mismo `RouteManifest` desde el que despacha el dispatcher y el mismo `ConstraintManifest` con el que valida el validador. El análogo más cercano fuera de PHP es springdoc-openapi, y lo más parecido dentro del propio Laravel es `php artisan route:list` — exacto por la misma razón, e incapaz por la misma razón de decirte nada sobre un cuerpo de petición. + +--- + +## Lo que aprendiste {.recap} + +| Concepto | Qué hace | +|---|---| +| `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`, `/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 | +| `ConstraintSchemaMapper` | Mapea la lista de reglas compilada — no los atributos — a palabras clave; gana quien escribe primero, así que `#[Min(1)] int` se queda en `integer` | +| `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 | +| `ViewerPage::render()` | Cae de vuelta a `builtin` cuando falta la dist de Swagger, en lugar de renderizar una consola cuyos assets dan 404 | + +--- + +## Ponlo en práctica {.exercises} + +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. **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/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index 0c4185f..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`. +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. @@ -378,7 +378,7 @@ return [ El propio `composer.json` de `firefly/actuator` no tiene ninguna dependencia de `firefly/security` en absoluto — asegurar el actuator de esta manera es **pura configuración**, apoyándose en el mismo DSL de URL de denegar-por-defecto que el Capítulo 10 ya te enseñó, sin ningún mecanismo nuevo que aprender. !!! warning "Cualquier otro endpoint de gestión es un endpoint independiente, no una sub-clave de `/info`" - `/actuator/info` genuinamente tiene exactamente dos fragmentos — `app` (de `AppInfoContributor`, leído desde `firefly.management.info.app.*`) y `build` (de `BuildInfoContributor`, que lee un archivo JSON en `firefly.management.info.build.path`). `env`, `beans`, `conditions`, `mappings`, `loggers` y `scheduledtasks` son cada uno su **propio** `ActuatorEndpoint`, montado en su propio `/actuator/{id}` — no anidado bajo `/info`. `firefly:about` (Capítulo 13) renderiza varios de estos juntos en el terminal, lo cual es una comodidad de ese único comando, no una prueba de que compartan una ruta. + `/actuator/info` genuinamente tiene exactamente tres fragmentos — `runtime` (de `RuntimeInfoContributor`, registrado por defecto, que es la razón por la que una aplicación recién generada ya responde algo: versión/SAPI/OPcache de PHP, versión de Laravel, versión de LaraFly, memoria actual y pico; desactívalo con `firefly.management.info.runtime.enabled = false`), `app` (de `AppInfoContributor`, leído desde `firefly.management.info.app.*`) y `build` (de `BuildInfoContributor`, que lee un archivo JSON en `firefly.management.info.build.path`). `env`, `beans`, `conditions`, `mappings`, `loggers`, `scheduledtasks`, `configprops` y `caches` son cada uno su **propio** `ActuatorEndpoint`, montado en su propio `/actuator/{id}` — no anidado bajo `/info`. `firefly:about` (Capítulo 13) renderiza varios de estos juntos en el terminal, lo cual es una comodidad de ese único comando, no una prueba de que compartan una ruta. --- @@ -648,8 +648,355 @@ Cada bean de observabilidad se apoya en la **misma** propiedad, `firefly.observa Por último, un puerto `Tracer` remata el paquete — una abstracción mínima de span `trace(string $name, callable $callback): mixed`, entregada hoy solo como `NoOpTracer` (simplemente ejecuta la clausura), escrita contra la interfaz de modo que un adaptador respaldado por OpenTelemetry pueda caer en su lugar más tarde con cero cambios en los sitios de llamada — la idéntica forma "puerto ahora, adaptador después" que ya has visto para el propio `CqrsMetrics`. +--- + +## 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 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 +``` + +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. + +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 | +|---|---|---|---| +| Runtime | Resumen | varios | ¿Está sano, qué está haciendo, y qué cableó? | +| Runtime | Salud | `health` | Cada indicador que registró este proceso, con su propio estado y detalles | +| 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 | +| Configuración | Entorno | `env` | La configuración `firefly.*` resuelta, con los secretos enmascarados | +| Configuración | Propiedades de configuración | `configprops` | Cada DTO `#[ConfigProperties]` que enlazó la aplicación, con los valores que resolvió | +| Configuración | Cachés | `caches` | Los almacenes de caché que tiene configurados esta aplicación | +| Configuración | Loggers | `loggers` | Canales de log y sus niveles, con un control para cambiar uno | + +Una página cuyo endpoint no está registrado en *este* proceso — o está apagado — queda **oculta del menú** en lugar de ofrecerse como un enlace que aterriza en una disculpa. Eso importa porque los endpoints del actuator son condicionales: `metrics` desaparece cuando `firefly.observability.metrics.enabled` es falso, y varios otros existen solo si está instalado el paquete que los aporta. El menú tiene que construirse a partir de lo que este proceso registró de verdad, y así se hace. + +--- + +### Lee los endpoints en proceso, no por HTTP + +El panel sostiene el `ActuatorRegistry` e invoca cada bean `ActuatorEndpoint` directamente: + +```php +final readonly class AdminEndpointReader +{ + public function __construct( + private ActuatorRegistry $registry, + private Config $config, + private ?Container $container = null, + ) {} + + /** The endpoint ids that are registered AND not switched off, in registration order. */ + 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 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; + } +} +``` + +Fíjate en lo que **no** hay en ese método: ninguna mención a `ExposureModel`. Eso es lo más importante que hay que entender de este paquete, y es deliberado. `firefly.management.endpoints.web.exposure.include` vale `health,info` por defecto, así que traer `/actuator/beans` o `/actuator/env` por HTTP daría un 404 — como toda la primera mitad de este capítulo insistió en que debía ser. El panel no necesita nada de eso. Renderiza lo que el proceso ya sabe, en proceso, de modo que **te muestra páginas que la superficie HTTP deliberadamente no expone**, y la superficie JSON sigue siendo segura por defecto. Exponer `beans`, `conditions` y `env` a cualquier llamante anónimo solo para que un navegador pudiera leerlos sería exactamente el intercambio equivocado. + +El interruptor de apagado por endpoint *sí* se honra, y la asimetría es el quid: `firefly.management.endpoint.{id}.enabled` significa "este endpoint está apagado", que es una afirmación sobre el endpoint en sí; `exposure.include` significa "este endpoint no está publicado", que es una afirmación sobre la superficie HTTP. El panel no es la superficie HTTP. + +Un endpoint que lanza se captura y se reporta como `null` en vez de dejar que se lleve la página por delante — la misma disciplina a prueba de fallos que `HealthEndpoint::readFailSafe()` aplica a los indicadores, y por la misma razón: un contribuyente roto debe degradar su propio panel, no el panel entero. + +!!! note "Los detalles de salud se leen del registro de contribuyentes, no a través del endpoint" + `show-details` vale `never` por defecto, y ese valor es el correcto — impide que un llamante HTTP anónimo aprenda el host de tu base de datos a partir de una conexión fallida. Aplicar esa política de divulgación HTTP al panel, sin embargo, producía un panel de Salud cuyo contenido entero era una disculpa que le decía al operador que fuera a cambiar una clave de configuración. El panel lee el `HealthContributorRegistry` directamente en su lugar, llamando a cada indicador de forma aislada, de modo que uno que lance se reporta DOWN con su motivo y nada más se ve afectado. + +--- + +### El grafo de beans + +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. + +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. + +#### 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. + +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 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. + +--- + +### 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á: + +```php +final readonly class AdminSettings +{ + public function __construct( + 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 + { + $base = trim($config->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')), + // ... firefly.admin.refresh-seconds (10, con suelo en 2), .theme (auto|light|dark), + // .graph.max-nodes (220) y .pages.exclude ('') tambien se leen aqui. + ); + } +} +``` + +`firefly.admin.enabled` **toma por defecto el valor de `app.debug`**. El razonamiento es que una aplicación que ya corre con debug encendido ya está sirviendo trazas de pila a quien las pida y es un entorno de desarrollo por definición, así que un panel ahí no divulga nada que no estuviera ya divulgado. Una aplicación con debug apagado ha hecho la afirmación contraria sobre sí misma, y debe optar por él explícitamente. Fijar la clave siempre gana sobre el valor por defecto de debug, en ambas direcciones — puedes apagar el panel en un entorno con debug, y encenderlo en uno de producción. + +!!! warning "Encenderlo fuera de debug es solo la mitad del trabajo" + `firefly.admin.enabled = true` con `app.debug = false` monta un panel que renderiza tu grafo de beans, tu configuración resuelta y tu tabla de rutas en una URL conocida, para cualquiera que pueda alcanzarla. El panel no trae **ninguna autenticación propia** — no tiene dependencia de código con `firefly/security` en absoluto, exactamente igual que `firefly/actuator`. Una aplicación que lo encienda fuera de debug **debe poner la ruta detrás de su propio middleware de autenticación**. + +Las reglas de `HttpSecurity` del Capítulo 10 lo hacen como pura configuración, del mismo modo que este capítulo ya aseguró el actuator — aquí tienes un despliegue que opta por el panel y le echa el candado en un solo fichero: + +```php + [ + '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'], + ], + ], + ], +]; +``` + +`AdminRouteRegistrar` monta las dos rutas — un índice y un comodín `GET|POST {base}/{page}` — como un `BootPass` en `WiringPasses`, orden **60**, un paso por detrás del 50 de `ActuatorRouteRegistrar`, porque lee el registro que puebla ese pase. Se registran nativamente sobre el `Router` de Illuminate por la misma razón que las rutas del actuator y las del paquete OpenAPI: `firefly.admin.base-path` tiene que ser fijable por aplicación, y una ruta por atributo hornea su ruta literal dentro de un `RouteDescriptor` compilado. Cuando el panel está deshabilitado el pase no registra *absolutamente nada* — no hay ruta que adivinar ni manejador al que llegar. + +También se echa atrás en silencio en un caso más, fácil de pasar por alto. Blade es necesario para renderizar el panel y no es una dependencia del paquete, así que un despliegue solo-JSON sin factoría de vistas enlazada no obtiene rutas en vez de rutas que fallarían fatalmente en la primera petición; allí la superficie de gestión sigue siendo el actuator JSON. + +!!! 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 "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. + +**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. + +### 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. + 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. --- @@ -667,6 +1014,19 @@ Por último, un puerto `Tracer` remata el paquete — una abstracción mínima d | `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 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 | --- @@ -675,3 +1035,6 @@ Por último, un puerto `Tracer` remata el paquete — una abstracción mínima d 1. **Añade un `HealthIndicator` personalizado.** Escribe un `#[Component]` que implemente `HealthIndicator` que compruebe algo específico de tu propia app (un feature flag, la profundidad de una cola, una conexión de caché) y confirma que aparece en el mapa `components` de `GET /actuator/health` una vez que `show-details` esté configurado a `always`. 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. **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 new file mode 100644 index 0000000..e2ef7eb --- /dev/null +++ b/book/src/04a-openapi.md @@ -0,0 +1,796 @@ +Part I — Foundations · Chapter 4A + +# 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. 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. + +--- + +## Nothing to annotate + +Every OpenAPI toolchain in PHP that predates this one asks you to write the document twice: once as code the server runs, and once as annotations, attributes or a YAML file that *describe* the code the server runs. The two drift the first time somebody adds a field in a hurry, and the drift is invisible — the document still validates, it just no longer matches the server. + +`firefly/openapi` does not have that failure mode available to it, because it does not have a second source. Chapter 4 ended with `RouteManifest`: the compiled table of every `RouteDescriptor` the dispatcher serves from, carrying the verb, the path, the declared status, the route name, the controller class and method, and the per-parameter *binding plan*. Chapter 4 also introduced `ConstraintManifest`: the compiled rule list `BeanValidator` runs on a `#[Valid]` body. Those two artifacts, plus `firefly/kernel`'s `ErrorResponse`, are the entire input: + +```bash +composer require firefly/openapi +``` + +That is the whole installation. Boot the app and `GET /openapi.json` is served; `GET /openapi` renders a reference console over it. Nothing was annotated, and nothing can drift, because every fact in the document is read from the same compiled artifact the dispatcher reads. + +--- + +## `firefly:openapi`, and routes that are not attribute routes + +The document is also a file you can commit: + +```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 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 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 +{ + 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'); + + // 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 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 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. + +--- + +## From `RouteManifest` to Operation Objects + +`OpenApiGenerator::generate()` is the entry point — it memoises a single private `build()` pass (`$this->document ??= $this->build()`), and `toJson()` wraps it for a file or an HTTP body. That pass walks the manifest once, skipping excluded routes, and hands each survivor to `OperationFactory`. Everything about the result is deterministic on purpose: + +```php +final class OpenApiGenerator +{ + private const array VERB_ORDER = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + + 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]; + } +} +``` + +Paths are sorted, verbs within a path are sorted into the canonical order the OpenAPI specification itself lists them in, and the schema registry sorts components by name. This is not tidiness for its own sake. 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 exactly what makes teams stop committing the generated file, which is what makes it go stale. + +Inside an operation, the binding plan does the real work. `OperationFactory` dispatches on the same `kind` discriminator `ArgumentResolver` uses at request time: + +```php +final class OperationFactory +{ + 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; + } + } + + // …the operation's own prose (operationId, summary, description, tags) is assembled here… + + 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; + } +} +``` + +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: + +- **Path template.** 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. Emitting two Path Items instead would describe a router surface that does not exist and would double every such operation in a generated client. +- **`operationId`.** The route's `name` when it has one, otherwise derived as the controller's short name minus a trailing `Controller`, lower-cased, plus the method name: `Lumen\Web\WalletController::balance()` becomes `walletBalance`. Because `operationId` must be unique across the whole document — and a duplicate is the one flaw that makes most client generators abort rather than degrade — a repeat claim is suffixed (`walletBalance_2`) rather than allowed to overwrite. +- **`tags`.** The same short name, so every `WalletController` operation groups under "Wallet" in a viewer. +- **`summary`.** Split from the method name: `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. + +--- + +## Request bodies: one component per DTO, reached by `$ref` + +Chapter 4's `OpenWalletRequest` is the running example again: + +```php +final class OpenWalletRequest +{ + public function __construct( + #[NotBlank] + public readonly string $owner_id, + #[NotBlank] + #[CurrencyCode] + public readonly string $currency, + ) {} +} +``` + +`POST /api/v1/wallets` binds it with `#[Valid] #[RequestBody]`, and the generated operation refers to it rather than repeating it: + +```json +{ + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OpenWalletRequest" } + } + } + } +} +``` + +`SchemaRegistry` is what makes that `$ref` possible, and it solves two problems a naive "inline the schema at every use site" generator has. The first is **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 pointer is what makes `openapi-generator`, `orval` and `kiota` produce *one* named type per DTO — which is the whole point of generating the document in the first place. The second is **recursion**: a DTO with a `#[Valid] ?self $parent` member cannot be inlined at all, because the expansion does not terminate. The registry therefore reserves the component name *before* invoking the builder, so a nested call for the same class finds the name already taken and returns the pointer immediately, closing the cycle. A `$ref` cycle is legal and useful in a document where a flattened rule list would be infinite. + +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 and one would silently overwrite the other, so the **second** claimant of a name falls back to its dotted fully-qualified name: ugly, unambiguous, and rare. First claimant wins, so adding a second `Address` elsewhere in the app never renames the one that was already published. + +Two properties of the emitted schema are worth stating explicitly because both are decisions rather than omissions. The member list is the **constructor's parameter list in declaration order**, because that is exactly what `ArgumentResolver` hydrates from — but a constraint keyed to a member with no constructor parameter is still documented, because `BeanValidator` validates the raw decoded array and so enforces it on input regardless. And **no `additionalProperties: false` is ever emitted**: the server genuinely ignores keys outside the constructor's list, so a spec claiming otherwise would make conforming clients reject requests the server would have served. + +--- + +## Constraints become schema keywords + +Neither half of a DTO is enough on its own. The `ConstraintManifest` knows the *validation* contract but nothing about types, because a rule list is untyped by construction. The constructor knows the *type* contract — `?int`, a backed enum, a nested DTO, a default — but nothing about the constraints. Types-only would document `#[NotBlank] string $name` as an unbounded string; constraints-only would document `int $quantity` as a string. `DtoSchemaFactory` merges them, and `ConstraintSchemaMapper` maps the second half. + +It maps the **compiled manifest**, never 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 has already applied `ConstraintScanner`'s Jakarta null contract, 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 a `toRules()` body changed. Generating from the manifest cannot drift, because the manifest *is* the contract. + +Here is the real mapping, constraint by constraint, with the compiled rules in the middle column so you can see that the mapper is reading rules and not attributes: + +| Constraint | Compiles to | JSON Schema | +|---|---|---| +| `#[NotBlank]` | `required`, `string`, `regex:/\S/` | member added to `required`; `type: string`; `pattern: \S` | +| `#[NotEmpty]` | `required` | member added to `required` | +| `#[NotNull]` | `present` + a `NotNull` rule object | member added to `required` **and** `null` removed from the type union | +| `#[Min(n)]` / `#[Max(n)]` | `numeric`, `gte:n` / `lte:n` | `minimum` / `maximum` | +| `#[Positive]` / `#[Negative]` | `numeric`, `gt:0` / `lt:0` | `exclusiveMinimum: 0` / `exclusiveMaximum: 0` | +| `#[PositiveOrZero]` / `#[NegativeOrZero]` | `numeric`, `gte:0` / `lte:0` | `minimum: 0` / `maximum: 0` | +| `#[Email]` | `email` | `type: string`, `format: email` | +| `#[Pattern(re)]` | `regex:re` | `pattern`, with the PCRE delimiters and the no-op `D`/`u` flags stripped | +| `#[Digits(i, f)]` | `numeric`, an anchored `regex:` | `type: number` + `pattern` | +| `#[AssertTrue]` / `#[AssertFalse]` | `accepted` / `declined` | `type: boolean` + `const: true` / `const: false` | +| `#[Future]` / `#[Past]` | `date`, `after:now` / `before:now` | `type: string`, `format: date-time` (the `after:now` half is recorded, not mapped) | +| `#[Size(min, max)]` | a `Size` rule object | `minLength`/`maxLength`, or `minItems`/`maxItems` when the type is an array | +| `#[UuidValue]` | a `Uuid` rule object | `format: uuid` + the UUID `pattern` | +| `#[Phone]` | an `E164` rule object | `format: phone` + `pattern: ^\+[1-9]\d{1,14}$` | +| `#[CurrencyCode]` / `#[CountryCode]` | `Currency` / `CountryCode` rule objects | `format: currency` + `^[A-Z]{3}$` / `format: country-code` + `^[A-Z]{2}$` | +| `#[LanguageTag]` / `#[PostalCode]` | rule objects | `format: bcp47` / `format: postal-code`, each with its pattern | +| `#[Iban]` / `#[Swift]` / `#[Bic]` | rule objects | `format: iban` / `swift` / `bic`, **pattern withheld** | +| `#[Cusip]` / `#[Isin]` / `#[Luhn]` / `#[RoutingNumber]` | rule objects | `format` only; the check digit is recorded, not mapped | +| `#[Percentage]` | a `Percentage` rule object | `type: number`, `minimum: 0`, `maximum: 100` | +| `#[Money]` | a `PositiveMoney` rule object | `type: number`, `exclusiveMinimum: 0`, `multipleOf: 0.01` | +| `#[DecimalScale(n)]` | a `DecimalScale` rule object | `multipleOf` — scale 2 becomes `0.01` | + +Three rows in that table repay a closer look. + +**The pattern is withheld where the rule normalises first.** `Iban` strips spaces and upper-cases before it matches; `Bic`, `Swift`, `Cusip` and `Isin` upper-case; `Luhn` and `RoutingNumber` strip separators. Publishing the post-normalisation pattern would reject payloads the server happily accepts, which is worse than under-specifying — so the `format` is emitted and the pattern is not. + +**`format` is an open vocabulary.** In JSON Schema 2020-12 an unknown `format` is an annotation, not an error. IBAN, BIC, ISIN, CUSIP and E.164 have no registered format name, so self-describing ones (`iban`, `bic`, …) are emitted rather than nothing. + +**Nullability is spelled the 3.1 way.** OpenAPI 3.1 *is* JSON Schema 2020-12, which dropped 3.0's `nullable: true` keyword in favour of a type union. A nullable member is therefore `"type": ["string", "null"]`, and an `enum` additionally gains a `null` member — widening the type alone would leave `null` failing the enumeration, making the property undocumentable-as-null in practice. + +The one rule that decides the whole merge is **first writer wins**, applied first to the declared type and then to the rules in declaration order — the same order the validator applies them in: + +```php +final class MapperState +{ + public function keyword(string $keyword, mixed $value): void + { + if (! array_key_exists($keyword, $this->schema)) { + $this->schema[$keyword] = $value; + } + } +} +``` + +Seeding the declared PHP type *before* any rule is seen is why `#[Min(1)] int $quantity` stays `type: integer` instead of being widened to `number` by the `numeric` rule string `#[Min]` emits — a widening that would wrongly document `1.5` as acceptable. + +Here is that whole pipeline on one real DTO. This is the generator's own test fixture, chosen because it deliberately spans every mapping route the generator has — a length-bounded string, a Jakarta null-contract nullable, an int with numeric bounds, a scaled decimal, a backed enum, a nested `#[Valid]` DTO, a PCRE pattern and a rule object: + +```php +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, + ) {} +} +``` + +…and this is the component it generates, verbatim: + +```json +{ + "type": "object", + "title": "CreateOrderRequest", + "properties": { + "reference": { "type": "string", "maxLength": 64, "pattern": "\\S" }, + "email": { "type": "string", "format": "email" }, + "quantity": { "type": "integer", "minimum": 1, "maximum": 999 }, + "amount": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01 }, + "currency": { "type": "string", "enum": ["EUR", "USD"] }, + "shipTo": { "$ref": "#/components/schemas/AddressPayload" }, + "coupon": { "type": ["string", "null"], "pattern": "^[A-Z]{3}-\\d{4}$" }, + "idempotencyKey": { + "type": ["string", "null"], + "format": "uuid", + "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}$" + } + }, + "required": ["reference", "email", "quantity", "amount", "currency", "shipTo"] +} +``` + +Every member of that object is traceable to something in the class above: the enum came from the backed enum's own cases, the `$ref` from `#[Valid]`, the two nullable unions from `?string`, the `multipleOf` from `#[DecimalScale(2)]`, and `required` from the presence rules — `coupon` and `idempotencyKey` are absent from it because nothing asserts their presence. + +Lumen's own DTOs show the same machinery at a smaller scale, and one of them shows a case the table above cannot: a property carrying **two** patterns. + +```json +{ + "owner_id": { "type": "string", "pattern": "\\S" }, + "currency": { + "type": "string", + "format": "currency", + "allOf": [{ "pattern": "\\S" }, { "pattern": "^[A-Z]{3}$" }] + } +} +``` + +`#[NotBlank] #[CurrencyCode] string $currency` genuinely produces two patterns — `\S` from the non-blank rule and `^[A-Z]{3}$` from the currency rule — and JSON Schema has exactly one `pattern` slot per schema object. Collapsing them by keeping the last would silently drop the non-blank guarantee, so several patterns become an `allOf` of single-pattern subschemas instead. One pattern stays a plain `pattern`; the `allOf` appears only when it has to. + +--- + +## Nothing is dropped silently + +Some constraints have no JSON Schema equivalent at all, and a few map only approximately. Dropping those quietly would produce a document that promises *less* validation than the server performs — a client would send a payload the spec calls valid and get a `422` back. The mapper's fall-through says what happens instead: + +```php +final class ConstraintSchemaMapper +{ + private function applyObject(MapperState $state, ValidationRule $rule): void + { + switch (true) { + // ... every recognised first-party rule object is matched above. + 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); + } + } +} +``` + +Everything unrecognised — `after:now` and `before:now`, `exists:` and `unique:`, a bare Luhn or CUSIP check digit, a bound that names another field (`gte:other_field`) rather than a number, and any third-party `ValidationRule` — is recorded under `x-firefly-constraints`. So is a pattern the mapper could only approximate: `D` and `u` are dropped as genuine no-ops, but 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, so a reader can see that the published pattern is stricter than the server's. + +Three properties, three outcomes: + +```json +{ + "deliverAfter": { "type": "string", "format": "date-time", "x-firefly-constraints": ["after:now"] }, + "slug": { "type": "string", "pattern": "^[a-z]+$", "x-firefly-constraints": ["regex:/^[a-z]+$/i"] }, + "account": { "type": "string", "format": "iban", "x-firefly-constraints": ["iban:checksum"] } +} +``` + +Conforming tools ignore all three extensions and see a valid document. A human, or a generator you write yourself, can read the full truth. + +--- + +## Responses: one problem component, and an error set that is derived + +Every operation ends at the same error component, and that component describes what LaraFly *actually* returns rather than what RFC 9457 describes in the abstract: + +```php +final class ProblemSchema +{ + public const string MEDIA_TYPE = 'application/problem+json'; + + public static function response(): array + { + return [ + 'description' => 'Error response in RFC 9457 problem+json form.', + 'content' => [self::MEDIA_TYPE => ['schema' => ['$ref' => self::REF]]], + ]; + } +} +``` + +The distinction matters because the two shapes differ. Chapter 4's `ErrorResponse::toArray()` emits `status`, `title`, `code`, `category` and `severity` unconditionally, then `detail`/`type`/`instance`/`traceId`/`timestamp` only when non-null, then `errors` only when non-empty. So `code`, `category`, `severity` and `errors` are Firefly members on top of the RFC's five; `type` is optional here where the RFC gives it a default; and `instance` carries a request *path* rather than a URI reference. Documenting the RFC's shape instead of this one would hand every generated client a decoder that silently drops the three members a caller actually branches on. + +The two enumerations are read straight off the kernel's own enums, so a case added in `firefly/kernel` appears in the spec on the next generation with no edit anywhere in `firefly/openapi`: + +```json +{ + "category": { + "type": "string", + "enum": ["business", "validation", "security", "infrastructure", + "external", "framework", "plugin", "internal"] + }, + "severity": { "type": "string", "enum": ["info", "warning", "error", "critical"] } +} +``` + +Which statuses an operation lists is **derived, not guessed**. Compare two real Lumen operations. `GET /api/v1/wallets/{id}/balance` takes one `string` path variable and no body: + +```json +{ + "200": { "description": "Successful response.", + "content": { "application/json": { "schema": { "type": "object" } } } }, + "default": { "$ref": "#/components/responses/Problem" } +} +``` + +`POST /api/v1/wallets/{id}/deposit` takes the same path variable plus a `#[Valid] #[RequestBody] AmountRequest`: + +```json +{ + "200": { "description": "Successful response.", + "content": { "application/json": { "schema": { "type": "object" } } } }, + "400": { "$ref": "#/components/responses/Problem" }, + "422": { "$ref": "#/components/responses/Problem" }, + "default": { "$ref": "#/components/responses/Problem" } +} +``` + +`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. + +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. + +--- + +## `#[Controller]` HTML routes are not operations + +Chapter 4 introduced `#[RestController]` alongside its HTML sibling `#[Controller]`, and only the first is a JSON API. The generator honours that distinction by default: + +```php +final class OpenApiGenerator +{ + 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; + } +} +``` + +A `#[Controller]` route renders a page. It is part of the application's HTTP surface, but it is not a JSON operation, and describing one as `application/json` would have a generator emit a typed client for a response that is a web page — the framework's own welcome page was in the spec exactly that way before this rule existed. Set `firefly.openapi.include-html` to `true` and the route is documented anyway, but honestly: the operation is then produced with `text/html` content and a `type: string` schema rather than a JSON schema that would be a lie a client generator faithfully acts on. + +The second half of that method is the blunt instrument for everything else: `firefly.openapi.exclude` is a CSV of path prefixes — `'/internal,/admin'` — for routes that are JSON but are nobody's public API. + +--- + +## 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. + +!!! 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. + +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. + +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' => ['style' => 'cdn']], +]; +``` + +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. + +--- + +## Configuration, and securing the surface + +Everything the document and its routes need lives under one config key: + +```php + [ + 'enabled' => true, // master gate: off means every route is genuinely unrouted + 'path' => '/openapi.json', // spec route + 'viewer' => [ + 'enabled' => true, + '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 + ], +]; +``` + +`servers` accepts both spellings a real config file uses — a list of bare URL strings, and OpenAPI's own object form with a `description` — and an entry that is neither is dropped rather than emitted, because a Server Object with no `url` is invalid under the 3.1 schema and would poison an otherwise-good document. + +A public deployment is secured the way any other route is. Chapter 10's `HttpSecurity` rules — ahead, in Part III — cover the spec and viewer paths with no code edge at all, because `HttpSecurityFilter` is a global middleware and runs for natively-registered routes exactly as it runs for your controllers: + +```php + [ + '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 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. + +--- + +## Replacing one piece of the pipeline + +Every collaborator in the package — `OpenApiProperties`, `ConstraintSchemaMapper`, `DtoSchemaFactory`, `OperationFactory`, `OpenApiGenerator` and `ViewerPage` — is a `#[Bean]` behind `#[ConditionalOnMissingBean]`, the Chapter 2 mechanism. Teaching the generator about your own `ValidationRule` is therefore a short `#[Configuration]` in your application and never a fork: + +```php +#[Configuration] +final class ApiDocsConfiguration +{ + #[Bean] + public function constraintSchemaMapper(): ConstraintSchemaMapper + { + return new HouseConstraintSchemaMapper; // teaches the generator your own ValidationRules + } +} +``` + +!!! note "The reflection here is real, and it is not on the request path" + `DtoSchemaFactory` reflects a DTO's constructor to learn its property types, which looks like a violation of the rule Chapter 13 will state in full: nothing on the cached request path reflects. It is not one. That reflection 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. The alternative, teaching `RouteScanner` to emit per-property types into every `RouteDescriptor`, was rejected because it would grow the compiled route manifest of *every* app for the benefit of one optional package. + +!!! laravel "Laravel parity" + Stock Laravel ships no OpenAPI support. The usual answers are a third-party package driven by its own annotation dialect (`zircote/swagger-php`'s `@OA\` blocks, or `vyuldashev/laravel-openapi`'s attribute classes) or a hand-maintained YAML file — both of which are a *second* description of the API, sitting beside the routes and the FormRequests that actually enforce it, and both of which drift. `firefly/openapi` has no dialect to learn because it has no second description: it reads the same `RouteManifest` the dispatcher dispatches from and the same `ConstraintManifest` the validator validates with. The closest analogue outside PHP is springdoc-openapi, and the closest thing in Laravel itself is `php artisan route:list` — accurate for the same reason, and for the same reason unable to tell you anything about a request body. + +--- + +## What you learned {.recap} + +| Concept | What it does | +|---|---| +| `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`, `/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 | +| `ConstraintSchemaMapper` | Maps the compiled rule list — not the attributes — to keywords; first writer wins, so `#[Min(1)] int` stays `integer` | +| `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 | +| `ViewerPage::render()` | Falls back to `builtin` when the Swagger dist is absent, rather than rendering a console whose assets 404 | + +--- + +## Try it yourself {.exercises} + +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. **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 a4518b4..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. +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. @@ -378,7 +378,7 @@ return [ `firefly/actuator`'s own `composer.json` has no dependency on `firefly/security` at all — securing the actuator this way is **pure configuration**, drawing on the same deny-by-default URL DSL Chapter 10 already taught you, with no new mechanism to learn. !!! warning "Every other management endpoint is a standalone endpoint, not an `/info` sub-key" - `/actuator/info` genuinely has exactly two fragments — `app` (from `AppInfoContributor`, read from `firefly.management.info.app.*`) and `build` (from `BuildInfoContributor`, reading a JSON file at `firefly.management.info.build.path`). `env`, `beans`, `conditions`, `mappings`, `loggers`, and `scheduledtasks` are each their **own** `ActuatorEndpoint`, mounted at their own `/actuator/{id}` — not nested under `/info`. `firefly:about` (Chapter 13) renders several of these together at the terminal, which is a convenience of that one command, not evidence they share a route. + `/actuator/info` genuinely has exactly three fragments — `runtime` (from `RuntimeInfoContributor`, registered by default, which is why a freshly generated application already answers something: PHP version/SAPI/OPcache, Laravel version, LaraFly version, current and peak memory; turn it off with `firefly.management.info.runtime.enabled = false`), `app` (from `AppInfoContributor`, read from `firefly.management.info.app.*`) and `build` (from `BuildInfoContributor`, reading a JSON file at `firefly.management.info.build.path`). `env`, `beans`, `conditions`, `mappings`, `loggers`, `scheduledtasks`, `configprops`, and `caches` are each their **own** `ActuatorEndpoint`, mounted at their own `/actuator/{id}` — not nested under `/info`. `firefly:about` (Chapter 13) renders several of these together at the terminal, which is a convenience of that one command, not evidence they share a route. --- @@ -648,8 +648,355 @@ Every observability bean gates on the **same** property, `firefly.observability. Finally, a `Tracer` port rounds out the package — a minimal `trace(string $name, callable $callback): mixed` span abstraction, shipped today only as `NoOpTracer` (it simply runs the callback), written against the interface so an OpenTelemetry-backed adapter can drop in later with zero call-site changes — the identical "port now, adapter later" shape you have now seen for `CqrsMetrics` itself. +--- + +## 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 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 +``` + +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. + +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 | +|---|---|---|---| +| Runtime | Overview | several | Is it healthy, what is it doing, and what did it wire? | +| Runtime | Health | `health` | Every indicator this process registered, with its own status and details | +| 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 | +| Configuration | Environment | `env` | Resolved `firefly.*` configuration, with secrets masked | +| Configuration | Config properties | `configprops` | Every `#[ConfigProperties]` DTO the application bound, with the values it resolved | +| Configuration | Caches | `caches` | The cache stores this application has configured | +| Configuration | 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. That matters because the actuator's endpoints are conditional: `metrics` disappears when `firefly.observability.metrics.enabled` is false, and several others exist only if the package that contributes them is installed. The menu has to be built from what this process actually registered, so it is. + +--- + +### It reads endpoints in-process, not over HTTP + +The dashboard holds the `ActuatorRegistry` and invokes each `ActuatorEndpoint` bean directly: + +```php +final readonly class AdminEndpointReader +{ + public function __construct( + private ActuatorRegistry $registry, + private Config $config, + private ?Container $container = null, + ) {} + + /** The endpoint ids that are registered AND not switched off, in registration order. */ + 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 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; + } +} +``` + +Look at what is **not** in that method: any mention of `ExposureModel`. That is the single most important thing to understand about this package, and it is deliberate. `firefly.management.endpoints.web.exposure.include` defaults to `health,info`, so fetching `/actuator/beans` or `/actuator/env` over HTTP would 404 — as the whole first half of this chapter insisted it should. The dashboard needs none of that. It renders what the process already knows, in-process, so **it shows you 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 point: `firefly.management.endpoint.{id}.enabled` means "this endpoint is off", which is a statement about the endpoint itself; `exposure.include` means "this endpoint is unpublished", which is a statement about the HTTP surface. 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::readFailSafe()` applies to indicators, for the same reason: one broken contributor should degrade its own panel, not the dashboard. + +!!! note "Health details are read from the contributor registry, not through the endpoint" + `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 reason and nothing else is affected. + +--- + +### The bean graph + +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. + +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. + +#### 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. + +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 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. + +--- + +### 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: + +```php +final readonly class AdminSettings +{ + public function __construct( + public bool $enabled, + public string $basePath, + public string $title, + // ... plus the presentation options: refreshSeconds, theme, graphMaxNodes, excludedPages. + ) {} + + public static function fromConfig(Config $config): self + { + $base = trim($config->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')), + // ... firefly.admin.refresh-seconds (10, floored at 2), .theme (auto|light|dark), + // .graph.max-nodes (220) and .pages.exclude ('') are read here too. + ); + } +} +``` + +`firefly.admin.enabled` **defaults to the value of `app.debug`**. The reasoning is that 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 turns it on outside debug **must put the route behind its own auth middleware**. + +Chapter 10's `HttpSecurity` rules do that as pure configuration, the same way this chapter already secured the actuator — here is a deployment that opts in and locks the route down in one file: + +```php + [ + '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'], + ], + ], + ], +]; +``` + +`AdminRouteRegistrar` mounts the two routes — an index and a `GET|POST {base}/{page}` catch-all — as a `BootPass` at `WiringPasses`, order **60**, one step after `ActuatorRouteRegistrar`'s 50, because it reads the registry that pass populates. They are registered natively on the Illuminate `Router` for the same reason the actuator's and the OpenAPI package's routes are: `firefly.admin.base-path` has to be settable per application, and an attribute route bakes its literal path into a compiled `RouteDescriptor`. When the dashboard is disabled the pass registers *nothing at all* — there is no route to guess at and no handler to reach. + +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 factory bound gets no routes rather than routes that would fatal on first request; the JSON actuator remains the management surface there. + +!!! 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 "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. + +**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. + +### 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. + 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. --- @@ -667,6 +1014,19 @@ Finally, a `Tracer` port rounds out the package — a minimal `trace(string $nam | `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 | +| `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 | --- @@ -675,3 +1035,6 @@ Finally, a `Tracer` port rounds out the package — a minimal `trace(string $nam 1. **Add a custom `HealthIndicator`.** Write a `#[Component]` implementing `HealthIndicator` that checks something specific to your own app (a feature flag, a queue depth, a cache connection) and confirm it appears in `GET /actuator/health`'s `components` map once `show-details` is set to `always`. 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. **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/composer.json b/composer.json index d4225c0..88b5159 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "firefly/monorepo", - "description": "LaraFly — the Firefly Framework for PHP. Development monorepo.", + "description": "LaraFly \u2014 the Firefly Framework for PHP. Development monorepo.", "type": "project", "license": "Apache-2.0", "require": { @@ -17,15 +17,18 @@ "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", "firefly/observability": "*@dev", + "firefly/openapi": "*@dev", "firefly/resilience": "*@dev", "firefly/scheduling": "*@dev", "firefly/scheduling-postgres": "*@dev", "firefly/security": "*@dev", "firefly/validation": "*@dev", - "firefly/web": "*@dev" + "firefly/web": "*@dev", + "swagger-api/swagger-ui": "^5.17" }, "require-dev": { "deptrac/deptrac": "^4.6", + "firefly/admin": "*@dev", "firefly/cli": "*@dev", "firefly/eda-kafka": "*@dev", "firefly/eda-postgres": "*@dev", @@ -37,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", @@ -46,37 +49,51 @@ }, "autoload-dev": { "psr-4": { - "Firefly\\Tests\\": "tests/", "Firefly\\Actuator\\Tests\\": "packages/actuator/tests/", + "Firefly\\Admin\\Tests\\": "packages/admin/tests/", "Firefly\\AutoConfigure\\Tests\\": "packages/autoconfigure/tests/", + "Firefly\\Cli\\Tests\\": "packages/cli/tests/", "Firefly\\Config\\Tests\\": "packages/config/tests/", "Firefly\\Container\\Tests\\": "packages/container/tests/", "Firefly\\Context\\Tests\\": "packages/context/tests/", - "Firefly\\Cli\\Tests\\": "packages/cli/tests/", "Firefly\\Cqrs\\Tests\\": "packages/cqrs/tests/", - "Firefly\\Firefly\\Tests\\": "packages/firefly/tests/", "Firefly\\Data\\Tests\\": "packages/data/tests/", "Firefly\\Domain\\Tests\\": "packages/domain/tests/", - "Firefly\\Eda\\Tests\\": "packages/eda/tests/", "Firefly\\Eda\\Kafka\\Tests\\": "packages/eda-kafka/tests/", "Firefly\\Eda\\Postgres\\Tests\\": "packages/eda-postgres/tests/", "Firefly\\Eda\\Rabbitmq\\Tests\\": "packages/eda-rabbitmq/tests/", + "Firefly\\Eda\\Tests\\": "packages/eda/tests/", + "Firefly\\Firefly\\Tests\\": "packages/firefly/tests/", "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/", "Firefly\\Security\\Tests\\": "packages/security/tests/", "Firefly\\Testing\\Tests\\": "packages/testing/tests/", + "Firefly\\Tests\\": "tests/", "Firefly\\Validation\\Tests\\": "packages/validation/tests/", "Firefly\\Web\\Tests\\": "packages/web/tests/", "Lumen\\Tests\\": "samples/lumen/tests/" } }, "repositories": [ - { "type": "path", "url": "packages/*", "options": { "symlink": true } }, - { "type": "path", "url": "samples/*", "options": { "symlink": true } } + { + "type": "path", + "url": "packages/*", + "options": { + "symlink": true + } + }, + { + "type": "path", + "url": "samples/*", + "options": { + "symlink": true + } + } ], "minimum-stability": "stable", "prefer-stable": true, @@ -93,9 +110,14 @@ "pint": "pint", "pint-test": "pint --test", "stan": "phpstan analyse --no-progress --memory-limit=1G", - "test": "pest", + "test": "@php -d memory_limit=512M vendor/bin/pest", "deptrac": "deptrac analyse --no-progress --config-file=deptrac.yaml", "mono-validate": "monorepo-builder validate", - "check": [ "@pint-test", "@stan", "@test", "@deptrac" ] + "check": [ + "@pint-test", + "@stan", + "@test", + "@deptrac" + ] } } diff --git a/deptrac.yaml b/deptrac.yaml index 78ae9f8..a9dbd73 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -86,6 +86,14 @@ deptrac: collectors: - type: directory value: packages/observability/src/.* + - name: Admin + collectors: + - type: directory + value: packages/admin/src/.* + - name: OpenApi + collectors: + - type: directory + value: packages/openapi/src/.* - name: Testing collectors: - type: directory @@ -328,6 +336,49 @@ deptrac: - Cqrs - Resilience + # Admin is the browser front-end for Actuator: it renders the SAME ActuatorEndpoint beans the JSON surface + # serves, resolved in-process from Actuator's registry rather than fetched over HTTP (=> Actuator), mounts + # 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 + - Config + - Context + - 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/ + # 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..8ad037e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,8 +44,9 @@ 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 | +| [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,9 @@ 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 — 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, with filtering, paging, relations you can walk, and an entity map | ### Testing @@ -113,7 +117,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 +135,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. @@ -139,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/cli.md b/docs/cli.md index 8395ede..ef66372 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 @@ -139,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/getting-started.md b/docs/getting-started.md index a8b3e44..a7cf947 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 @@ -13,26 +13,29 @@ 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` 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. +`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. + +`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 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: @@ -56,10 +59,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/index.md b/docs/index.md index 26d0d39..418bf12 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,13 @@ 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), and an + 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. - **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 +64,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) · [Data Browser](modules/data-browser.md) | | **Testing** | [Testing](modules/testing.md) · [Integration Testing](modules/integration-testing.md) | | **Tooling** | [Installer](modules/installer.md) | @@ -76,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/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..cec9a23 100644 --- a/docs/modules/actuator.md +++ b/docs/modules/actuator.md @@ -9,10 +9,19 @@ always-on, and secured entirely by M11 config with zero code edge to `firefly/se - `/actuator` — HAL index of exposed endpoints - `/actuator/health` (+ `/actuator/health/{group}`, liveness/readiness) — aggregated health, 503 on DOWN -- `/actuator/info` — merged `InfoContributor` fragments (`app`, `build`) +- `/actuator/info` — merged `InfoContributor` fragments (`runtime`, `app`, `build`) - `/actuator/env` — the `firefly.*` config tree, sensitive values masked +- `/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 @@ -23,7 +32,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' => [ @@ -52,20 +62,23 @@ end-to-end: with the lockdown rules above and `env` exposed, an anonymous `GET / (`AuthenticationException`, no matching rule's expression is satisfied) while `GET /actuator/health` stays **200** (`permitAll`). -`/actuator/env` additionally masks any key matching `password|secret|token|key|credential|passwd` (case-insensitive, -recursive) with `******`, independent of whether the URL lockdown above is configured — defense in depth for an +`/actuator/env` and `/actuator/configprops` additionally mask any key matching `password|secret|token|key|credential|passwd` +(case-insensitive, recursive — the shared `SensitiveValueMasker`) with `******`. The key is tested **before** the value's +type, so a sensitive key holding an array (a JWT keyring, a credentials pair) is replaced wholesale rather than recursed +into, independent of whether the URL lockdown above is configured — defense in depth for an 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` +- `firefly.management.info.runtime.enabled` (default `true`, `#[ConditionalOnProperty(matchIfMissing: true)]`) — the `runtime` fragment of `/actuator/info` (PHP version/SAPI/OPcache, Laravel version, LaraFly version, current+peak memory). Setting it `false` removes the contributor bean entirely. ## Laravel comparison @@ -75,5 +88,5 @@ URL generation, the HTTP-kernel middleware pipeline) — not app controllers. He ## Known-latent - `when-authorized` show-details degrades to `never` (no Security code edge; gate details via the lockdown). -- `/httpexchanges`, `/caches`, `/configprops`, `/refresh`, `/threaddump`, `/shutdown` are deferred to later SP cycles. +- `/refresh`, `/threaddump`, `/shutdown` are deferred to later SP cycles. `/caches` is read-only: Spring's `DELETE` eviction is deliberately not implemented, because `firefly/actuator` carries no code edge to `firefly/security` and so cannot say who asked; a `POST` to it answers 404. - No second management port (an Octane second-listener is an SP-7 option). diff --git a/docs/modules/admin.md b/docs/modules/admin.md new file mode 100644 index 0000000..c4cb463 --- /dev/null +++ b/docs/modules/admin.md @@ -0,0 +1,385 @@ +# 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. + +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`. **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 + 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 pages + +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 | +|---|---|---|---|---| +| 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 | +| 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 +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. + +### 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 +`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. | +| `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 | + +## 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, 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. + +--- + +See also: [Actuator](actuator.md) for the endpoints themselves, [Observability](observability.md) for the metrics +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 new file mode 100644 index 0000000..4f31f29 --- /dev/null +++ b/docs/modules/bean-graph.md @@ -0,0 +1,199 @@ +# 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 # already required by firefly/firefly; the graph is a page of the dashboard, not a package of its own +``` + +## What counts as a node + +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 +/** + * 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 = [], +``` + +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 + +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. + +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. + +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. + +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 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 + +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. + +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 + +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. + +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. + +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, 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 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. + +## 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 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 + +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. +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 + +- **`#[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. + +--- + +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/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<string> ``` -`#[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 `<env>` 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/data-browser.md b/docs/modules/data-browser.md new file mode 100644 index 0000000..80a1d78 --- /dev/null +++ b/docs/modules/data-browser.md @@ -0,0 +1,438 @@ +# 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. + +!!! 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 +``` + +`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 exists for Eloquent, and is refused for everything else + +The browser once had no `create()` at all, and the argument for that was half right. + +**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.** + +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. + +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 + +| # | 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`, `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. + + `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 + +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. + +## 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<OrderLineEntity, $this> */ + 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 publishes **minus the masked ones**: + +| 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 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. + +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 +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 — 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. | +| `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. + +## 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 + +- **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, 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. + +--- + +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..d4426c2 100644 --- a/docs/modules/data.md +++ b/docs/modules/data.md @@ -252,3 +252,17 @@ 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 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. 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\<string\> | `[]` | 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/error-handling.md b/docs/modules/error-handling.md index d7cfab6..6aa58bf 100644 --- a/docs/modules/error-handling.md +++ b/docs/modules/error-handling.md @@ -80,9 +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, -whenever the request expects JSON (`$request->expectsJson()`). +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: @@ -96,6 +114,68 @@ 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 + +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/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..b02964e 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 @@ -94,11 +133,46 @@ flip the one flag, and the `MeterRegistry` bean, the CqrsMetrics winner, and eve adapter can drop in at SP-7 with zero call-site changes — the same "port now, adapter later" shape as the CqrsMetrics seam above. +## HTTP exchanges and the process endpoint + +`/actuator/httpexchanges` serves the last N requests this application answered, newest first, and +`/actuator/process` serves the live process numbers (pid, uptime, PHP version/SAPI, memory, OPcache) beside the +request counter the same recorder already keeps. Neither is in the secure-by-default exposure list +(`health,info`), so reaching either over HTTP means naming it in +`firefly.management.endpoints.web.exposure.include`. + +Recording is done by `HttpExchangeFilter` — a `#[Component]` `WebFilter` discovered by web's +`FilterChainRegistrar`, `#[Order(-100)]`, `#[Lazy]`, gated on its **own** +`firefly.observability.httpexchanges.enabled` rather than on the metrics flag. Each row carries +`timestamp`/`method`/`uri`/`status`/`durationMs`/`correlationId`; `uri` is the **route template** where a route +matched, and otherwise the raw path with the query string dropped, capped at 256 characters. **No request or +response body is ever retained, and there is no flag to enable one.** Headers are off by default; switching +`include-headers` on adds a `requestHeaders` object whose credential-bearing entries (`authorization`, `cookie`, +`proxy-authorization`, anything matching `password|secret|token|key|credential|passwd|authenticate`) are replaced +with `******` by `HeaderMasker`. + +`HttpExchangeRecorder` is a port with the same two implementations, and the same reason for them, as +`MeterRegistry`: `InMemoryHttpExchangeRecorder` (default, correct only on a long-lived worker) and +`CacheHttpExchangeRecorder`. Under PHP-FPM the in-memory buffer 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. The payload therefore reports `storage` (`memory` or `cache:<store>`), `processLocal`, +`recording`, `capacity`, `recorded` (monotonic, so `recorded - count` is what the ring has evicted) and `count`, +so an empty list can be told apart from a broken one. `?limit=N` trims the list; a malformed limit is ignored +rather than answered with a `400`. + ## Configuration (`firefly.observability.*`, kebab-case) | 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.observability.httpexchanges.enabled` | `true` | Gates `HttpExchangeFilter` — i.e. whether anything is recorded. Compared as the literal string `true` by `#[ConditionalOnProperty]`, so `1`/`on`/`yes` count as OFF. The endpoints stay mounted either way and report `"recording": false`. Independent of the metrics gate. | +| `firefly.observability.httpexchanges.capacity` | `100` | Ring size, clamped to `[1, 10000]`. | +| `firefly.observability.httpexchanges.store` | `''` | Names a **cache store**. Empty (or no `cache` binding) → the process-local `InMemoryHttpExchangeRecorder`; a store name → `CacheHttpExchangeRecorder` over `cache()->store($name)`, keyed under `firefly:httpexchanges:`, which is what makes the buffer non-empty under PHP-FPM. | +| `firefly.observability.httpexchanges.ttl` | `0` | Expiry in seconds for each cache-backed row. `0` or less means no expiry. Only consulted when `store` is set. | +| `firefly.observability.httpexchanges.include-headers` | `false` | Adds masked request headers to each row. Bodies are never recorded, with or without this. | +| `firefly.observability.httpexchanges.exclude` | `[<management base path>, <management base path>/*]` | Glob patterns whose requests are not recorded. The default keeps a polling dashboard from evicting real traffic from its own ring; setting it **replaces** the default rather than adding to it. | | `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 +191,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/openapi.md b/docs/modules/openapi.md new file mode 100644 index 0000000..0623b85 --- /dev/null +++ b/docs/modules/openapi.md @@ -0,0 +1,778 @@ +# 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**. + +`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 +``` + +`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 + +| 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. 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 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 +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`. + +## 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<X>` 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<Order>} + */ +#[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<string, mixed>` 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>`, `Order[]`, `array<int, Order>` | `type: array` with `items: {$ref: Order}` | +| `array<string, Money>` | `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<string, mixed>` 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<Shipment>')] +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 +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 | + +### 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<X>` once was: + +| Written | Was | Is | +|---|---|---| +| `list<string> $tags` | `type: array` — `Array<any>` again | `items: {type: string}` | +| `list<list<int>> $matrix` | `type: array` | nested `items` | +| `array<string, int> $meta` | `type: array` — **the wrong JSON type** | `type: object` with `additionalProperties` | + +The third is the one that mattered. `array<string, int>` 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<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 +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()` +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` + +```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.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 +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` 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. +- **`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/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\<class-string\<Throwable\>\> | `[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\<class-string\<Throwable\>\> | `[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:<pattern>:<name>`, 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:<ROLE>` | `hasRole('<ROLE>')` | +| `hasAuthority:<AUTHORITY>` | `hasAuthority('<AUTHORITY>')` | + +**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/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/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/docs/versioning.md b/docs/versioning.md index 64588ba..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.16'; + public const string VERSION = '26.09.1'; } ``` @@ -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.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/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 7d2124f..34f7d98 100644 --- a/packages/actuator/cache/firefly-actuator-components.php +++ b/packages/actuator/cache/firefly-actuator-components.php @@ -24,9 +24,38 @@ 'primary' => false, 'order' => 0, 'lazy' => false, + 'dependencies' => [ + 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' => [ + ], ], 1 => [ 'class' => 'Firefly\\Actuator\\Health\\DbHealthIndicator', @@ -42,6 +71,9 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Database\\ConnectionResolverInterface', + ], ], 2 => [ 'class' => 'Firefly\\Actuator\\Health\\DiskSpaceHealthIndicator', @@ -57,6 +89,9 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 3 => [ 'class' => 'Firefly\\Actuator\\Health\\HealthEndpoint', @@ -72,6 +107,11 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Actuator\\Health\\HealthContributorRegistry', + 1 => 'Firefly\\Actuator\\Health\\StatusAggregator', + 2 => 'Firefly\\Config\\Config', + ], ], 4 => [ 'class' => 'Firefly\\Actuator\\Health\\PingHealthIndicator', @@ -87,6 +127,8 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + ], ], 5 => [ 'class' => 'Firefly\\Actuator\\Info\\AppInfoContributor', @@ -102,6 +144,9 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 6 => [ 'class' => 'Firefly\\Actuator\\Info\\BuildInfoContributor', @@ -117,6 +162,9 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], ], 7 => [ 'class' => 'Firefly\\Actuator\\Info\\InfoEndpoint', @@ -132,8 +180,28 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Actuator\\Info\\InfoContributorRegistry', + ], ], 8 => [ + 'class' => 'Firefly\\Actuator\\Info\\RuntimeInfoContributor', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Info\\InfoContributor', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], + 9 => [ 'class' => 'Firefly\\Actuator\\Introspection\\BeansEndpoint', 'stereotype' => 'component', 'name' => null, @@ -147,8 +215,29 @@ 'beans' => [ ], 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Actuator\\Introspection\\BeansCatalog', + ], ], - 9 => [ + 10 => [ + 'class' => 'Firefly\\Actuator\\Introspection\\CachesEndpoint', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Endpoint\\ActuatorEndpoint', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Contracts\\Config\\Repository', + ], + ], + 11 => [ 'class' => 'Firefly\\Actuator\\Introspection\\ConditionsEndpoint', 'stereotype' => 'component', 'name' => null, @@ -162,8 +251,29 @@ 'beans' => [ ], 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Context\\Condition\\ConditionEvaluationReport', + ], ], - 10 => [ + 12 => [ + 'class' => 'Firefly\\Actuator\\Introspection\\ConfigPropsEndpoint', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Endpoint\\ActuatorEndpoint', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Contracts\\Container\\Container', + ], + ], + 13 => [ 'class' => 'Firefly\\Actuator\\Introspection\\EnvEndpoint', 'stereotype' => 'component', 'name' => null, @@ -177,8 +287,11 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Contracts\\Config\\Repository', + ], ], - 11 => [ + 14 => [ 'class' => 'Firefly\\Actuator\\Introspection\\LoggersEndpoint', 'stereotype' => 'component', 'name' => null, @@ -192,8 +305,12 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Log\\LogManager', + 1 => 'Illuminate\\Contracts\\Config\\Repository', + ], ], - 12 => [ + 15 => [ 'class' => 'Firefly\\Actuator\\Introspection\\MappingsEndpoint', 'stereotype' => 'component', 'name' => null, @@ -207,8 +324,11 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Web\\Route\\RouteManifest', + ], ], - 13 => [ + 16 => [ 'class' => 'Firefly\\Actuator\\Introspection\\ScheduledTasksEndpoint', 'stereotype' => 'component', 'name' => null, @@ -222,5 +342,8 @@ 'beans' => [ ], 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Scheduling\\Schedule\\ScheduledManifest', + ], ], ]; diff --git a/packages/actuator/cache/firefly-actuator-context.php b/packages/actuator/cache/firefly-actuator-context.php index c2e4d62..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 => [ @@ -51,6 +73,27 @@ ], ], 2 => [ + 'class' => 'Firefly\\Actuator\\Info\\RuntimeInfoContributor', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.management.info.runtime.enabled', + 1 => 'true', + 2 => true, + ], + ], + ], + 'beanConditions' => [ + ], + ], + 3 => [ 'class' => 'Firefly\\Actuator\\Introspection\\ScheduledTasksEndpoint', 'postConstruct' => [ ], 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 9fbebd4..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'); @@ -86,6 +107,18 @@ private function beansCatalog(BootContext $context): BeansCatalog 'name' => $descriptor->name, 'interfaces' => $descriptor->interfaces, 'beans' => array_map(static fn ($bean): string => $bean->method, $descriptor->beans), + // A #[Configuration]'s own edges are the union of its constructor's and every #[Bean] + // factory method's parameters: that is where a framework's wiring actually lives, and a + // graph built from constructors alone draws almost nothing. + 'produces' => array_map(static fn ($bean): array => [ + 'type' => $bean->returns, + 'method' => $bean->method, + 'dependencies' => $bean->dependencies, + ], $descriptor->beans), + // The bean graph's edges. Recorded by ComponentScanner at scan time — answering "what + // depends on what" by reflecting at request time would break the reflection-free boot + // contract, so the wiring is compiled like everything else. + 'dependencies' => $descriptor->dependencies, ]; } 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/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/Info/RuntimeInfoContributor.php b/packages/actuator/src/Info/RuntimeInfoContributor.php new file mode 100644 index 0000000..efe91ae --- /dev/null +++ b/packages/actuator/src/Info/RuntimeInfoContributor.php @@ -0,0 +1,119 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Info; + +use Firefly\Container\Attributes\Component; +use Firefly\Context\Condition\Attributes\ConditionalOnProperty; +use Firefly\Kernel\Version; +use Illuminate\Foundation\Application as LaravelApplication; + +/** + * Surfaces the process's own runtime facts under the `runtime` key, so /actuator/info says something on a + * fresh application. + * + * WHY THIS EXISTS. The two contributors that shipped before it both read something the application had to + * author first: AppInfoContributor renders `firefly.management.info.app.*` (absent until somebody writes it) + * and BuildInfoContributor reads a generated firefly-build.json (absent until a release pipeline emits one). + * On a skeleton neither fires, so /actuator/info answered `{}` — a 200 with nothing in it, which the admin + * dashboard could only render as an apology telling the operator to go and configure something. That is the + * wrong default for the endpoint an operator hits FIRST when they want to know what is actually running. This + * contributor needs no configuration at all, because everything it publishes is already true of the process. + * + * WHAT IT PUBLISHES, AND WHY EACH FACT IS SAFE. PHP version, Laravel version, LaraFly version, SAPI, whether + * OPcache is on, and current/peak memory. Every one of them is a property of the runtime rather than of the + * application's data or configuration: none names a host, a credential, a path or a customer. They are also + * the six facts that actually get asked for in an incident — "which PHP is that box on", "is OPcache even + * enabled on the workers", "is this the release we think it is", "how close to the memory limit are we". + * Deliberately NOT included: loaded extensions and ini settings (a fingerprint of exploitable versions), + * anything from $_ENV or $_SERVER, and the application path. + * + * DEFAULT ON, WITH A SWITCH. `firefly.management.info.runtime.enabled` turns it off; matchIfMissing means an + * application that has never heard of the key gets the data. Version numbers are a mild fingerprint, so an + * application that publishes /actuator/info to the open internet may reasonably want it gone — and turning it + * off REMOVES the bean (the condition is evaluated at boot, and InfoContributorRegistrar only ever sees + * definitions that survived condition filtering), rather than registering a contributor that returns []. + * + * REGISTRATION. Nothing registers this explicitly: it is a #[Component] implementing InfoContributor, which + * is exactly what InfoContributorRegistrar (BootPhase::WiringPasses, order 20) discovers and hands to + * InfoContributorRegistry — the same path AppInfoContributor and BuildInfoContributor take. No #[Lazy]: it has + * no constructor dependencies at all, so EagerSingletonsPass resolving it at phase 900 is free and harmless. + */ +#[Component] +#[ConditionalOnProperty(name: 'firefly.management.info.runtime.enabled', havingValue: 'true', matchIfMissing: true)] +final class RuntimeInfoContributor implements InfoContributor +{ + /** + * @return array<string, mixed> + */ + public function info(): array + { + return [ + 'runtime' => [ + 'php' => [ + 'version' => PHP_VERSION, + 'sapi' => PHP_SAPI, + 'opcache' => $this->opcacheEnabled(), + ], + 'laravel' => [ + 'version' => $this->laravelVersion(), + ], + 'firefly' => [ + 'version' => Version::VERSION, + ], + // memory_get_usage(true) rather than the emalloc figure: an operator watching a worker is + // asking how much memory the PROCESS has taken from the OS and how close that came to + // memory_limit, which is the real allocation, not the portion PHP's allocator currently has + // handed out. Bytes, never a pre-formatted "12.4 MB" string — the endpoint is a JSON API, and + // the dashboard's own Format helper is where humanising belongs. + 'memory' => [ + 'used' => memory_get_usage(true), + 'peak' => memory_get_peak_usage(true), + ], + ], + ]; + } + + /** + * The Laravel version, or null on a host that has no illuminate/foundation. + * + * firefly/actuator's composer.json requires illuminate/container, /contracts, /database, /http, /log, + * /routing and /support — NOT illuminate/foundation — and that omission is real, not an oversight: the + * repo ships a Lumen sample, and Lumen has no Illuminate\Foundation\Application. Reading a class constant + * off a missing class is a fatal Error (unlike an instanceof, which is merely false), so the guard is + * load-bearing. FilterChainRegistrar in firefly/web sets the same precedent: reference the Foundation + * class directly, guard its presence at runtime, and degrade instead of requiring the package. + */ + private function laravelVersion(): ?string + { + return class_exists(LaravelApplication::class) ? LaravelApplication::VERSION : null; + } + + /** + * Whether OPcache is actually compiling, not merely installed. + * + * opcache_get_status() is the authority — `opcache.enable_cli` defaults to off, so an extension that is + * loaded is routinely NOT caching anything under the CLI/queue-worker SAPI, and reporting "on" from + * extension_loaded() alone would be actively misleading on exactly the processes an operator is trying to + * diagnose. It can still be unavailable (the function does not exist without the extension) or refuse to + * answer (opcache.restrict_api limits it to scripts under a configured path, and then it returns false + * without throwing), so the ini setting is the documented fallback for that second case — the SAPI-correct + * one, since enable_cli and enable are separate switches. + */ + private function opcacheEnabled(): bool + { + if (! function_exists('opcache_get_status')) { + return false; + } + + $status = @opcache_get_status(false); + if (is_array($status)) { + $enabled = $status['opcache_enabled'] ?? false; + + return $enabled === true; + } + + return filter_var(ini_get(PHP_SAPI === 'cli' ? 'opcache.enable_cli' : 'opcache.enable'), FILTER_VALIDATE_BOOLEAN); + } +} diff --git a/packages/actuator/src/Introspection/CachesEndpoint.php b/packages/actuator/src/Introspection/CachesEndpoint.php new file mode 100644 index 0000000..b516974 --- /dev/null +++ b/packages/actuator/src/Introspection/CachesEndpoint.php @@ -0,0 +1,121 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Introspection; + +use Firefly\Actuator\Endpoint\ActuatorEndpoint; +use Firefly\Actuator\Endpoint\EndpointRequest; +use Firefly\Actuator\Endpoint\EndpointResponse; +use Firefly\Container\Attributes\Component; +use Illuminate\Contracts\Config\Repository; + +/** + * Lists the cache stores this application has configured — Laravel's `cache.stores` — with each store's driver + * and which one `cache.default` selects. Spring Boot Actuator's /actuator/caches. + * + * GET /actuator/caches -> every store + * GET /actuator/caches/{name} -> one store, or 404 when no store by that name is configured + * + * READ-ONLY, DELIBERATELY. Spring's endpoint also answers DELETE (clear one cache) and DELETE on the index + * (clear them all), and this one does not. Cache eviction is a destructive, unauthenticated-by-default + * operation whose blast radius is a cold cache in production, and firefly/actuator has NO authorization story + * of its own: it deliberately carries no Actuator -> Security edge (see deptrac.yaml), so the only thing + * standing between a caller and the button would be ExposureModel — a publication list, not an access-control + * decision. `caches` is not in the default exposure list, but "off by default" is not the same as + * "authorized", and an endpoint that flushes production caches must be able to say WHO asked. A GET-only + * endpoint is the honest shape until that story exists; a POST (the verb the actuator route actually mounts + * alongside GET) is answered 404 rather than being quietly accepted or silently ignored. + * + * WHAT IS NOT REPORTED. Only `name`, `driver` and `default` — never the rest of the store's config array. A + * store definition routinely carries credentials (`cache.stores.dynamodb.key`/`.secret`, memcached SASL + * usernames and passwords, a `dsn` with an inline password), and this endpoint has the same no-authorization + * problem as eviction does. The masking rule /env and /configprops use would cover `key` and `secret` by name, + * but not a password embedded in a URL, so the honest answer is to publish the two facts an operator actually + * needs — which driver backs this store, and which store is the default — and nothing else. + * + * Return type stays the nullable `?EndpointResponse` (unlike its sibling introspection endpoints): an unknown + * store name and a POST both genuinely 404, so the null branch is live code, not dead. + */ +#[Component] +final class CachesEndpoint implements ActuatorEndpoint +{ + public function __construct(private readonly Repository $config) {} + + public function endpointId(): string + { + return 'caches'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): ?EndpointResponse + { + // Tested as "is it the mutating verb?" rather than "is it GET?" because ActuatorRouteRegistrar mounts + // the dispatch route with match(['GET','POST']) and Laravel's Router answers HEAD wherever it answers + // GET — so Symfony's getMethod() legitimately reports HEAD here, and a `!== 'GET'` guard would 404 a + // perfectly ordinary HEAD probe. + if ($request->method === 'POST') { + return null; + } + + $caches = $this->caches(); + + if ($request->subPath === []) { + return EndpointResponse::json(['default' => $this->defaultStore(), 'caches' => $caches]); + } + + // One segment only: /actuator/caches/redis/anything is not a resource this endpoint has. + if (count($request->subPath) !== 1) { + return null; + } + + $store = $caches[$request->subPath[0]] ?? null; + + return $store === null ? null : EndpointResponse::json($store); + } + + /** + * Keyed by store name — the shape Spring's /caches uses, the shape the sub-path lookup needs, and the shape + * a dashboard indexes by. Left in `cache.stores` order rather than sorted: that order is AUTHORED, in a + * config file somebody wrote, and therefore already stable across machines — unlike the filesystem-walk + * order /configprops has to sort away. As on /configprops, an empty map encodes as `[]`, not `{}`. + * + * A store whose `driver` is missing or non-string reports 'unknown' rather than being dropped: the store IS + * configured, something is wrong with how, and hiding the row would hide the defect. + * + * @return array<string, array{name: string, driver: string, default: bool}> + */ + private function caches(): array + { + $default = $this->defaultStore(); + + /** @var array<array-key, mixed> $stores */ + $stores = (array) $this->config->get('cache.stores', []); + + $caches = []; + foreach ($stores as $name => $store) { + $key = (string) $name; + $driver = is_array($store) && isset($store['driver']) && is_string($store['driver']) ? $store['driver'] : 'unknown'; + + $caches[$key] = ['name' => $key, 'driver' => $driver, 'default' => $key === $default]; + } + + return $caches; + } + + /** + * `cache.default` is null on a container that has no cache config at all (a bare skeleton, or a Lumen app + * that never published config/cache.php). Reporting null — rather than inventing 'file' to match Laravel's + * shipped default — keeps the payload a description of THIS application, not of the framework's defaults. + */ + private function defaultStore(): ?string + { + $default = $this->config->get('cache.default'); + + return is_string($default) ? $default : null; + } +} diff --git a/packages/actuator/src/Introspection/ConfigPropsEndpoint.php b/packages/actuator/src/Introspection/ConfigPropsEndpoint.php new file mode 100644 index 0000000..df28aef --- /dev/null +++ b/packages/actuator/src/Introspection/ConfigPropsEndpoint.php @@ -0,0 +1,245 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Introspection; + +use BackedEnum; +use Firefly\Actuator\Endpoint\ActuatorEndpoint; +use Firefly\Actuator\Endpoint\EndpointRequest; +use Firefly\Actuator\Endpoint\EndpointResponse; +use Firefly\Config\Scanner\ConfigPropertiesManifest; +use Firefly\Config\Scanner\ConfigPropertiesScanner; +use Firefly\Container\Attributes\Component; +use Firefly\Context\Scan\AppScan; +use Illuminate\Contracts\Container\Container; +use ReflectionClass; +use ReflectionProperty; +use Throwable; +use UnitEnum; + +/** + * Lists every #[ConfigProperties] DTO the application bound, with its prefix and the values it actually + * RESOLVED — Spring Boot Actuator's /actuator/configprops. + * + * WHY RESOLVED VALUES AND NOT THE CONFIG SUBTREE. /env already renders `firefly.*` as written. What it cannot + * answer is the question that actually costs people afternoons: the config file says + * `daily_transfer_limit_minor => 250000`, the DTO parameter is `$dailyTransferLimitMinor`, and the DTO is + * holding its default — did relaxed binding match, or did it silently fall through? Reading the BOUND INSTANCE + * back out of the container answers that directly, because it shows the value the application will actually + * use, after relaxed binding, scalar coercion, nested-DTO construction and constructor defaults have all had + * their say. A row whose `properties` disagree with the corresponding /env subtree IS the bug report. + * + * WHERE THE MANIFEST COMES FROM. The framework never binds ConfigPropertiesManifest into the container: it is + * constructed by FireflyAutoConfigureServiceProvider and handed straight to FlushDefinitionsPass, which passes + * it to ConfigRegistrar and drops it. So this endpoint resolves its own, following the SAME cached-then-scanned + * convention AppScan documents for every Category-B manifest — compiled `config-properties.php` when + * firefly:cache has run (production, zero reflection), an in-process scan of `firefly.scan.paths` otherwise + * (development), an empty manifest when neither is configured. A container-bound manifest still wins if one is + * ever present, so a future pass that binds it (or a test that supplies one) is honoured rather than ignored. + * The manifest is memoized for the life of this singleton — the DTO LIST is fixed at deploy time, so re-reading + * it per request would buy nothing; the VALUES are re-read on every request, so a rebound DTO shows through. + * + * WHY THE SCAN IS DEFERRED TO handle(). Resolving the manifest in the constructor would run a reflective + * directory scan during EagerSingletonsPass on every uncached boot — including boots that never serve + * /configprops, which is most of them, since the endpoint is not in the default exposure list. Deferring it to + * the first request keeps that cost on the caller who asked for it, and is why this component needs no #[Lazy]. + * + * MASKING. Resolved values run through the shared SensitiveValueMasker — the identical rule /env enforces, + * including its array-valued-secret fix (see that class): a DTO property named `$apiToken` or `$signingKeys` + * is masked whether it holds a string or a whole keyring. + * + * Return type is narrowed to the non-nullable EndpointResponse (the same covariant-narrowing idiom + * BeansEndpoint/EnvEndpoint use): /configprops has no sub-resource concept to 404 on. + */ +#[Component] +final class ConfigPropsEndpoint implements ActuatorEndpoint +{ + /** + * Objects nested more deeply than this render as their class name instead of being descended into. A + * #[ConfigProperties] tree is built by ReflectionConfigBinder from a config array, so it is finite and + * acyclic by construction — but nothing stops a DTO from holding a hand-built object with a back-reference, + * and an introspection endpoint that can be made to recurse forever is a denial-of-service, not a feature. + */ + private const int MAX_DEPTH = 8; + + private ?ConfigPropertiesManifest $manifest = null; + + public function __construct(private readonly Container $container) {} + + public function endpointId(): string + { + return 'configprops'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): EndpointResponse + { + $beans = []; + foreach ($this->manifest()->properties as $descriptor) { + $beans[$descriptor->class] = $this->describe($descriptor->class, $descriptor->prefix, $descriptor->profiles); + } + + // Keyed by class and sorted by it, rather than a list in manifest order. Keyed because that is the + // shape Spring's own /configprops uses (`contexts.<ctx>.beans.<name>`) and the one a dashboard wants + // for lookup; sorted because the fallback manifest comes from a recursive DIRECTORY WALK, whose order + // depends on the filesystem — an unsorted payload would shuffle between two machines rendering the + // same application and make a dashboard diff meaningless. Note that PHP encodes an EMPTY map as `[]`, + // not `{}`; that is the same well-known quirk /loggers already lives with for its `loggers` map, and + // it is left alone here rather than special-cased into a stdClass, so the endpoint keeps one type. + ksort($beans); + + return EndpointResponse::json(['beans' => $beans]); + } + + /** + * One row per descriptor, with a FIXED key set — `bound` and `error` are always present, never omitted on + * the happy path, so a dashboard can render one column layout instead of probing for optional keys. + * + * A DTO that is not bound is reported rather than dropped. Absence has exactly one cause worth surfacing: + * ConfigRegistrar skipped it because its #[Profile] requirement does not match the active profiles, and + * "declared, gated off under this profile" is precisely what an operator staring at a missing setting needs + * to see. `profiles` on the same row is the explanation. + * + * @param list<string> $profiles + * @return array{class: string, prefix: string, profiles: list<string>, bound: bool, properties: array<string, mixed>, error: string|null} + */ + private function describe(string $class, string $prefix, array $profiles): array + { + $row = ['class' => $class, 'prefix' => $prefix, 'profiles' => $profiles, 'bound' => false, 'properties' => [], 'error' => null]; + + if (! $this->container->bound($class)) { + return $row; + } + + try { + $instance = $this->container->get($class); + } catch (Throwable $e) { + // A DTO whose required property is missing from config throws out of ConfigRegistrar's binding + // closure the first time anything resolves it. Reporting that per row — instead of letting it + // escape and turn the whole endpoint into a problem+json 500 — is the point of an introspection + // endpoint: one unbindable DTO must not hide the twenty that bound correctly, and its message is + // the most useful thing on the page. + $row['error'] = $e::class.': '.$e->getMessage(); + + return $row; + } + + if (! is_object($instance)) { + $row['error'] = 'Container returned a '.get_debug_type($instance).' for this class-string binding.'; + + return $row; + } + + $row['bound'] = true; + $row['properties'] = SensitiveValueMasker::mask($this->properties($instance, 0)); + + return $row; + } + + /** + * The instance's public, non-static properties — which is exactly the surface a #[ConfigProperties] DTO + * has, since ReflectionConfigBinder builds it through constructor promotion. + * + * isInitialized() is checked because a DTO may declare a typed property the constructor does not assign; + * reading one throws an Error, and an uninitialized property is honestly reported as null rather than + * crashing the endpoint. + * + * @return array<string, mixed> + */ + private function properties(object $instance, int $depth): array + { + $values = []; + foreach ((new ReflectionClass($instance))->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic()) { + continue; + } + + $values[$property->getName()] = $property->isInitialized($instance) + ? $this->value($property->getValue($instance), $depth + 1) + : null; + } + + return $values; + } + + /** + * Renders one resolved value as something json_encode cannot choke on. + * + * Nested DTOs are descended into (ReflectionConfigBinder builds them recursively, so a `$database` property + * really is another bound DTO and its values are just as interesting). Enums render as their backing value + * — or their case name when they are pure — because that is the spelling the config file used. Anything + * else non-scalar, a resource or a closure, renders as its type name: a config DTO should not be holding + * one, and if it is, saying so is more useful than a JSON_THROW_ON_ERROR failure in the dispatch action. + */ + private function value(mixed $value, int $depth): mixed + { + if (is_array($value)) { + if ($depth > self::MAX_DEPTH) { + return 'array'; + } + + $mapped = []; + foreach ($value as $key => $item) { + $mapped[$key] = $this->value($item, $depth + 1); + } + + return $mapped; + } + + if ($value instanceof BackedEnum) { + return $value->value; + } + + if ($value instanceof UnitEnum) { + return $value->name; + } + + if (is_object($value)) { + return $depth > self::MAX_DEPTH ? $value::class : $this->properties($value, $depth); + } + + return is_scalar($value) || $value === null ? $value : get_debug_type($value); + } + + private function manifest(): ConfigPropertiesManifest + { + return $this->manifest ??= $this->discoverManifest(); + } + + private function discoverManifest(): ConfigPropertiesManifest + { + if ($this->container->bound(ConfigPropertiesManifest::class)) { + $bound = $this->boundManifest(); + if ($bound instanceof ConfigPropertiesManifest) { + return $bound; + } + } + + $file = AppScan::cachedFile($this->container, AppScan::CONFIG_PROPERTIES); + if ($file !== null) { + return ConfigPropertiesManifest::load($file); + } + + $paths = AppScan::paths($this->container); + + return new ConfigPropertiesManifest($paths === [] ? [] : (new ConfigPropertiesScanner)->scan($paths)); + } + + /** + * Resolved through a dedicated method with an EXPLICIT `object` return type, rather than inline at the call + * site: Larastan's container extension narrows `get(ConfigPropertiesManifest::class)` to exactly that class, + * which makes the instanceof guard above read as dead code to PHPStan even though the container is a runtime + * registry and nothing stops a host application from binding something else under that key. Declaring + * `object` is the honest static type — the same idiom firefly/web's FilterChainRegistrar uses for the HTTP + * kernel — so the check above is a real narrowing rather than a suppressed one. + */ + private function boundManifest(): object + { + return $this->container->get(ConfigPropertiesManifest::class); + } +} diff --git a/packages/actuator/src/Introspection/EnvEndpoint.php b/packages/actuator/src/Introspection/EnvEndpoint.php index cc45183..2c3e699 100644 --- a/packages/actuator/src/Introspection/EnvEndpoint.php +++ b/packages/actuator/src/Introspection/EnvEndpoint.php @@ -14,6 +14,12 @@ * Exposes the firefly.* configuration tree with sensitive values masked (fail-safe invariant: /env values masked). * A key matching password|secret|token|key|credential|passwd (case-insensitive) is replaced with ******. * + * The rule itself moved to SensitiveValueMasker when /configprops arrived and needed the SAME rule — see that + * class for why it is shared rather than copied, and for the array-valued-secret bypass this endpoint used to + * have (a sensitive key holding an array was recursed into instead of masked, so a JWT keyring under + * `firefly.security.jwt.keys` rendered every private key in full). This endpoint's observable contract is + * unchanged for scalar values and strictly safer for array ones. + * * Return type is narrowed to the non-nullable EndpointResponse (a legal covariant narrowing of * ActuatorEndpoint::handle()'s `?EndpointResponse`, the same idiom InfoEndpoint uses): /env has no * sub-resource concept to 404 on, so handle() always produces a body — PHPStan (level max) flags the @@ -22,10 +28,6 @@ #[Component] final class EnvEndpoint implements ActuatorEndpoint { - private const MASK = '******'; - - private const SENSITIVE = '/password|secret|token|key|credential|passwd/i'; - public function __construct(private readonly Repository $config) {} public function endpointId(): string @@ -43,26 +45,6 @@ public function handle(EndpointRequest $request): EndpointResponse /** @var array<string, mixed> $firefly */ $firefly = (array) $this->config->get('firefly', []); - return EndpointResponse::json(['firefly' => $this->mask($firefly)]); - } - - /** - * @param array<string, mixed> $values - * @return array<string, mixed> - */ - private function mask(array $values): array - { - $masked = []; - foreach ($values as $key => $value) { - if (is_array($value)) { - /** @var array<string, mixed> $value */ - $masked[$key] = $this->mask($value); - - continue; - } - $masked[$key] = preg_match(self::SENSITIVE, (string) $key) === 1 ? self::MASK : $value; - } - - return $masked; + return EndpointResponse::json(['firefly' => SensitiveValueMasker::mask($firefly)]); } } diff --git a/packages/actuator/src/Introspection/SensitiveValueMasker.php b/packages/actuator/src/Introspection/SensitiveValueMasker.php new file mode 100644 index 0000000..8e7ccdd --- /dev/null +++ b/packages/actuator/src/Introspection/SensitiveValueMasker.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Introspection; + +/** + * THE masking rule the introspection surface shares — one regex, one replacement, one recursion, used by + * every endpoint that renders configuration back to a caller (/env and /configprops today). + * + * It lives in its own class rather than being copied because a second copy is how a masking rule rots: the + * moment /configprops grew its own `password|secret|token` regex, the two lists would drift on the very next + * key somebody thought to add ("private_key", "dsn"), and the endpoint that missed the addition would leak. + * The rule itself is unchanged from the one EnvEndpoint has always enforced (fail-safe invariant: /env values + * masked) — a key matching password|secret|token|key|credential|passwd, case-insensitively, anywhere in the + * key, is replaced with ******. + * + * ARRAY-VALUED SECRETS. The rule EnvEndpoint originally implemented tested the key ONLY on the branch where + * the value was a scalar: + * + * if (is_array($value)) { $masked[$key] = $this->mask($value); continue; } // <- recursed, never tested + * $masked[$key] = preg_match(SENSITIVE, $key) ? MASK : $value; + * + * so a sensitive key holding an ARRAY was never masked — it was descended into, and each leaf was then judged + * on its OWN key. `firefly.security.jwt.keys => ['active' => 'PRIVATE...', 'previous' => '...']` therefore + * rendered both private keys in full: `keys` matched the regex but was an array, `active` and `previous` did + * not match anything. Every real-world shape of a secret — a keyring, a credentials pair, a per-tenant token + * map — is exactly that shape, so the bypass covered the cases that mattered most. The order is inverted + * here: the KEY decides first, and a sensitive key masks its whole subtree regardless of the value's type; + * only a key that is NOT sensitive is descended into. + * + * Masking a sensitive array as the scalar ****** (rather than as a same-shaped array of ******) is + * deliberate: the shape of a secret is itself information — how many keys are in the keyring, which tenants + * have tokens — and a caller who may not see the values has no business counting them either. + */ +final class SensitiveValueMasker +{ + public const string MASK = '******'; + + private const string SENSITIVE = '/password|secret|token|key|credential|passwd/i'; + + /** + * The key type is preserved through the template so a caller that hands in an `array<string, mixed>` + * gets an `array<string, mixed>` back without a suppressing @var at the call site. + * + * @template TKey of array-key + * + * @param array<TKey, mixed> $values + * @return array<TKey, mixed> + */ + public static function mask(array $values): array + { + $masked = []; + foreach ($values as $key => $value) { + if (self::isSensitive($key)) { + $masked[$key] = self::MASK; + + continue; + } + + $masked[$key] = is_array($value) ? self::mask($value) : $value; + } + + return $masked; + } + + /** + * Integer keys are stringified before matching rather than skipped: a list under a non-sensitive key + * carries indices 0, 1, 2, which can never match the pattern, so the cast costs nothing and keeps the + * predicate total over array-key — no separate "is this a list?" branch that a future edit could forget. + */ + public static function isSensitive(int|string $key): bool + { + return preg_match(self::SENSITIVE, (string) $key) === 1; + } +} 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 f4e22e5..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); @@ -67,9 +73,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/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/CapstoneActuatorEmptyInfoTest.php b/packages/actuator/tests/CapstoneActuatorEmptyInfoTest.php new file mode 100644 index 0000000..2c9c22c --- /dev/null +++ b/packages/actuator/tests/CapstoneActuatorEmptyInfoTest.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Tests\Support\EmptyInfoActuatorCapstoneTestCase; + +uses(EmptyInfoActuatorCapstoneTestCase::class); + +/** + * 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. + * + * This assertion used to live in CapstoneActuatorIntegrationTest, where /actuator/info was empty by default. + * RuntimeInfoContributor made it non-empty by default (which is the point — the dashboard was rendering an + * apology), so the empty case now needs a boot that deliberately removes it. The rendering rule itself is + * unchanged and still worth pinning: it protects every OTHER endpoint that can legitimately return nothing. + */ +it('renders an empty endpoint body as {} rather than []', function () { + /** @var EmptyInfoActuatorCapstoneTestCase $this */ + $response = $this->get('/actuator/info'); + + $response->assertStatus(200); + expect($response->getContent())->toBe('{}'); +}); diff --git a/packages/actuator/tests/CapstoneActuatorIntegrationTest.php b/packages/actuator/tests/CapstoneActuatorIntegrationTest.php index 39fca7a..e0be767 100644 --- a/packages/actuator/tests/CapstoneActuatorIntegrationTest.php +++ b/packages/actuator/tests/CapstoneActuatorIntegrationTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Firefly\Actuator\Tests\Support\ActuatorCapstoneTestCase; +use Firefly\Kernel\Version; uses(ActuatorCapstoneTestCase::class); @@ -33,3 +34,22 @@ // 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(). + +// /actuator/info used to answer `{}` on a skeleton — a 200 with nothing in it, which the admin dashboard +// could only render as an apology telling the operator to configure something. RuntimeInfoContributor is +// registered by default now, so the endpoint says what is actually running WITHOUT the application having +// authored firefly.management.info.app.* or shipped a firefly-build.json. The `{}`-not-`[]` rendering rule +// that used to be asserted here still is, over a boot that deliberately switches the contributor off: +// CapstoneActuatorEmptyInfoTest. +it('serves runtime facts at /actuator/info with no application configuration', function () { + /** @var ActuatorCapstoneTestCase $this */ + $this->getJson('/actuator/info') + ->assertStatus(200) + ->assertJsonPath('runtime.php.version', PHP_VERSION) + ->assertJsonPath('runtime.php.sapi', PHP_SAPI) + ->assertJsonPath('runtime.firefly.version', Version::VERSION) + ->assertJsonPath('runtime.php.opcache', fn (mixed $v): bool => is_bool($v)) + ->assertJsonPath('runtime.laravel.version', fn (mixed $v): bool => is_string($v) && $v !== '') + ->assertJsonPath('runtime.memory.used', fn (mixed $v): bool => is_int($v) && $v > 0) + ->assertJsonPath('runtime.memory.peak', fn (mixed $v): bool => is_int($v) && $v > 0); +}); diff --git a/packages/actuator/tests/CapstoneActuatorIntrospectionTest.php b/packages/actuator/tests/CapstoneActuatorIntrospectionTest.php new file mode 100644 index 0000000..7c60040 --- /dev/null +++ b/packages/actuator/tests/CapstoneActuatorIntrospectionTest.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Tests\Fixtures\DemoProperties; +use Firefly\Actuator\Tests\Fixtures\ProdOnlyProperties; +use Firefly\Actuator\Tests\Fixtures\UnbindableProperties; +use Firefly\Actuator\Tests\Support\IntrospectionExposedCapstoneTestCase; + +uses(IntrospectionExposedCapstoneTestCase::class); + +/** + * The HTTP-level half of the two new introspection endpoints. The unit tests pin the payload shapes; this file + * pins that the endpoints are actually WIRED — discovered as #[Component] ActuatorEndpoint beans out of the + * compiled manifest, resolved once by ActuatorRouteRegistrar into ActuatorRegistry, and dispatched by + * ActuatorDispatchAction under the base path. Neither could be caught by constructing the endpoint directly, + * and that exact gap is what once left InfoEndpoint unreachable in every real boot (see its docblock). + */ +it('serves /actuator/configprops with resolved, masked values', function () { + /** @var IntrospectionExposedCapstoneTestCase $this */ + $response = $this->getJson('/actuator/configprops'); + + $response->assertStatus(200) + ->assertJsonPath('beans.'.DemoProperties::class.'.class', DemoProperties::class) + ->assertJsonPath('beans.'.DemoProperties::class.'.prefix', 'demo') + ->assertJsonPath('beans.'.DemoProperties::class.'.bound', true) + ->assertJsonPath('beans.'.DemoProperties::class.'.error', null) + ->assertJsonPath('beans.'.DemoProperties::class.'.properties.name', 'checkout') + // '3' in config, int in the DTO: the row shows the value the application will actually use. + ->assertJsonPath('beans.'.DemoProperties::class.'.properties.retries', 3) + ->assertJsonPath('beans.'.DemoProperties::class.'.properties.apiToken', '******') + ->assertJsonPath('beans.'.DemoProperties::class.'.properties.signingKeys', '******') + ->assertJsonPath('beans.'.DemoProperties::class.'.properties.endpoint.url', 'https://demo.test') + ->assertJsonPath('beans.'.DemoProperties::class.'.properties.endpoint.password', '******'); + + expect($this->responseBody($response))->not->toContain('super-secret-token') + ->and($this->responseBody($response))->not->toContain('PRIVATE-A') + ->and($this->responseBody($response))->not->toContain('hunter2'); +}); + +// Both degraded rows, over the REAL registrar rather than a hand-built manifest. ProdOnlyProperties carries +// #[Profile('prod')] and the active profiles do not include it, so ConfigRegistrar never bound it; +// UnbindableProperties has a required property with nothing under its prefix, so resolving it throws. Neither +// may take the endpoint down with it, and neither may vanish from the list — "declared but not bound" and +// "declared and broken" are exactly the two states an operator hunting a missing setting needs to see. +it('reports a profile-gated and an unbindable DTO as their own rows without failing the endpoint', function () { + /** @var IntrospectionExposedCapstoneTestCase $this */ + $this->getJson('/actuator/configprops') + ->assertStatus(200) + ->assertJsonPath('beans.'.ProdOnlyProperties::class.'.profiles', ['prod']) + ->assertJsonPath('beans.'.ProdOnlyProperties::class.'.bound', false) + ->assertJsonPath('beans.'.ProdOnlyProperties::class.'.properties', []) + ->assertJsonPath('beans.'.ProdOnlyProperties::class.'.error', null) + ->assertJsonPath('beans.'.UnbindableProperties::class.'.bound', false) + ->assertJsonPath('beans.'.UnbindableProperties::class.'.error', fn (mixed $e): bool => is_string($e) && str_contains($e, 'Missing required configuration property [mandatory]')); +}); + +it('serves /actuator/caches and a single store on a sub-path', function () { + /** @var IntrospectionExposedCapstoneTestCase $this */ + $this->getJson('/actuator/caches') + ->assertStatus(200) + ->assertJsonPath('default', 'array') + ->assertJsonPath('caches.array', ['name' => 'array', 'driver' => 'array', 'default' => true]) + ->assertJsonPath('caches.redis', ['name' => 'redis', 'driver' => 'redis', 'default' => false]); + + $this->getJson('/actuator/caches/redis') + ->assertStatus(200) + ->assertJsonPath('driver', 'redis') + ->assertJsonPath('default', false); +}); + +it('404s an unknown cache store and refuses a POST through the real dispatcher', function () { + /** @var IntrospectionExposedCapstoneTestCase $this */ + $this->getJson('/actuator/caches/memcached')->assertStatus(404); + $this->postJson('/actuator/caches/redis')->assertStatus(404); +}); + +it('advertises both endpoints on the HAL index once exposed', function () { + /** @var IntrospectionExposedCapstoneTestCase $this */ + $this->getJson('/actuator') + ->assertStatus(200) + ->assertJsonPath('_links.configprops.href', fn (mixed $h): bool => is_string($h) && str_ends_with($h, '/actuator/configprops')) + ->assertJsonPath('_links.caches.href', fn (mixed $h): bool => is_string($h) && str_ends_with($h, '/actuator/caches')); +}); 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/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(); +}); diff --git a/packages/actuator/tests/Fixtures/DemoEndpointProperties.php b/packages/actuator/tests/Fixtures/DemoEndpointProperties.php new file mode 100644 index 0000000..1263ed4 --- /dev/null +++ b/packages/actuator/tests/Fixtures/DemoEndpointProperties.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Fixtures; + +/** + * The nested half of DemoProperties. Carries NO #[ConfigProperties] attribute of its own — that is exactly how + * ReflectionConfigBinder builds a nested DTO (from the parent's sub-array, never from a second manifest row), + * so /configprops has to reach it by reflecting the parent instance rather than by listing it separately. + */ +final readonly class DemoEndpointProperties +{ + public function __construct( + public string $url, + public string $password, + ) {} +} diff --git a/packages/actuator/tests/Fixtures/DemoMode.php b/packages/actuator/tests/Fixtures/DemoMode.php new file mode 100644 index 0000000..775cf73 --- /dev/null +++ b/packages/actuator/tests/Fixtures/DemoMode.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Fixtures; + +/** A backed enum property, so the /configprops renderer is pinned to publishing the BACKING VALUE. */ +enum DemoMode: string +{ + case Strict = 'strict'; + case Lenient = 'lenient'; +} diff --git a/packages/actuator/tests/Fixtures/DemoProperties.php b/packages/actuator/tests/Fixtures/DemoProperties.php new file mode 100644 index 0000000..2a9448b --- /dev/null +++ b/packages/actuator/tests/Fixtures/DemoProperties.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Fixtures; + +use Firefly\Config\Attributes\ConfigProperties; + +/** + * A #[ConfigProperties] DTO shaped to exercise everything /configprops has to render in one row: a plain + * scalar, a nested DTO built by ReflectionConfigBinder, a scalar whose NAME is sensitive, and — the case the + * masking audit was about — an ARRAY whose name is sensitive, so the test can prove the whole subtree is + * replaced rather than descended into. + */ +#[ConfigProperties(prefix: 'demo')] +final readonly class DemoProperties +{ + /** + * @param array<string, string> $signingKeys + */ + public function __construct( + public string $name, + public int $retries, + public string $apiToken, + public array $signingKeys, + public DemoEndpointProperties $endpoint, + public DemoMode $mode = DemoMode::Strict, + ) {} +} diff --git a/packages/actuator/tests/Fixtures/ProdOnlyProperties.php b/packages/actuator/tests/Fixtures/ProdOnlyProperties.php new file mode 100644 index 0000000..6631a08 --- /dev/null +++ b/packages/actuator/tests/Fixtures/ProdOnlyProperties.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Fixtures; + +use Firefly\Config\Attributes\ConfigProperties; +use Firefly\Config\Profile\Profile; + +/** #[Profile]-gated: ConfigRegistrar does not bind it outside `prod`, so /configprops must report it unbound. */ +#[ConfigProperties(prefix: 'prodonly')] +#[Profile('prod')] +final readonly class ProdOnlyProperties +{ + public function __construct(public string $endpoint) {} +} diff --git a/packages/actuator/tests/Fixtures/UnbindableProperties.php b/packages/actuator/tests/Fixtures/UnbindableProperties.php new file mode 100644 index 0000000..17dbc2f --- /dev/null +++ b/packages/actuator/tests/Fixtures/UnbindableProperties.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Fixtures; + +use Firefly\Config\Attributes\ConfigProperties; + +/** + * Required, non-nullable, no default, and nothing under its prefix in config — so ReflectionConfigBinder throws + * a ConfigurationException the first time anything resolves it. /configprops must report that on the row and + * keep rendering every other DTO. + */ +#[ConfigProperties(prefix: 'unbindable')] +final readonly class UnbindableProperties +{ + public function __construct(public string $mandatory) {} +} diff --git a/packages/actuator/tests/Info/RuntimeInfoContributorTest.php b/packages/actuator/tests/Info/RuntimeInfoContributorTest.php new file mode 100644 index 0000000..683ba5f --- /dev/null +++ b/packages/actuator/tests/Info/RuntimeInfoContributorTest.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\ActuatorServiceProvider; +use Firefly\Actuator\ActuatorWiringProvider; +use Firefly\Actuator\Endpoint\EndpointRequest; +use Firefly\Actuator\Info\InfoContributor; +use Firefly\Actuator\Info\InfoContributorRegistry; +use Firefly\Actuator\Info\InfoEndpoint; +use Firefly\Actuator\Info\RuntimeInfoContributor; +use Firefly\Kernel\Version; +use Firefly\Scheduling\Schedule\ScheduledManifest; +use Firefly\Web\Route\RouteManifest; +use Illuminate\Foundation\Application; + +/** + * Boots the same bare skeleton PackageBootTest does (empty RouteManifest/ScheduledManifest stubbed via the + * harness's bindings menu rather than dragging in Web's and Scheduling's whole boot pipelines) so the + * REGISTRATION half of the contract is exercised end to end: nothing registers RuntimeInfoContributor + * explicitly — it has to be discovered as a #[Component] InfoContributor by InfoContributorRegistrar, and its + * #[ConditionalOnProperty] has to be evaluated by the real condition pipeline off the compiled context + * manifest. A unit test over the class alone would prove neither. + * + * @param array<string, mixed> $management + */ +function runtimeInfoApp(array $management = []): Application +{ + return fireflyApplication( + config: ['firefly' => ['management' => ['enabled' => true] + $management]], + providers: [ActuatorServiceProvider::class, ActuatorWiringProvider::class], + bindings: [ + RouteManifest::class => new RouteManifest([]), + ScheduledManifest::class => new ScheduledManifest([]), + ], + ); +} + +/** @return list<class-string<InfoContributor>> */ +function runtimeInfoContributors(Application $app): array +{ + /** @var InfoContributorRegistry $registry */ + $registry = $app->make(InfoContributorRegistry::class); + + return array_map(static fn (InfoContributor $c): string => $c::class, $registry->all()); +} + +/** + * One section of the `runtime` fragment, narrowed to a real string-keyed array by control flow rather than by + * an inline-PHPDoc override — so a shape regression (a section that stops being an array, or disappears) + * fails HERE with a readable message instead of surfacing as a mixed-offset access. + * + * @param array<string, mixed> $info + * @return array<string, mixed> + */ +function runtimeSection(array $info, string ...$path): array +{ + $node = $info; + foreach ($path as $key) { + $value = $node[$key] ?? null; + if (! is_array($value)) { + throw new RuntimeException('Expected ['.implode('.', $path)."] to be an array; [{$key}] is not."); + } + + $narrowed = []; + foreach ($value as $k => $v) { + $narrowed[(string) $k] = $v; + } + $node = $narrowed; + } + + return $node; +} + +/** + * @param array<string, mixed> $section + */ +function runtimeInt(array $section, string $key): int +{ + $value = $section[$key] ?? null; + if (! is_int($value)) { + throw new RuntimeException("Expected [{$key}] to be an int, got ".get_debug_type($value).'.'); + } + + return $value; +} + +it('publishes the runtime facts under the runtime key', function () { + $info = (new RuntimeInfoContributor)->info(); + + $runtime = runtimeSection($info, 'runtime'); + $php = runtimeSection($runtime, 'php'); + $memory = runtimeSection($runtime, 'memory'); + + // Asserted structurally, not by value: PHP_VERSION and the memory figures differ per machine and per run, + // so pinning literals here would make the suite fail on somebody else's laptop for no reason. What IS + // pinned is the exact key set and the type of every leaf — which is what the dashboard renders against. + expect(array_keys($runtime))->toBe(['php', 'laravel', 'firefly', 'memory']) + ->and(array_keys($php))->toBe(['version', 'sapi', 'opcache']) + ->and($php['version'])->toBe(PHP_VERSION) + ->and($php['sapi'])->toBe(PHP_SAPI) + ->and($php['opcache'])->toBeBool() + ->and(runtimeSection($runtime, 'laravel')['version'])->toBe(Application::VERSION) + ->and(runtimeSection($runtime, 'firefly')['version'])->toBe(Version::VERSION) + ->and(array_keys($memory))->toBe(['used', 'peak']) + ->and(runtimeInt($memory, 'peak'))->toBeGreaterThanOrEqual(runtimeInt($memory, 'used')); +}); + +// The whole point of the contributor: /actuator/info answered `{}` on a fresh application, and the dashboard +// could only render an apology. This is the regression that must never come back. +it('makes /info non-empty with no application configuration at all', function () { + $registry = new InfoContributorRegistry; + $registry->register(new RuntimeInfoContributor); + + $body = (new InfoEndpoint($registry))->handle(new EndpointRequest('GET', []))->body; + + expect($body)->not->toBe([]) + ->and($body)->toHaveKey('runtime'); +}); + +it('is discovered and registered on a real boot with no configuration', function () { + expect(runtimeInfoContributors(runtimeInfoApp()))->toContain(RuntimeInfoContributor::class); +}); + +// Off means GONE, not "registered but returning []": the condition is evaluated at boot, and +// InfoContributorRegistrar only ever sees definitions that survived condition filtering. +it('is not registered at all when firefly.management.info.runtime.enabled is false', function () { + $contributors = runtimeInfoContributors(runtimeInfoApp(['info' => ['runtime' => ['enabled' => false]]])); + + expect($contributors)->not->toContain(RuntimeInfoContributor::class); +}); + +it('is registered when the switch is explicitly true', function () { + $contributors = runtimeInfoContributors(runtimeInfoApp(['info' => ['runtime' => ['enabled' => true]]])); + + expect($contributors)->toContain(RuntimeInfoContributor::class); +}); diff --git a/packages/actuator/tests/Introspection/CachesEndpointTest.php b/packages/actuator/tests/Introspection/CachesEndpointTest.php new file mode 100644 index 0000000..907930a --- /dev/null +++ b/packages/actuator/tests/Introspection/CachesEndpointTest.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Endpoint\EndpointRequest; +use Firefly\Actuator\Endpoint\EndpointResponse; +use Firefly\Actuator\Introspection\CachesEndpoint; +use Illuminate\Config\Repository; + +/** + * The store list is fed as a real Laravel `cache` config array (the shape config/cache.php ships), including + * the credential-bearing keys a dynamodb store carries, so the "publishes name/driver/default and nothing + * else" rule is asserted against the exact input that would leak if the endpoint dumped the store definition. + */ +function cachesEndpoint(): CachesEndpoint +{ + return new CachesEndpoint(new Repository(['cache' => [ + 'default' => 'redis', + 'stores' => [ + 'array' => ['driver' => 'array', 'serialize' => false], + 'redis' => ['driver' => 'redis', 'connection' => 'cache', 'lock_connection' => 'default'], + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => 'AKIAEXAMPLE', + 'secret' => 'wJalrXUtnFEMI-EXAMPLE-KEY', + 'table' => 'cache', + ], + ], + ]])); +} + +/** + * EndpointResponse::$body is `array<mixed>|string` — narrowed by real control flow, and the null 404 signal is + * rejected here rather than @var-ed away, so a test that expects a body cannot silently pass on a 404. + * + * @return array<mixed> + */ +function cachesJsonBody(?EndpointResponse $response): array +{ + if ($response === null) { + throw new RuntimeException('Expected a non-null EndpointResponse.'); + } + + if (! is_array($response->body)) { + throw new RuntimeException('Expected a JSON (array) response body.'); + } + + return $response->body; +} + +it('lists every configured store with its driver and marks the default', function () { + $body = cachesJsonBody(cachesEndpoint()->handle(new EndpointRequest('GET', []))); + + expect($body)->toBe([ + 'default' => 'redis', + 'caches' => [ + 'array' => ['name' => 'array', 'driver' => 'array', 'default' => false], + 'redis' => ['name' => 'redis', 'driver' => 'redis', 'default' => true], + 'dynamodb' => ['name' => 'dynamodb', 'driver' => 'dynamodb', 'default' => false], + ], + ]); +}); + +// The reason the endpoint publishes three fields and not the store definition: a dynamodb store carries an +// access key id and a secret access key, and this endpoint has no authorization story to protect them with. +it('never publishes a store credential', function () { + $flat = json_encode(cachesJsonBody(cachesEndpoint()->handle(new EndpointRequest('GET', []))), JSON_THROW_ON_ERROR); + + expect($flat)->not->toContain('AKIAEXAMPLE') + ->and($flat)->not->toContain('wJalrXUtnFEMI-EXAMPLE-KEY') + ->and($flat)->not->toContain('lock_connection'); +}); + +it('describes a single store on a sub-path', function () { + $body = cachesJsonBody(cachesEndpoint()->handle(new EndpointRequest('GET', ['redis']))); + + expect($body)->toBe(['name' => 'redis', 'driver' => 'redis', 'default' => true]); +}); + +it('404s a store that is not configured', function () { + expect(cachesEndpoint()->handle(new EndpointRequest('GET', ['memcached'])))->toBeNull(); +}); + +it('404s a sub-path deeper than one segment', function () { + expect(cachesEndpoint()->handle(new EndpointRequest('GET', ['redis', 'entries'])))->toBeNull(); +}); + +// GET-only by design: eviction is destructive and firefly/actuator carries no Actuator -> Security edge, so it +// has no way to say WHO asked. POST is the only other verb the actuator route mounts, and it 404s. +it('404s a POST rather than accepting an eviction it cannot authorize', function () { + expect(cachesEndpoint()->handle(new EndpointRequest('POST', [])))->toBeNull() + ->and(cachesEndpoint()->handle(new EndpointRequest('POST', ['redis'])))->toBeNull(); +}); + +// Laravel's Router answers HEAD wherever it answers GET, so getMethod() legitimately reports HEAD here; a +// naive `!== 'GET'` guard would have 404'd an ordinary probe. +it('serves a HEAD probe exactly like a GET', function () { + expect(cachesJsonBody(cachesEndpoint()->handle(new EndpointRequest('HEAD', [])))) + ->toBe(cachesJsonBody(cachesEndpoint()->handle(new EndpointRequest('GET', [])))); +}); + +it('reports a store whose driver is missing rather than hiding the row', function () { + $endpoint = new CachesEndpoint(new Repository(['cache' => ['default' => 'broken', 'stores' => ['broken' => ['table' => 'cache']]]])); + + expect(cachesJsonBody($endpoint->handle(new EndpointRequest('GET', [])))) + ->toBe(['default' => 'broken', 'caches' => ['broken' => ['name' => 'broken', 'driver' => 'unknown', 'default' => true]]]); +}); + +// A bare skeleton (or a Lumen app that never published config/cache.php) has no cache config at all. Reporting +// a null default rather than inventing Laravel's shipped 'file' keeps the payload a description of THIS app. +it('answers a null default and an empty list when nothing is configured', function () { + $endpoint = new CachesEndpoint(new Repository([])); + + $response = $endpoint->handle(new EndpointRequest('GET', [])); + + expect($response)->toBeInstanceOf(EndpointResponse::class) + ->and(cachesJsonBody($response))->toBe(['default' => null, 'caches' => []]); +}); + +it('is exposed as the caches endpoint id', function () { + expect(cachesEndpoint()->endpointId())->toBe('caches') + ->and(cachesEndpoint()->enabled())->toBeTrue(); +}); diff --git a/packages/actuator/tests/Introspection/ConfigPropsEndpointTest.php b/packages/actuator/tests/Introspection/ConfigPropsEndpointTest.php new file mode 100644 index 0000000..e330616 --- /dev/null +++ b/packages/actuator/tests/Introspection/ConfigPropsEndpointTest.php @@ -0,0 +1,223 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Endpoint\EndpointRequest; +use Firefly\Actuator\Endpoint\EndpointResponse; +use Firefly\Actuator\Introspection\ConfigPropsEndpoint; +use Firefly\Actuator\Tests\Fixtures\DemoProperties; +use Firefly\Actuator\Tests\Fixtures\ProdOnlyProperties; +use Firefly\Actuator\Tests\Fixtures\UnbindableProperties; +use Firefly\Config\Config; +use Firefly\Config\Profile\Profiles; +use Firefly\Config\Registrar\ConfigRegistrar; +use Firefly\Config\Scanner\ConfigPropertiesDescriptor; +use Firefly\Config\Scanner\ConfigPropertiesManifest; +use Illuminate\Config\Repository; +use Illuminate\Container\Container; + +/** + * The DTOs are registered through the REAL ConfigRegistrar over a real manifest — not hand-bound with + * $container->instance() — because the whole value of /configprops is that it reports what the framework's own + * binding actually produced. A test that hand-built the instances would still pass if relaxed binding, scalar + * coercion or profile gating broke. + * + * @param list<ConfigPropertiesDescriptor> $descriptors + * @param array<string, mixed> $config + * @param list<string> $profiles + */ +function configPropsContainer(array $descriptors, array $config = [], array $profiles = ['test']): Container +{ + $repository = new Repository($config); + $manifest = new ConfigPropertiesManifest($descriptors); + + $container = new Container; + $container->instance('config', $repository); + (new ConfigRegistrar($container, new Config($repository), profiles: new Profiles($profiles)))->register($manifest); + + // Bound so the endpoint uses THIS manifest rather than falling back to its AppScan discovery — the + // container-bound manifest winning is itself part of the contract (see ConfigPropsEndpoint::discoverManifest). + $container->instance(ConfigPropertiesManifest::class, $manifest); + + return $container; +} + +/** + * EndpointResponse::$body is `array<mixed>|string` — narrowed via real control flow, never a suppressing + * inline-PHPDoc or assert() override. Named distinctly from the sibling test files' helpers so a whole-package + * Pest run, which loads every file into one process, has no top-level function collision. + * + * @return array<mixed> + */ +function configPropsJsonBody(EndpointResponse $response): array +{ + if (! is_array($response->body)) { + throw new RuntimeException('Expected a JSON (array) response body.'); + } + + return $response->body; +} + +/** + * The `beans` map, narrowed to string keys and array rows by real checks so the assertions below index a type + * PHPStan can see rather than a bare mixed. + * + * @return array<string, array<mixed>> + */ +function configPropsBeans(EndpointResponse $response): array +{ + $value = configPropsJsonBody($response)['beans'] ?? null; + if (! is_array($value)) { + throw new RuntimeException('Expected [beans] to be an array.'); + } + + $rows = []; + foreach ($value as $class => $row) { + if (! is_array($row)) { + throw new RuntimeException('Expected each [beans] entry to be an array.'); + } + $rows[(string) $class] = $row; + } + + return $rows; +} + +it('lists each bound DTO with its prefix and the values it actually resolved', function () { + $container = configPropsContainer( + [new ConfigPropertiesDescriptor(DemoProperties::class, 'demo')], + ['demo' => [ + 'name' => 'checkout', + // A STRING where the DTO declares int: proves the row shows the COERCED value the application + // will use (250), not the raw config scalar ('250'). + 'retries' => '250', + 'api_token' => 'super-secret-token', // relaxed binding: snake_case -> $apiToken + 'signing_keys' => ['active' => 'PRIVATE-A', 'previous' => 'PRIVATE-B'], + 'endpoint' => ['url' => 'https://demo.test', 'password' => 'hunter2'], + // No 'mode' key on purpose: ReflectionConfigBinder does not coerce a string into a backed enum, so + // the DTO holds its constructor default and the row proves the renderer publishes the enum's + // BACKING VALUE ('strict') rather than 'DemoMode::Strict' or an unencodable object. + ]], + ); + + $beans = configPropsBeans((new ConfigPropsEndpoint($container))->handle(new EndpointRequest('GET', []))); + + expect($beans)->toHaveCount(1) + ->and($beans[DemoProperties::class])->toBe([ + 'class' => DemoProperties::class, + 'prefix' => 'demo', + 'profiles' => [], + 'bound' => true, + 'properties' => [ + 'name' => 'checkout', + 'retries' => 250, + 'apiToken' => '******', + 'signingKeys' => '******', + 'endpoint' => ['url' => 'https://demo.test', 'password' => '******'], + 'mode' => 'strict', + ], + 'error' => null, + ]); +}); + +// The audit finding, pinned: a sensitive key holding an ARRAY must be replaced wholesale, never descended +// into. $signingKeys is the shape that used to leak — `keys` matched the regex but was an array, so each leaf +// was judged on its own harmless name ('active', 'previous') and both private keys rendered in full. +it('masks a sensitive property that holds an array, leaking neither values nor shape', function () { + $container = configPropsContainer( + [new ConfigPropertiesDescriptor(DemoProperties::class, 'demo')], + ['demo' => [ + 'name' => 'checkout', + 'retries' => 1, + 'api_token' => 't', + 'signing_keys' => ['active' => 'PRIVATE-A', 'previous' => 'PRIVATE-B'], + 'endpoint' => ['url' => 'https://demo.test', 'password' => 'hunter2'], + ]], + ); + + // JSON_UNESCAPED_SLASHES so the URL assertion reads the way the dispatch action actually renders it. + $flat = json_encode( + configPropsJsonBody((new ConfigPropsEndpoint($container))->handle(new EndpointRequest('GET', []))), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, + ); + + expect($flat)->not->toContain('PRIVATE-A') + ->and($flat)->not->toContain('PRIVATE-B') + ->and($flat)->not->toContain('active') // the shape of the keyring is withheld too + ->and($flat)->not->toContain('hunter2') + ->and($flat)->toContain('https://demo.test'); +}); + +it('reports a profile-gated DTO as declared-but-unbound instead of dropping it', function () { + $container = configPropsContainer( + [new ConfigPropertiesDescriptor(ProdOnlyProperties::class, 'prodonly', ['prod'])], + ['prodonly' => ['endpoint' => 'https://prod.test']], + ['test'], + ); + + $beans = configPropsBeans((new ConfigPropsEndpoint($container))->handle(new EndpointRequest('GET', []))); + + expect($beans)->toBe([ProdOnlyProperties::class => [ + 'class' => ProdOnlyProperties::class, + 'prefix' => 'prodonly', + 'profiles' => ['prod'], + 'bound' => false, + 'properties' => [], + 'error' => null, + ]]); +}); + +it('reports a DTO that cannot bind on its own row and still renders the others', function () { + $container = configPropsContainer( + [ + new ConfigPropertiesDescriptor(UnbindableProperties::class, 'unbindable'), + new ConfigPropertiesDescriptor(ProdOnlyProperties::class, 'prodonly'), + ], + ['prodonly' => ['endpoint' => 'https://prod.test']], + ); + + $beans = configPropsBeans((new ConfigPropsEndpoint($container))->handle(new EndpointRequest('GET', []))); + + $broken = $beans[UnbindableProperties::class]; + + expect($beans)->toHaveCount(2) + ->and($beans[ProdOnlyProperties::class]['bound'])->toBeTrue() + ->and($beans[ProdOnlyProperties::class]['properties'])->toBe(['endpoint' => 'https://prod.test']) + ->and($broken['bound'])->toBeFalse() + ->and($broken['properties'])->toBe([]) + ->and($broken['error'])->toBeString() + ->and($broken['error'])->toContain('Missing required configuration property [mandatory]'); +}); + +// Deterministic ordering matters more than it looks: the fallback manifest comes from a recursive directory +// walk, so without this sort the same application renders its rows in a different order on two machines. +it('sorts rows by class name so the payload does not depend on scan order', function () { + $container = configPropsContainer([ + new ConfigPropertiesDescriptor(UnbindableProperties::class, 'unbindable'), + new ConfigPropertiesDescriptor(DemoProperties::class, 'demo'), + new ConfigPropertiesDescriptor(ProdOnlyProperties::class, 'prodonly'), + ]); + + $beans = configPropsBeans((new ConfigPropsEndpoint($container))->handle(new EndpointRequest('GET', []))); + + expect(array_keys($beans))->toBe([DemoProperties::class, ProdOnlyProperties::class, UnbindableProperties::class]); +}); + +// A container with no bound manifest, no compiled config-properties.php and no firefly.scan.paths is the bare +// skeleton: the endpoint must answer an empty list, never blow up on the AppScan fallback. +it('answers an empty bean list when the application declares no config properties', function () { + $container = new Container; + $container->instance('config', new Repository(['firefly' => []])); + + $response = (new ConfigPropsEndpoint($container))->handle(new EndpointRequest('GET', [])); + + expect($response->status)->toBe(200) + ->and($response->contentType)->toBe('application/json') + ->and($response->body)->toBe(['beans' => []]); +}); + +it('is exposed as the configprops endpoint id', function () { + $endpoint = new ConfigPropsEndpoint(new Container); + + expect($endpoint->endpointId())->toBe('configprops') + ->and($endpoint->enabled())->toBeTrue(); +}); diff --git a/packages/actuator/tests/Introspection/IntrospectionEndpointsTest.php b/packages/actuator/tests/Introspection/IntrospectionEndpointsTest.php index b6befb7..3472102 100644 --- a/packages/actuator/tests/Introspection/IntrospectionEndpointsTest.php +++ b/packages/actuator/tests/Introspection/IntrospectionEndpointsTest.php @@ -68,6 +68,27 @@ function introspectionRows(array $body, string $key): array ->and($flat)->toContain('******'); }); +// The audit finding, pinned on /env as well as /configprops: the original mask() tested the key ONLY on the +// scalar branch, so a sensitive key holding an ARRAY was recursed into and each leaf judged on its own +// harmless name. A JWT keyring under `firefly.security.jwt.keys` therefore rendered every private key in +// full — and a keyring, a credentials pair or a per-tenant token map is what a real secret actually looks +// like, so the bypass covered the cases that mattered most. +it('masks a sensitive /env key whose value is an array, leaking neither values nor shape', function () { + $repository = new Repository(['firefly' => ['security' => ['jwt' => [ + 'keys' => ['active' => 'PRIVATE-A', 'previous' => 'PRIVATE-B'], + 'issuer' => 'https://auth.local', + ]]]]); + + $body = (new EnvEndpoint($repository))->handle(new EndpointRequest('GET', []))->body; + + $flat = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + expect($flat)->not->toContain('PRIVATE-A') + ->and($flat)->not->toContain('PRIVATE-B') + ->and($flat)->not->toContain('active') + ->and($flat)->toContain('https://auth.local') + ->and($flat)->toContain('******'); +}); + it('lists beans from the boot-time catalog', function () { $catalog = new BeansCatalog([ ['class' => 'App\\Foo', 'stereotype' => 'service', 'scope' => 'Singleton', 'name' => null, 'interfaces' => ['App\\FooPort'], 'beans' => []], diff --git a/packages/actuator/tests/Introspection/SensitiveValueMaskerTest.php b/packages/actuator/tests/Introspection/SensitiveValueMaskerTest.php new file mode 100644 index 0000000..c51920c --- /dev/null +++ b/packages/actuator/tests/Introspection/SensitiveValueMaskerTest.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +use Firefly\Actuator\Introspection\SensitiveValueMasker; + +/** + * The rule itself, tested once, where both /env and /configprops now get it from. Before it was extracted the + * only coverage was an /env test that fed it two scalars — which is precisely why the array bypass survived. + */ +it('masks every spelling the rule names, case-insensitively and as a substring', function () { + $masked = SensitiveValueMasker::mask([ + 'password' => 'p', 'SECRET' => 's', 'apiToken' => 't', 'signing_key' => 'k', + 'credentials' => 'c', 'passwd' => 'w', 'host' => 'db.local', 'port' => 5432, + ]); + + expect($masked)->toBe([ + 'password' => '******', 'SECRET' => '******', 'apiToken' => '******', 'signing_key' => '******', + 'credentials' => '******', 'passwd' => '******', 'host' => 'db.local', 'port' => 5432, + ]); +}); + +// The bug the audit found: the key decides FIRST, so a sensitive key masks its whole subtree instead of being +// descended into and having each leaf judged on its own harmless name. +it('masks a sensitive key that holds an array, without leaking its shape', function () { + expect(SensitiveValueMasker::mask(['keys' => ['active' => 'A', 'previous' => 'B'], 'issuer' => 'auth'])) + ->toBe(['keys' => '******', 'issuer' => 'auth']); +}); + +it('recurses into a non-sensitive key and masks what it finds there', function () { + expect(SensitiveValueMasker::mask(['datasource' => ['host' => 'db.local', 'password' => 'hunter2']])) + ->toBe(['datasource' => ['host' => 'db.local', 'password' => '******']]); +}); + +// Integer keys can never match the pattern, so a list under a harmless key survives intact — the predicate is +// total over array-key rather than needing a separate "is this a list?" branch. +it('leaves a list under a harmless key alone', function () { + expect(SensitiveValueMasker::mask(['hosts' => ['a.local', 'b.local']])) + ->toBe(['hosts' => ['a.local', 'b.local']]); +}); + +it('masks a whole list held under a sensitive key', function () { + expect(SensitiveValueMasker::mask(['tokens' => ['t1', 't2']]))->toBe(['tokens' => '******']); +}); + +it('leaves an empty tree empty', function () { + expect(SensitiveValueMasker::mask([]))->toBe([]); +}); 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/EmptyInfoActuatorCapstoneTestCase.php b/packages/actuator/tests/Support/EmptyInfoActuatorCapstoneTestCase.php new file mode 100644 index 0000000..a081b7b --- /dev/null +++ b/packages/actuator/tests/Support/EmptyInfoActuatorCapstoneTestCase.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Support; + +/** + * The actuator capstone with the runtime info contributor switched OFF, so /actuator/info genuinely has no + * fragment to merge and the empty-body rendering rule can still be asserted at HTTP level. + * + * It needs its OWN boot rather than a config()->set() inside a test body, for the same reason + * ActuatorDisabledCapstoneTestCase and ActuatorEnvExposedCapstoneTestCase do: firefly.management.info.runtime + * .enabled is a #[ConditionalOnProperty] read by the condition pipeline while definitions are being filtered, + * long before any test body runs. Flipping it afterwards would reach nothing — RuntimeInfoContributor would + * already be registered in InfoContributorRegistry. + */ +abstract class EmptyInfoActuatorCapstoneTestCase extends ActuatorCapstoneTestCase +{ + /** + * @return array<string, mixed> + */ + protected function configOverrides(): array + { + return parent::configOverrides() + ['firefly.management.info.runtime.enabled' => false]; + } +} diff --git a/packages/actuator/tests/Support/IntrospectionExposedCapstoneTestCase.php b/packages/actuator/tests/Support/IntrospectionExposedCapstoneTestCase.php new file mode 100644 index 0000000..3572f88 --- /dev/null +++ b/packages/actuator/tests/Support/IntrospectionExposedCapstoneTestCase.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Firefly\Actuator\Tests\Support; + +/** + * The "configprops + caches explicitly exposed" sibling of ActuatorCapstoneTestCase — the same own-boot + * requirement ActuatorEnvExposedCapstoneTestCase documents: ExposureModel's include list is a singleton #[Bean] + * captured once at BootPhase::FlushDefinitions, so an endpoint cannot be exposed from inside a test body. + * + * `firefly.scan.paths` points at this package's own tests/Fixtures directory rather than a hand-bound + * ConfigPropertiesManifest, so the ENTIRE production chain runs for real: FireflyAutoConfigureServiceProvider + * scans the roots, FlushDefinitionsPass hands the result to ConfigRegistrar, ConfigRegistrar binds (or, for + * the #[Profile('prod')] fixture, refuses to bind) each DTO, and ConfigPropsEndpoint independently resolves + * the same manifest through AppScan's cached-then-scanned convention. Binding a manifest directly would have + * skipped both halves and proved only that the renderer can format an array it was handed. + */ +class IntrospectionExposedCapstoneTestCase extends ActuatorCapstoneTestCase +{ + protected function exposureInclude(): string + { + return 'health,info,configprops,caches'; + } + + /** + * @return array<string, mixed> + */ + protected function configOverrides(): array + { + return parent::configOverrides() + [ + 'firefly.scan.paths' => ['Firefly\\Actuator\\Tests\\Fixtures\\' => dirname(__DIR__).'/Fixtures'], + 'demo' => [ + 'name' => 'checkout', + // A string where DemoProperties declares int, and snake_case keys where it declares camelCase + // parameters: between them they prove /configprops reports the value AFTER relaxed binding and + // coercion, which is the whole reason it reads the bound instance instead of the config tree. + 'retries' => '3', + 'api_token' => 'super-secret-token', + 'signing_keys' => ['active' => 'PRIVATE-A'], + 'endpoint' => ['url' => 'https://demo.test', 'password' => 'hunter2'], + ], + 'cache.default' => 'array', + 'cache.stores' => [ + 'array' => ['driver' => 'array', 'serialize' => false], + 'redis' => ['driver' => 'redis', 'connection' => 'cache'], + ], + ]; + } +} 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/.gitattributes b/packages/admin/.gitattributes new file mode 100644 index 0000000..538b69a --- /dev/null +++ b/packages/admin/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.gitattributes export-ignore diff --git a/packages/admin/LICENSE b/packages/admin/LICENSE new file mode 100644 index 0000000..2240005 --- /dev/null +++ b/packages/admin/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/admin/README.md b/packages/admin/README.md new file mode 100644 index 0000000..6fb081d --- /dev/null +++ b/packages/admin/README.md @@ -0,0 +1,80 @@ +# firefly/admin + +The LaraFly admin dashboard — a server-rendered browser view over the actuator, in the spirit of +[Spring Boot Admin](https://docs.spring-boot-admin.com/). + +```bash +composer require firefly/admin +``` + +Then open `/firefly`. + +## What it shows + +| Page | Reads | Answers | +| --- | --- | --- | +| Overview | `health`, `info`, `beans`, `conditions`, `mappings` | Is it up, what booted, and was it compiled or scanned? | +| Beans | `beans` | Every bean the container registered, with stereotype and scope. | +| Conditions | `conditions` | Which auto-configurations applied, and which backed off because you supplied your own. | +| Mappings | `mappings` | The compiled route table the dispatcher serves from. | +| Scheduled | `scheduledtasks` | Methods registered by `#[Scheduled]`, with their cron or fixed rate. | +| Metrics | `metrics` | Counters, timers and gauges, with their current measurements. | +| Loggers | `loggers` | Log channels and their levels, with a control to change one. | +| Environment | `env` | Resolved `firefly.*` configuration, with secrets masked. | + +A page whose endpoint is not registered — or is switched off — is hidden from the menu rather than +offered as a link that lands on an apology. + +## How it reads them + +In-process. The dashboard holds the `ActuatorRegistry` and invokes each `ActuatorEndpoint` bean directly +rather than fetching `/actuator/...` over HTTP. + +That is deliberate. `firefly.management.endpoints.web.exposure.include` defaults to `health,info`, so +fetching beans or env over HTTP would 404 unless you first published them to every caller. The dashboard +needs none of that: it renders what the process already knows, and the JSON surface stays +secure-by-default. + +A per-endpoint kill switch (`firefly.management.endpoint.{id}.enabled`) *is* honoured, because that key +means "this endpoint is off", not "this endpoint is unpublished". + +## Access + +Because the dashboard bypasses exposure, its own URL is the only boundary — so it must not be on by +default in production. + +`firefly.admin.enabled` defaults to the value of `app.debug`. An application already running with debug on +is already serving stack traces and is a development environment by definition. An application with debug +off must opt in explicitly, and **should put the route behind its own auth middleware when it does**. +Setting the key always wins over the debug default, in both directions. + +## Configuration + +| Key | Default | Meaning | +| --- | --- | --- | +| `firefly.admin.enabled` | `app.debug` | Mount the dashboard at all. | +| `firefly.admin.base-path` | `/firefly` | Where it is mounted. Leading and trailing slashes are optional. | +| `firefly.admin.title` | `app.name` | The name shown in the sidebar and the page title. | + +## 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. + +Blade itself *is* required. On a JSON-only deployment with no view factory bound, the dashboard mounts +nothing and leaves the JSON actuator as the management surface, rather than mounting routes that would +fatal on first request. + +## Caveats + +* **Changing a log level affects this process only.** It calls the same endpoint + `POST /actuator/loggers/{name}` does, which mutates the current process's Monolog handlers. Under PHP-FPM + the next request is a different process. Change `logging.channels` for anything that must persist. +* **Metrics are only as durable as your registry.** The default `SimpleMeterRegistry` keeps meters in + process memory, so under PHP-FPM the dashboard sees only its own request. Set + `firefly.observability.metrics.store` to a cache store to accumulate across workers. +* **Health details are hidden unless you ask for them.** Set + `firefly.management.endpoint.health.show-details` to `always` to see each indicator on the overview. + +Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/admin/composer.json b/packages/admin/composer.json new file mode 100644 index 0000000..c5bc295 --- /dev/null +++ b/packages/admin/composer.json @@ -0,0 +1,43 @@ +{ + "name": "firefly/admin", + "description": "LaraFly admin: a self-contained, server-rendered dashboard over the actuator — the Spring Boot Admin analog. Health, beans, conditional auto-configuration back-off, mappings, environment, metrics, loggers with live level editing, and scheduled tasks, rendered from the endpoints already registered in-process. No npm build, no CDN, no network at request time.", + "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", "admin", "actuator", "dashboard"], + "support": { + "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", + "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/admin" + }, + "require": { + "php": "^8.3", + "firefly/actuator": "*@dev", + "firefly/autoconfigure": "*@dev", + "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" + }, + "extra": { + "laravel": { + "providers": [ + "Firefly\\Admin\\AdminServiceProvider" + ] + }, + "branch-alias": { "dev-main": "26.x-dev" } + }, + "autoload": { "psr-4": { "Firefly\\Admin\\": "src/" } }, + "autoload-dev": { "psr-4": { "Firefly\\Admin\\Tests\\": "tests/" } }, + "minimum-stability": "stable", + "config": { "sort-packages": true } +} 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 +<td class="cell t-{{ $type }} @if ($isNull) nil @endif @if ($column->sensitive) secret @endif" title="{{ $text }}"> + @if ($isNull) + <span class="nul">—</span> + @elseif ($type === DataColumn::TYPE_BOOL) + <span class="bool {{ ((int) $value) === 1 ? 'yes' : 'no' }}">{{ $text }}</span> + @elseif ($column->identifier) + <span class="idv">{{ $text }}</span> + @else + <span class="v">{{ $text }}</span> + @endif +</td> diff --git a/packages/admin/resources/views/_empty.blade.php b/packages/admin/resources/views/_empty.blade.php new file mode 100644 index 0000000..4fec3e7 --- /dev/null +++ b/packages/admin/resources/views/_empty.blade.php @@ -0,0 +1,5 @@ +{{-- An empty state says what is missing and what to do about it — never just "no data". --}} +<div class="empty"> + <strong>{{ $title }}</strong> + <p>{!! $body !!}</p> +</div> 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. --}} +<header> + <h2>{{ $title }}</h2> + <span class="spacer"></span> + @isset($filter) + <input class="filter" type="search" data-filter="{{ $filter }}" + placeholder="{{ $placeholder ?? 'Filter…' }}" aria-label="{{ $placeholder ?? 'Filter rows' }}"> + <span class="meta" data-filter-count>{{ $count }}</span> + @else + <span class="meta">{{ $count }}</span> + @endisset +</header> diff --git a/packages/admin/resources/views/beans.blade.php b/packages/admin/resources/views/beans.blade.php new file mode 100644 index 0000000..d3f4df2 --- /dev/null +++ b/packages/admin/resources/views/beans.blade.php @@ -0,0 +1,48 @@ +@extends('firefly-admin::layout') +@section('title', 'Beans') +@section('body') + @php use Firefly\Admin\Format; @endphp + + <div class="head"> + <h1>Beans</h1> + <p>Every bean the container registered, with the stereotype that declared it and the scope it lives in.</p> + </div> + + <div class="panel"> + @include('firefly-admin::_panel-head', [ + 'title' => 'Container', 'count' => count($beans), + 'filter' => 'beans-body', 'placeholder' => 'Filter by class, stereotype or interface…', + ]) + @if ($beans === []) + @include('firefly-admin::_empty', [ + 'title' => 'No beans registered', + 'body' => 'Check <code>firefly.scan.paths</code> points at your application namespace.', + ]) + @else + <div class="tw"> + <table> + <thead><tr><th>Class</th><th>Stereotype</th><th>Scope</th><th>Name</th><th>Implements</th></tr></thead> + <tbody id="beans-body"> + @foreach ($beans as $bean) + @php $class = is_string($bean['class'] ?? null) ? $bean['class'] : ''; @endphp + <tr> + <td class="cls"><span class="nm">{{ Format::shortClass($class) }}</span><span class="ns">{{ rtrim(Format::namespaceOf($class), '\\') }}</span></td> + <td class="mono dim tight">{{ $bean['stereotype'] ?? '' }}</td> + <td class="mono dim tight">{{ $bean['scope'] ?? '' }}</td> + <td class="mono dim tight">{{ $bean['name'] ?: '—' }}</td> + <td class="mono dim wrap"> + @php $interfaces = is_array($bean['interfaces'] ?? null) ? $bean['interfaces'] : []; @endphp + @forelse ($interfaces as $interface) + <div>{{ Format::shortClass((string) $interface) }}</div> + @empty + — + @endforelse + </td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> +@endsection diff --git a/packages/admin/resources/views/caches.blade.php b/packages/admin/resources/views/caches.blade.php new file mode 100644 index 0000000..b72120c --- /dev/null +++ b/packages/admin/resources/views/caches.blade.php @@ -0,0 +1,51 @@ +@extends('firefly-admin::layout') +@section('title', 'Caches') +@section('body') + <div class="head"> + <h1>Caches</h1> + <p>The cache stores this application has configured. Several framework features read one: + <code>firefly.observability.metrics.store</code>, resilience state, and scheduling locks.</p> + </div> + + <div class="panel"> + @include('firefly-admin::_panel-head', [ + 'title' => 'Stores', 'count' => count($stores), + 'filter' => 'caches-body', 'placeholder' => 'Filter stores…', + ]) + @if ($stores === []) + @include('firefly-admin::_empty', [ + 'title' => 'No stores configured', + 'body' => 'Laravel ships a <code>config/cache.php</code> defining several stores. If this is empty, that file is missing.', + ]) + @else + <div class="tw"> + <table> + <thead><tr><th>Store</th><th>Driver</th><th>Default</th></tr></thead> + <tbody id="caches-body"> + @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 + <tr> + <td class="mono tight">{{ $label }}</td> + <td class="mono dim">{{ $driver ?: '—' }}</td> + <td class="tight">@if ($isDefault)<span class="chip up">default</span>@endif</td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> + + <p class="note"> + @if ($defaultStore !== null) + <code>cache.default</code> is <code>{{ $defaultStore }}</code>. + @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 <code>php artisan cache:clear</code>. + </p> +@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..c8f2598 --- /dev/null +++ b/packages/admin/resources/views/conditions.blade.php @@ -0,0 +1,49 @@ +@extends('firefly-admin::layout') +@section('title', 'Conditions') +@section('body') + @php + use Firefly\Admin\Format; + $positive = is_array($positiveMatches ?? null) ? $positiveMatches : []; + $negative = is_array($negativeMatches ?? null) ? $negativeMatches : []; + @endphp + + <div class="head"> + <h1>Conditions</h1> + <p>Auto-configuration wires a capability only until you supply your own bean, then steps aside. + Everything under <em>Backed off</em> is a decision the framework made in your favour.</p> + </div> + + <div class="grid two"> + @foreach ([['Applied', $positive, 'pos-body'], ['Backed off', $negative, 'neg-body']] as [$title, $rows, $id]) + <div class="panel"> + @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 + <div class="tw"> + <table> + <thead><tr><th>Class</th><th>Condition</th></tr></thead> + <tbody id="{{ $id }}"> + @foreach ($rows as $row) + @php $class = is_string($row['class'] ?? null) ? $row['class'] : ''; @endphp + <tr> + <td class="cls"><span class="nm">{{ Format::shortClass($class) }}</span><span class="ns">{{ rtrim(Format::namespaceOf($class), '\\') }}</span></td> + <td class="mono dim tight" title="{{ $row['condition'] ?? '' }}">#[{{ Format::shortClass((string) ($row['condition'] ?? '')) }}]</td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> + @endforeach + </div> +@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..f1e1c4e --- /dev/null +++ b/packages/admin/resources/views/configprops.blade.php @@ -0,0 +1,107 @@ +@extends('firefly-admin::layout') +@section('title', 'Config properties') +@section('body') + @php + use Firefly\Admin\Format; + + // 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 = []; + $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 + + <div class="head"> + <h1>Config properties</h1> + <p>Every <code>#[ConfigProperties]</code> DTO the application bound, with the values it actually + resolved — which is not always what the file says, once relaxed binding and profiles apply.</p> + </div> + + @if ($problems !== []) + <div class="panel"> + @include('firefly-admin::_panel-head', ['title' => 'Not bound', 'count' => count($problems)]) + <div class="tw"> + <table> + <thead><tr><th>Class</th><th>Prefix</th><th>Why</th></tr></thead> + <tbody> + @foreach ($problems as $problem) + <tr> + <td class="cls"><span class="nm">{{ Format::shortClass($problem['class']) }}</span><span class="ns">{{ rtrim(Format::namespaceOf($problem['class']), '\\') }}</span></td> + <td class="mono dim tight">{{ $problem['prefix'] ?: '—' }}</td> + <td class="dim wrap"> + @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 + </td> + </tr> + @endforeach + </tbody> + </table> + </div> + </div> + @endif + + <div class="panel"> + @include('firefly-admin::_panel-head', [ + 'title' => 'Bound values', '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 <code>php artisan make:firefly-config-properties</code>, then re-run <code>firefly:cache</code> if this application boots compiled.', + ]) + @else + <div class="tw"> + <table> + <thead><tr><th>Class</th><th>Prefix</th><th>Property</th><th>Value</th></tr></thead> + <tbody id="props-body"> + @foreach ($rows as $row) + <tr> + <td class="cls"><span class="nm">{{ Format::shortClass($row['class']) }}</span><span class="ns">{{ rtrim(Format::namespaceOf($row['class']), '\\') }}</span></td> + <td class="mono dim tight">{{ $row['prefix'] ?: '—' }}</td> + <td class="mono tight">{{ $row['key'] }}</td> + <td class="mono dim wrap">{{ $row['value'] }}</td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> + + <p class="note">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.</p> +@endsection 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..198cbc1 --- /dev/null +++ b/packages/admin/resources/views/data-list.blade.php @@ -0,0 +1,299 @@ +@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 ?? ''); + + // 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 + + <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> + @if ($relations !== []) + · related: + @foreach ($relations as $relation) + @if ($relation->navigable() && $relation->toMany === false) + <a href="{{ $settings->url('data') }}?resource={{ urlencode($relation->relatedSlug) }}">{{ $relation->shortRelated() }}</a>@if (! $loop->last), @endif + @else + <span class="dim">{{ $relation->shortRelated() ?: $relation->kind }}</span>@if (! $loop->last), @endif + @endif + @endforeach + @endif + </p> + </div> + + @if ($listing->filters !== []) + <p class="tip"> + Showing only rows where + @foreach ($listing->filters as $filter) + <code>{{ $filter->describe() }}</code>@if (! $loop->last) and @endif + @endforeach + · <a href="{{ $base }}{{ $keepSize }}">clear</a> + </p> + @endif + + @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 + + {{-- 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. --}} + <details class="panel filters" @if ($listing->filters !== []) open @endif> + <summary> + <span>Filter</span> + <span class="spacer"></span> + <span class="meta">{{ count($listing->filters) ?: 'none' }}{{ count($listing->filters) ? ' active' : '' }}</span> + </summary> + <form method="get" action="{{ $settings->url('data') }}" class="filterform"> + <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 type="hidden" name="size" value="{{ $listing->perPage }}"> + @if ($listing->search !== null)<input type="hidden" name="q" value="{{ $listing->search }}">@endif + + <div id="frows"> + @php $rows = $listing->filters; $rows[] = null; @endphp + @foreach ($rows as $row) + <div class="frow"> + {{-- 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. --}} + <select name="fc[]" aria-label="Column"> + <option value="">—</option> + @foreach ($schema->columns as $column) + @continue (! in_array($column->name, $schema->filterable(), true)) + <option value="{{ $column->name }}" @selected($row?->column === $column->name)>{{ $column->label() }}</option> + @endforeach + </select> + <select name="fo[]" aria-label="Comparison"> + @foreach ($operators as $id => $label) + <option value="{{ $id }}" @selected($row?->operator === $id)>{{ $label }}</option> + @endforeach + </select> + <input name="fv[]" value="{{ $row?->value }}" placeholder="value" aria-label="Value"> + <button class="drop" type="button" title="Remove this condition" aria-label="Remove this condition">×</button> + </div> + @endforeach + </div> + + <div class="actions"> + <button class="go" type="submit">Apply</button> + <button class="act" type="button" id="fadd">Add condition</button> + @if ($listing->filters !== []) + <a class="act" href="{{ $base }}{{ $keepSize }}">Clear</a> + @endif + <span class="hint">Conditions are combined with <strong>and</strong>.</span> + </div> + </form> + </details> + + <div class="panel"> + <header> + <h2>Records</h2> + <span class="spacer"></span> + @if ($schema->searchable() !== []) + <form method="get" action="{{ $settings->url('data') }}" class="inline"> + <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 type="hidden" name="size" value="{{ $listing->perPage }}"> + {{-- 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) + <input type="hidden" name="fc[]" value="{{ $filter->column }}"> + <input type="hidden" name="fo[]" value="{{ $filter->operator }}"> + <input type="hidden" name="fv[]" value="{{ $filter->value }}"> + @endforeach + <input class="filter" type="search" name="q" value="{{ $listing->search }}" placeholder="Search…" aria-label="Search records"> + </form> + @endif + @if ($writable && $resource?->isEloquentBacked()) + <a class="act" href="{{ $base }}&new=1">New record</a> + @endif + <span class="meta">{{ number_format($listing->total) }} total</span> + </header> + + @if ($listing->isEmpty()) + @include('firefly-admin::_empty', [ + 'title' => $listing->search !== null || $listing->filters !== [] ? 'Nothing matches' : 'No records yet', + 'body' => $listing->search !== null || $listing->filters !== [] + ? 'Loosen a condition, or <a href="'.e($base).'">clear them all</a>.' + : 'This resource has no rows.', + ]) + @else + <div class="tw"> + <table class="datatable"> + <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 class="t-{{ $column->type }} @if ($column->identifier) idcol @endif"> + @if ($sortable) + <a href="{{ $base }}&sort={{ urlencode($column->name) }}&dir={{ $next }}{{ $keepSearch }}{{ $keepFilter }}{{ $keepSize }}"> + {{ $column->label() }}<span class="ord">{{ $isSorted ? ($listing->direction === 'asc' ? '↑' : '↓') : '' }}</span> + </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) + @include('firefly-admin::_cell', ['value' => $row[$column->name] ?? null, 'column' => $column, 'base' => $base]) + @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> + + @php + $keep = $keepSort.$keepSearch.$keepFilter.$keepSize; + $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 + <div class="pager"> + <span class="range"> + {{ number_format($from) }}–{{ number_format($to) }} of {{ number_format($listing->total) }} + @if ($last > 1) · page {{ $listing->page }} of {{ number_format($last) }} @endif + </span> + <form method="get" action="{{ $settings->url('data') }}" class="inline"> + <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 }}"> + @if ($listing->search !== null)<input type="hidden" name="q" value="{{ $listing->search }}">@endif + @foreach ($listing->filters as $filter) + <input type="hidden" name="fc[]" value="{{ $filter->column }}"> + <input type="hidden" name="fo[]" value="{{ $filter->operator }}"> + <input type="hidden" name="fv[]" value="{{ $filter->value }}"> + @endforeach + <label class="sizer"> + <span>Rows</span> + <select name="size" onchange="this.form.submit()" aria-label="Rows per page"> + @foreach ([10, 25, 50, 100, 200] as $size) + <option value="{{ $size }}" @selected($listing->perPage === $size)>{{ $size }}</option> + @endforeach + </select> + </label> + </form> + <span class="spacer"></span> + @if ($last > 1) + <a class="act @if (! $listing->hasPrevious()) off @endif" + @if ($listing->hasPrevious()) href="{{ $base }}&page={{ $listing->page - 1 }}{{ $keep }}" @endif>Previous</a> + @if ($window[0] > 1) + <a class="act" href="{{ $base }}&page=1{{ $keep }}">1</a> + @if ($window[0] > 2)<span class="gap">…</span>@endif + @endif + @foreach ($window as $n) + <a class="act @if ($n === $listing->page) on @endif" href="{{ $base }}&page={{ $n }}{{ $keep }}">{{ $n }}</a> + @endforeach + @if (end($window) < $last) + @if (end($window) < $last - 1)<span class="gap">…</span>@endif + <a class="act" href="{{ $base }}&page={{ $last }}{{ $keep }}">{{ $last }}</a> + @endif + <a class="act @if (! $listing->hasNext()) off @endif" + @if ($listing->hasNext()) href="{{ $base }}&page={{ $listing->page + 1 }}{{ $keep }}" @endif>Next</a> + @endif + </div> + @endif + </div> + @endif +@endsection + +@push('scripts') +<script> + // ADDING A CONDITION MUST NOT REQUIRE SUBMITTING ONE. The bar renders the applied filters plus one empty + // row, which meant a second condition could only be reached by applying the first — so an "A and B" + // query took two round trips and a first result nobody wanted. Cloning the last row is the whole fix, + // and it is progressive: with scripts off the form still works, it just offers one row at a time. + (function () { + var rows = document.getElementById('frows'); + var add = document.getElementById('fadd'); + if (!rows || !add) { return; } + + function blank() { + var last = rows.lastElementChild; + var copy = last.cloneNode(true); + copy.querySelectorAll('select').forEach(function (select) { select.selectedIndex = 0; }); + copy.querySelectorAll('input').forEach(function (input) { input.value = ''; }); + + return copy; + } + + add.addEventListener('click', function () { + var copy = blank(); + rows.appendChild(copy); + var first = copy.querySelector('select'); + if (first) { first.focus(); } + }); + + // Delegated, so it also covers the rows added above. Removing the LAST row would leave nothing to + // clone, so it is emptied in place instead — the control never leaves the form unusable. + rows.addEventListener('click', function (event) { + var button = event.target.closest('.drop'); + if (!button) { return; } + + var row = button.closest('.frow'); + if (rows.children.length > 1) { + row.remove(); + + return; + } + + row.querySelectorAll('select').forEach(function (select) { select.selectedIndex = 0; }); + row.querySelectorAll('input').forEach(function (input) { input.value = ''; }); + }); + })(); +</script> +@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 + + <div class="head"> + <h1>Entity map</h1> + <p>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 <em>holds</em> the key to the table it references.</p> + </div> + + <dl class="stats"> + <div class="stat"><dt>Entities</dt><dd>{{ count($map->nodes) }}</dd></div> + <div class="stat"><dt>Foreign keys</dt><dd>{{ count($map->edges) }}</dd></div> + <div class="stat"><dt>Levels</dt><dd>{{ count($map->levelsPresent()) }}</dd></div> + <div class="stat"><dt>Cycles</dt><dd class="{{ $map->cycles === [] ? '' : 'bad' }}">{{ count($map->cycles) }}</dd></div> + </dl> + + @if ($map->isEmpty()) + <div class="panel"> + @include('firefly-admin::_empty', [ + 'title' => 'No entities to map', + 'body' => 'No bean implements <code>CrudRepository</code>, so there is nothing to draw. Declare a + repository — <code>extends EloquentRepository</code> plus a model name — and it + appears here and in the browser at the same time.', + ]) + </div> + @else + <div class="panel"> + <header> + <h2>Schema</h2> + <span class="spacer"></span> + <span class="meta">{{ count($map->nodes) }} entities · {{ count($map->edges) }} keys</span> + </header> + <div class="mapwrap"> + <svg class="emap" role="img" aria-label="Entity relationship diagram" + width="{{ $canvasW }}" height="{{ $canvasH }}" viewBox="0 0 {{ $canvasW }} {{ $canvasH }}"> + <defs> + <marker id="emarw" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> + <path d="M0,0 L8,4 L0,8 z" fill="currentColor"/> + </marker> + </defs> + + @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 + <g class="ed"> + <path class="eline" d="{{ $d }}" marker-end="url(#emarw)"/> + <text class="elabel" x="{{ (int) (($x1 + $x2) / 2) }}" y="{{ (int) $mid }}" text-anchor="middle">{{ $edge['column'] }}</text> + </g> + @endforeach + + @foreach ($placed as $slug => $box) + @php $node = $box['node']; @endphp + <a href="{{ $settings->url('data') }}?resource={{ urlencode($slug) }}"> + <g class="ent" transform="translate({{ $box['x'] }},{{ $box['y'] }})"> + <rect class="ebox" width="{{ $box['w'] }}" height="{{ $box['h'] }}" rx="8"/> + <rect class="ehead" width="{{ $box['w'] }}" height="{{ $headH }}" rx="8"/> + <text class="ename" x="12" y="18">{{ $node['label'] }}</text> + <text class="etable" x="12" y="32">{{ $node['table'] ?: Format::shortClass($node['entity']) }}</text> + @foreach ($node['columns'] as $i => $column) + <text class="ecol {{ $column['identifier'] ? 'key' : '' }}" + x="12" y="{{ $headH + $padY + $rowH * $i + 4 }}">{{ $column['identifier'] ? '● ' : '' }}{{ $column['name'] }}</text> + <text class="etype" x="{{ $box['w'] - 12 }}" y="{{ $headH + $padY + $rowH * $i + 4 }}" text-anchor="end">{{ $column['type'] }}</text> + @endforeach + @if ($node['more'] > 0) + <text class="emore" x="12" y="{{ $headH + $padY + $rowH * count($node['columns']) + 4 }}">+{{ $node['more'] }} more</text> + @endif + </g> + </a> + @endforeach + </svg> + </div> + <p class="note"> + 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. + </p> + </div> + + @if ($map->cycles !== []) + <div class="panel"> + @include('firefly-admin::_panel-head', ['title' => 'Cycles', 'count' => count($map->cycles)]) + <div class="tw"> + <table> + <thead><tr><th>From</th><th>To</th></tr></thead> + <tbody> + @foreach ($map->cycles as $cycle) + <tr><td class="mono">{{ $cycle['from'] }}</td><td class="mono">{{ $cycle['to'] }}</td></tr> + @endforeach + </tbody> + </table> + </div> + <p class="note">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.</p> + </div> + @endif + @endif +@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-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 + + <div class="head"> + <h1>New {{ strtolower($resource->label) }}</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 + + @if (! $writable) + <div class="panel"> + @include('firefly-admin::_empty', [ + 'title' => 'The browser is read-only', + 'body' => 'Set <code>firefly.admin.data.writable</code> to permit writes. It is a separate key from + <code>firefly.admin.data.enabled</code> on purpose: switching the browser on never + silently makes it writable.', + ]) + </div> + @elseif (! $resource->isEloquentBacked()) + <div class="panel"> + @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.', + ]) + </div> + @else + <div class="panel"> + @include('firefly-admin::_panel-head', [ + 'title' => 'Fields', + 'count' => count(array_filter($schema->columns, fn ($c) => $c->isEditable())), + ]) + <form method="post" action="{{ $settings->url('data') }}" class="editor"> + @csrf + <input type="hidden" name="resource" value="{{ $resource->slug }}"> + <input type="hidden" name="op" value="create"> + + @foreach ($schema->columns as $column) + @continue (! $column->isEditable()) + <label> + <span>{{ $column->label() }} <em>{{ $column->type }}{{ $column->nullable ? '?' : '' }}</em></span> + <input name="f[{{ $column->name }}]" + @if ($column->type === 'int' || $column->type === 'float') inputmode="decimal" @endif + placeholder="{{ $column->nullable ? 'optional' : 'required' }}"> + </label> + @endforeach + + <div class="actions"> + <button class="go" type="submit">Create record</button> + <a class="act" href="{{ $base }}">Cancel</a> + </div> + </form> + {{-- 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. --}} + <p class="note"> + The identifier is assigned by the database, and masked columns are never written from here — + both are omitted rather than shown and ignored. + </p> + </div> + @endif +@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..f3c94f7 --- /dev/null +++ b/packages/admin/resources/views/data-record.blade.php @@ -0,0 +1,143 @@ +@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 ($relations !== []) + <div class="panel"> + @include('firefly-admin::_panel-head', ['title' => 'Related', 'count' => count($relations)]) + <div class="tw"> + <table> + <thead><tr><th>Relation</th><th>Kind</th><th>Entity</th><th>Joined on</th><th></th></tr></thead> + <tbody> + @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 + <tr> + <td class="mono tight">{{ $relation->label }}</td> + <td class="mono dim tight">{{ $relation->kind }}</td> + <td class="mono">{{ $relation->shortRelated() ?: '—' }}</td> + <td class="mono dim"> + @if ($relation->column !== '') + {{ $relation->toMany ? $relation->shortRelated().'.'.$relation->column : $relation->column }} + @if ($relation->target !== '') → {{ $relation->target }} @endif + @else + — + @endif + </td> + <td class="tight"> + @if ($href !== null) + <a class="act" href="{{ $href }}">{{ $relation->toMany ? 'Browse' : 'Open' }} →</a> + @endif + </td> + </tr> + @endforeach + </tbody> + </table> + </div> + @if (collect($relations)->every(fn ($r) => ! $r->navigable())) + <p class="note">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.</p> + @endif + </div> + @endif + + @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/datasource.blade.php b/packages/admin/resources/views/datasource.blade.php new file mode 100644 index 0000000..a30df8a --- /dev/null +++ b/packages/admin/resources/views/datasource.blade.php @@ -0,0 +1,197 @@ +@extends('firefly-admin::layout') +@section('title', 'Datasource') +@section('body') + <div class="head"> + <h1>Datasource</h1> + <p>Where this application's data lives, how it holds the connection open, and what + <code>#[Transactional]</code> compiled to. Connection settings come from Laravel's + <code>config/database.php</code>; secrets are masked with the same rule the actuator's + <code>env</code> endpoint uses.</p> + </div> + + @if (! $available) + <div class="panel"> + @include('firefly-admin::_empty', [ + 'title' => 'No database manager is bound', + 'body' => 'This application never resolved <code>illuminate/database</code>, 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.', + ]) + </div> + @else + + {{-- Whether the default connection actually answers is the first thing anyone wants, so it leads. --}} + @isset($probe) + <div class="panel"> + <header> + <h2>Connectivity</h2> + <span class="spacer"></span> + <span class="chip {{ $probe['up'] ? 'up' : 'down' }}">{{ $probe['up'] ? 'UP' : 'DOWN' }}</span> + </header> + <dl class="stats"> + <div class="stat"><dt>Connection</dt><dd class="sm">{{ $probe['name'] }}</dd></div> + <div class="stat"><dt>Server</dt><dd class="sm">{{ $probe['version'] !== '' ? $probe['version'] : '—' }}</dd></div> + </dl> + <p class="note">{{ $probe['detail'] }}</p> + </div> + @endisset + + <div class="panel"> + @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' => '<code>config/database.php</code> defines no connections at all.', + ]) + @else + <div class="tw"> + <table> + <thead><tr><th>Name</th><th>Driver</th><th>Target</th><th>Settings</th><th></th></tr></thead> + <tbody id="conn-body"> + @foreach ($connections as $connection) + <tr> + <td class="mono tight"> + {{ $connection['name'] }} + @if ($connection['default'])<span class="chip up">default</span>@endif + </td> + <td class="mono dim tight">{{ $connection['driver'] }}</td> + <td class="mono">{{ $connection['target'] }}</td> + <td> + @foreach ($connection['summary'] as $key => $value) + <span class="pair"><b>{{ $key }}</b>{{ $value }}</span> + @endforeach + @foreach ($connection['options'] as $key => $value) + <span class="pair opt"><b>{{ $key }}</b>{{ $value }}</span> + @endforeach + </td> + <td class="tight"> + @if ($probeEnabled) + <a class="act" href="?probe={{ urlencode($connection['name']) }}">Test</a> + @endif + </td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> + + <div class="panel"> + @include('firefly-admin::_panel-head', ['title' => 'Connection reuse', 'count' => count($pooling)]) + <div class="tw"> + <table> + <thead><tr><th>Connection</th><th>Persistent</th><th>What that means</th></tr></thead> + <tbody> + @foreach ($pooling as $row) + <tr> + <td class="mono tight">{{ $row['name'] }}</td> + <td class="tight"><span class="chip {{ $row['persistent'] ? 'up' : '' }}">{{ $row['persistent'] ? 'on' : 'off' }}</span></td> + <td class="dim">{{ $row['note'] }}</td> + </tr> + @endforeach + </tbody> + </table> + </div> + {{-- 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. --}} + <p class="note"> + PHP has no connection pool. What exists is PDO's <code>ATTR_PERSISTENT</code>, 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. + </p> + </div> + + {{-- 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()) + <details class="panel filters" @if ($trial !== null) open @endif> + <summary> + <span>Try a connection</span> + <span class="spacer"></span> + <span class="meta">nothing is written</span> + </summary> + + @isset($trial) + <p class="tip {{ $trial['ok'] ? '' : 'warnbox' }}" style="margin:14px 16px 0"> + <strong>{{ $trial['ok'] ? 'Works' : 'Failed' }}.</strong> + {{ $trial['message'] }} + @if ($trial['version'] !== '') <span class="dim">· server {{ $trial['version'] }}</span>@endif + </p> + @if ($trial['snippet'] !== '') + <pre class="snippet">{{ $trial['snippet'] }}</pre> + @endif + @endisset + + <form method="post" action="{{ $settings->url('datasource') }}" class="filterform"> + @csrf + <div class="frow"> + <select name="driver" aria-label="Driver"> + @foreach ($wizard->drivers() as $driver) + <option value="{{ $driver }}" @selected(($trialInput['driver'] ?? '') === $driver)>{{ $driver }}</option> + @endforeach + </select> + <input name="host" value="{{ $trialInput['host'] ?? '' }}" placeholder="host (127.0.0.1)" aria-label="Host"> + <input name="port" value="{{ $trialInput['port'] ?? '' }}" placeholder="port" aria-label="Port" inputmode="numeric"> + </div> + <div class="frow"> + <input name="database" value="{{ $trialInput['database'] ?? '' }}" placeholder="database" aria-label="Database"> + <input name="username" value="{{ $trialInput['username'] ?? '' }}" placeholder="username" aria-label="Username"> + <input name="password" type="password" placeholder="password" aria-label="Password"> + </div> + <div class="actions"> + <button class="go" type="submit">Test connection</button> + <span class="hint">The settings are used for this request only — nothing is saved, and the + password never appears in the snippet.</span> + </div> + </form> + </details> + @elseif ($wizard->isProduction()) + <p class="note"> + 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. + </p> + @endif + + <div class="panel"> + @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 <code>#[Transactional]</code>, or <code>firefly:cache</code> has + not run since one was added. The manifest is compiled, so a new annotation is + invisible until it is recompiled.', + ]) + @else + <div class="tw"> + <table> + <thead><tr><th>Class</th><th>Method</th><th>Propagation</th><th>Isolation</th><th>Read only</th><th>Timeout</th><th>Connection</th></tr></thead> + <tbody id="tx-body"> + @foreach ($transactional as $row) + <tr> + <td class="mono cls">{{ $row['class'] }}</td> + <td class="mono tight">{{ $row['method'] }}()</td> + <td class="mono dim tight">{{ $row['propagation'] }}</td> + <td class="mono dim tight">{{ $row['isolation'] }}</td> + <td class="tight">@if ($row['readOnly'])<span class="chip">yes</span>@endif</td> + <td class="mono dim tight">{{ $row['timeout'] }}</td> + <td class="mono dim tight">{{ $row['connection'] }}</td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> + + @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..09927ed --- /dev/null +++ b/packages/admin/resources/views/env.blade.php @@ -0,0 +1,36 @@ +@extends('firefly-admin::layout') +@section('title', 'Environment') +@section('body') + <div class="head"> + <h1>Environment</h1> + <p>Resolved <code>firefly.*</code> configuration as this process sees it. Keys that look secret are + masked by the endpoint before they reach this page.</p> + </div> + + <div class="panel"> + @include('firefly-admin::_panel-head', [ + 'title' => 'Configuration', 'count' => count($env), + 'filter' => 'env-body', 'placeholder' => 'Filter by key or value…', + ]) + @if ($env === []) + @include('firefly-admin::_empty', [ + 'title' => 'Nothing set', + 'body' => 'No <code>firefly.*</code> configuration is present. The skeleton ships a documented reference at <code>config/firefly.php</code>.', + ]) + @else + <div class="tw"> + <table> + <thead><tr><th>Key</th><th>Value</th></tr></thead> + <tbody id="env-body"> + @foreach ($env as $key => $value) + <tr> + <td class="mono wrap">{{ $key }}</td> + <td class="mono dim wrap">{{ $value }}</td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> +@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..a568cbb --- /dev/null +++ b/packages/admin/resources/views/graph.blade.php @@ -0,0 +1,436 @@ +@extends('firefly-admin::layout') +@section('title', 'Bean graph') +@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)]; + } + + $byLevel = []; + foreach ($graph->nodes as $node) { $byLevel[$node['level']][] = $node; } + ksort($byLevel); + + // 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); + + $at = []; + $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>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> + </div> + </dl> + + @if ($graph->cycles !== []) + <div class="panel" style="margin-top:16px"> + @include('firefly-admin::_panel-head', ['title' => 'Circular dependencies', 'count' => count($graph->cycles)]) + <div class="tw"> + <table> + <thead><tr><th>Bean</th><th>Depends on</th></tr></thead> + <tbody> + @foreach ($graph->cycles as $cycle) + <tr> + <td class="mono">{{ Format::shortClass($cycle['from']) }}</td> + <td class="mono">{{ Format::shortClass($cycle['to']) }}</td> + </tr> + @endforeach + </tbody> + </table> + </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 depending on an interface and letting the other side provide it.</p> + </div> + @endif + + <div class="panel" style="margin-top:16px"> + <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', [ + 'title' => 'No beans to graph', + 'body' => 'Check <code>firefly.scan.paths</code> 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 <code>firefly.admin.graph.max-nodes</code> to draw it anyway.', + ]) + @else + <div class="legend"> + @foreach ($modules as $module) + <button class="mod" type="button" data-module="{{ $module }}" aria-pressed="true" + style="--hue:{{ $moduleHue[$module] }}"> + <i></i>{{ $module }} + </button> + @endforeach + </div> + + <div class="graph"> + <div class="canvas" id="g-canvas"> + <svg id="g-svg" role="img" + aria-label="Bean dependency graph: {{ count($graph->nodes) }} beans, {{ count($graph->edges) }} relations"> + <defs> + <marker id="arw" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> + <path d="M0,0 L8,4 L0,8 z" fill="currentColor"/> + </marker> + </defs> + <g id="g-pan"> + <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['type'] }}{{ $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(#arw)"> + <title>{{ 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 + + + +
Drag to pan · scroll to zoom · click a bean to focus it
+ + + + + @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 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 + +
BeanDepends onWired by
{{ 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']) : '—' }}
+
+ @endif +
+ + @if ($graph->unresolved !== []) +
+ @include('firefly-admin::_panel-head', ['title' => 'Provided outside the container', 'count' => count($graph->unresolved)]) +
+ @foreach ($graph->unresolved as $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 + database connection — so they are not drawn as nodes.

+
+ @endif + + @push('scripts') + + @endpush +@endsection 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 +
+ + + + @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 new file mode 100644 index 0000000..9b0eeaa --- /dev/null +++ b/packages/admin/resources/views/layout.blade.php @@ -0,0 +1,707 @@ +{{-- + 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. +--}} + + + + + + + + @yield('title', 'Admin') · {{ $settings->title }} + + + + +
+
+ {{ $settings->title }}admin + @hasSection('topchips')@yield('topchips')@endif + + + +
+ + + +
+ @yield('body') +
+
+ + + +{{-- + 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/loggers.blade.php b/packages/admin/resources/views/loggers.blade.php new file mode 100644 index 0000000..ec8e262 --- /dev/null +++ b/packages/admin/resources/views/loggers.blade.php @@ -0,0 +1,62 @@ +@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.

+
+ +
+ @include('firefly-admin::_panel-head', [ + 'title' => 'Channels', 'count' => count($channels), + 'filter' => 'log-body', 'placeholder' => 'Filter channels…', + ]) + @if ($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 + + + + + + @endforeach + +
ChannelLevelSet
{{ $name }}{{ $level }} +
+ @csrf + + + +
+
+
+ @endif +
+ + {{-- + 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. + --}} +

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..ed4d27b --- /dev/null +++ b/packages/admin/resources/views/mappings.blade.php @@ -0,0 +1,41 @@ +@extends('firefly-admin::layout') +@section('title', 'Routes') +@section('body') + @php use Firefly\Admin\Format; @endphp + +
+

Routes

+

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

+
+ +
+ @include('firefly-admin::_panel-head', [ + 'title' => 'Mappings', 'count' => count($mappings), + 'filter' => 'map-body', 'placeholder' => 'Filter by path or handler…', + ]) + @if ($mappings === []) + @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 + +
MethodPathHandlerName
{{ $route['httpMethod'] ?? '' }}{{ $route['path'] ?? '' }}{{ Format::shortClass($handler) }}{{ rtrim(Format::namespaceOf($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..7703e96 --- /dev/null +++ b/packages/admin/resources/views/metrics.blade.php @@ -0,0 +1,54 @@ +@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. Units are inferred from the + meter name, the same convention the Prometheus exposition uses.

+
+ +
+ @include('firefly-admin::_panel-head', [ + 'title' => 'Meters', 'count' => count($metrics), + 'filter' => 'metrics-body', 'placeholder' => 'Filter meters…', + ]) + @if ($metrics === []) + @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 +
+ + + + @foreach ($metrics as $metric) + @forelse ($metric['rows'] as $row) + + + + + + + @empty + + + + + @endforelse + @endforeach + +
MeterStatisticValueRelative
{{ $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
+
+ @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..0868d03 --- /dev/null +++ b/packages/admin/resources/views/missing.blade.php @@ -0,0 +1,11 @@ +@extends('firefly-admin::layout') +@section('title', 'Not found') +@section('body') +

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 new file mode 100644 index 0000000..f87f7b2 --- /dev/null +++ b/packages/admin/resources/views/overview.blade.php @@ -0,0 +1,154 @@ +@extends('firefly-admin::layout') +@section('title', 'Overview') + +@section('topchips') + {{ $aggregate }} + {{ $bootMode }} +@endsection + +@section('body') + @php + use Firefly\Admin\Format; + $down = array_values(array_filter($indicators, fn ($i) => $i['status'] !== 'UP')); + @endphp + +
+

Overview

+

Health, runtime and what this process wired at boot.

+
+ +
+
+
Health
+
{{ $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 — right while developing. + Run php artisan firefly:cache before deploying for a zero-reflection boot.

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

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..593a552 --- /dev/null +++ b/packages/admin/resources/views/scheduled.blade.php @@ -0,0 +1,39 @@ +@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 + drives them.

+
+ +
+ @include('firefly-admin::_panel-head', ['title' => 'Tasks', 'count' => count($tasks)]) + @if ($tasks === []) + @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 + +
RunnableCronFixed rateFixed delayZone
{{ Format::shortClass($runnable) }}{{ rtrim(Format::namespaceOf($runnable), '\\') }}{{ $task['cron'] ?: '—' }}{{ $task['fixedRate'] ?: '—' }}{{ $task['fixedDelay'] ?: '—' }}{{ $task['zone'] ?: '—' }}
+
+ @endif +
+@endsection 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/resources/views/unavailable.blade.php b/packages/admin/resources/views/unavailable.blade.php new file mode 100644 index 0000000..5f84f95 --- /dev/null +++ b/packages/admin/resources/views/unavailable.blade.php @@ -0,0 +1,11 @@ +@extends('firefly-admin::layout') +@section('title', 'Unavailable') +@section('body') +

{{ $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.', + ]) +
+@endsection diff --git a/packages/admin/src/AdminEndpointReader.php b/packages/admin/src/AdminEndpointReader.php new file mode 100644 index 0000000..9640ad7 --- /dev/null +++ b/packages/admin/src/AdminEndpointReader.php @@ -0,0 +1,154 @@ +}> + */ + 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. + * + * @return list + */ + 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..46e2ca0 --- /dev/null +++ b/packages/admin/src/AdminServiceProvider.php @@ -0,0 +1,77 @@ +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 + */ + 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..c3b9f1c --- /dev/null +++ b/packages/admin/src/AdminSettings.php @@ -0,0 +1,94 @@ + $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 + { + $base = trim($config->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')), + // 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 + { + return '/'.$this->basePath.($page === '' ? '' : '/'.$page); + } +} diff --git a/packages/admin/src/BeanGraph.php b/packages/admin/src/BeanGraph.php new file mode 100644 index 0000000..ec45faa --- /dev/null +++ b/packages/admin/src/BeanGraph.php @@ -0,0 +1,223 @@ + $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 + * @param array $configProperties rows as the configprops endpoint publishes them, keyed by class + */ + public static function build(array $beans, array $configProperties = []): self + { + $index = new BeanGraphIndex; + + foreach ($beans as $row) { + if (! is_array($row) || ! is_string($row['class'] ?? null)) { + continue; + } + $index->addComponent($row); + } + + 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, $unresolved] = $index->edges(); + [$levels, $cycles] = self::levels($index->ids(), $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 ($index->nodes() as $id => $node) { + $nodes[] = [ + ...$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, $unresolved); + } + + /** + * Kept for the older two-argument shape. + * + * @param array $beans + */ + public static function fromCatalog(array $beans): self + { + return self::build($beans); + } + + /** @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 $counts; + } + + /** + * The namespace roots present, most-populated first — the drawing colours by module, and a legend has to + * name them. + * + * @return list + */ + public function modules(): array + { + $counts = []; + foreach ($this->nodes as $node) { + $module = self::moduleOf($node['id']); + $counts[$module] = ($counts[$module] ?? 0) + 1; + } + + 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. 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 $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, []); + } + + // 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 what nothing depends on. + $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/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/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php new file mode 100644 index 0000000..6f6744d --- /dev/null +++ b/packages/admin/src/Boot/AdminRouteRegistrar.php @@ -0,0 +1,164 @@ +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, + )); + // 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. + // 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); + $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), + $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)), + $container->make(DataBrowser::class), + $container->make(DatasourceReport::class), + $container->make(SettingsConsole::class), + $container->make(ConnectionWizard::class), + )); + + /** @var Router $router */ + $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/ConnectionWizard.php b/packages/admin/src/Data/ConnectionWizard.php new file mode 100644 index 0000000..fbf8624 --- /dev/null +++ b/packages/admin/src/Data/ConnectionWizard.php @@ -0,0 +1,229 @@ + 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') { + // 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 [ + '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/Data/DataBrowser.php b/packages/admin/src/Data/DataBrowser.php new file mode 100644 index 0000000..5942ffc --- /dev/null +++ b/packages/admin/src/Data/DataBrowser.php @@ -0,0 +1,677 @@ +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 + */ + 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. + * + * @param list $filters + */ + public function list( + string $slug, + int $page = 1, + ?int $perPage = null, + ?string $sort = null, + string $direction = 'asc', + ?string $search = null, + array $filters = [], + ): 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, $this->validFilters($filters, $schema)); + } + + /** + * Filters the resource can actually answer, with everything else dropped. + * + * 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 + { + // 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, + static fn (DataFilter $filter): bool => in_array($filter->column, $columns, true) + && DataFilter::isOperator($filter->operator) + && ($filter->value !== '' || ! $filter->needsValue()), + )); + } + + /** + * 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('/(?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 `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. + * + * 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 $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 `` 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); + } + + /** + * 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 + */ + 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, + // 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), + 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|null $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|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/DataBrowserSettings.php b/packages/admin/src/Data/DataBrowserSettings.php new file mode 100644 index 0000000..7734f88 --- /dev/null +++ b/packages/admin/src/Data/DataBrowserSettings.php @@ -0,0 +1,112 @@ + $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 bool $relations = true, + ) {} + + 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', '')), + // 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), + ); + } + + /** + * 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..e38adfa --- /dev/null +++ b/packages/admin/src/Data/DataColumn.php @@ -0,0 +1,107 @@ + 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'; + + /* + | 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'; + + 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_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..ca68a90 --- /dev/null +++ b/packages/admin/src/Data/DataFilter.php @@ -0,0 +1,113 @@ + 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 $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 new file mode 100644 index 0000000..f521821 --- /dev/null +++ b/packages/admin/src/Data/DataListing.php @@ -0,0 +1,98 @@ +> $rows each row keyed by column name, in schema column order + * @param list $filters + */ + 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, + 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. + */ + 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 + */ + public function columns(): array + { + return $this->schema === null ? [] : $this->schema->columns; + } +} 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/Data/DataQueryEngine.php b/packages/admin/src/Data/DataQueryEngine.php new file mode 100644 index 0000000..d63deb9 --- /dev/null +++ b/packages/admin/src/Data/DataQueryEngine.php @@ -0,0 +1,577 @@ + $repository + * @param list $filters + */ + public function list( + CrudRepository $repository, + DataResource $resource, + DataSchema $schema, + int $page, + int $perPage, + ?string $sort, + string $direction, + ?string $search, + array $filters = [], + ): 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, $filters); + + $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, null, $filters); + } + + /** + * 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 $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 $repository + * @param list $filters + * @return array{0: list, 1: int} + */ + private function fetch( + CrudRepository $repository, + DataSchema $schema, + int $page, + int $perPage, + ?string $sort, + string $direction, + ?string $term, + array $filters = [], + ): array { + $pageable = new Pageable($page, $perPage, $this->sort($sort, $direction)); + + if (($term !== null || $filters !== []) && $repository instanceof EloquentRepository) { + $specifications = []; + + if ($term !== null) { + $columns = $this->searchColumns($schema); + if ($columns === []) { + return [[], 0]; + } + $specifications[] = $this->searchSpecification($columns, $term); + } + + foreach ($filters as $filter) { + $specifications[] = $this->filterSpecification($filter); + } + + // 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 && $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, $filters); + } + + /** + * One filter as a predicate on the repository's own builder, so anything its `query()` seam already + * constrained still holds. + * + * 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 { + $escaped = self::escapeLike($filter->value); + + match ($filter->operator) { + DataFilter::NE => $query->where($filter->column, '!=', $filter->value), + 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), + DataFilter::NOT_NULL => $query->whereNotNull($filter->column), + default => $query->where($filter->column, '=', $filter->value), + }; + }); + } + + /** + * 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 + * on the first page. + * + * @param CrudRepository $repository + * @param list $filters + * @return array{0: list, 1: int} + */ + private function fetchInPhp( + CrudRepository $repository, + DataSchema $schema, + int $page, + int $perPage, + ?string $sort, + string $direction, + ?string $term, + array $filters = [], + ): 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; + } + + // 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; + } + + $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 $columns + * @return Specification + */ + private function searchSpecification(array $columns, string $term): Specification + { + 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) { + self::like($group, $column, '%'.self::escapeLike($term).'%', 'or'); + } + }); + }); + } + + /** + * @param array $values + * @param list $filters + */ + private function passes(array $values, array $filters): bool + { + 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; + } + + /** + * @param array $values + * @param list $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 */ + 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 + */ + 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 + */ + 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 @@ + $fields masked, in schema column order + */ + public function __construct( + public DataResource $resource, + public DataSchema $schema, + public int|string $id, + public array $fields, + ) {} + + /** + * @return list + */ + 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/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/DataResource.php b/packages/admin/src/Data/DataResource.php new file mode 100644 index 0000000..c82206d --- /dev/null +++ b/packages/admin/src/Data/DataResource.php @@ -0,0 +1,53 @@ +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..d114c34 --- /dev/null +++ b/packages/admin/src/Data/DataResourceRegistry.php @@ -0,0 +1,237 @@ +|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-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' => null, + 'paged' => $paged, + 'eloquent' => false, + ]; + } + + /** + * 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..7939074 --- /dev/null +++ b/packages/admin/src/Data/DataSchema.php @@ -0,0 +1,138 @@ + $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 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. + * + * @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/DataSchemaFactory.php b/packages/admin/src/Data/DataSchemaFactory.php new file mode 100644 index 0000000..43983b0 --- /dev/null +++ b/packages/admin/src/Data/DataSchemaFactory.php @@ -0,0 +1,214 @@ +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 */ + 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 $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 $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' => DataColumn::TYPE_FLOAT, + '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, + '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, + 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 @@ + $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 $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/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 new file mode 100644 index 0000000..fbb9235 --- /dev/null +++ b/packages/admin/src/Data/RepositoryIntrospector.php @@ -0,0 +1,272 @@ + */ + 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 + { + $reflection = new ReflectionClass($repositoryClass); + + 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]; + } + + $reflection = new ReflectionClass($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, + 'float' => DataColumn::TYPE_FLOAT, + '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/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/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 new file mode 100644 index 0000000..4d615fb --- /dev/null +++ b/packages/admin/src/Web/AdminAction.php @@ -0,0 +1,687 @@ +guard->permits($request)) { + return $this->html($this->render('missing', ['slug' => trim($page, '/')]), 404); + } + + $slug = trim($page, '/'); + $current = null; + foreach (AdminPage::all() as $candidate) { + if ($candidate->slug === $slug) { + $current = $candidate; + } + } + + // 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); + } + + if ($current->requires !== null && ! $this->reader->has($current->requires)) { + return $this->html($this->render('unavailable', ['page' => $current]), 404); + } + + if ($slug === 'loggers' && $request->isMethod('POST')) { + return $this->setLoggerLevel($request); + } + + if ($slug === 'data') { + return $request->isMethod('POST') ? $this->dataWrite($request) : $this->dataPage($request); + } + + if ($slug === 'datasource') { + 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. + * + * 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; + + // 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(), + 'connections' => $connections, + 'pooling' => $this->datasource->pooling(), + '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. + * + * 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'); + 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); + + // 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 $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); + } + + 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); + + return $record === null + ? $this->html($this->render('data-missing', ['slug' => $slug]), 404) + : $this->html($this->render('data-record', [ + 'record' => $record, + 'writable' => $this->data->isWritable(), + 'relations' => $this->data->relationsFor($slug), + ]), 200); + } + + $page = (int) ($request->query('page') ?? 1); + $sort = $request->query('sort'); + $direction = $request->query('dir') === 'desc' ? 'desc' : 'asc'; + $search = $request->query('q'); + + $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' => $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 + { + 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')], + // #[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')], + 'env' => ['env' => $this->flatten($this->subArray($this->payload('env'), 'firefly'), 'firefly')], + // 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 => [], + }; + } + + /** + * 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 overview(): array + { + $indicators = $this->reader->healthIndicators(); + $conditions = $this->payload('conditions'); + + 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(), + ]; + } + + /** + * /actuator/info flattened to dotted keys and formatted for reading. + * + * 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 runtime(): array + { + $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; + } + + /** + * 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 aggregateStatus(): string + { + $body = $this->payload('health'); + $status = $body['status'] ?? null; + if (is_string($status) && $status !== '') { + return $status; + } + + $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, the right trade for a dashboard, and it keeps MetricsEndpoint's contract untouched. + * + * 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 + { + $metrics = []; + foreach ($this->subArray($this->payload('metrics'), 'names') as $name) { + if (! is_string($name)) { + continue; + } + + /** @var array $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, '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'); + $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')); + } + + /** @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); + } + + /** + * @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 + */ + 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, ?AdminPage $page = null): string + { + return $this->views->make('firefly-admin::'.$view, [ + ...$data, + 'settings' => $this->settings, + 'nav' => $this->nav(), + '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 + { + 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->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. + && ($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 new file mode 100644 index 0000000..31face1 --- /dev/null +++ b/packages/admin/src/Web/AdminPage.php @@ -0,0 +1,98 @@ + */ + public static function all(): array + { + return [ + 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('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, + '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.'), + // `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 + // 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.'), + new self('data-map', 'Entity map', null, self::GROUP_DATA, + 'The entities and the foreign keys between them, drawn.'), + ]; + } + + /** + * 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_DATA, self::GROUP_CONFIG]; + } +} 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/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..9320631 --- /dev/null +++ b/packages/admin/tests/AdminSettingsTest.php @@ -0,0 +1,83 @@ + $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'); +}); + +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..30c2e5d --- /dev/null +++ b/packages/admin/tests/BeanGraphTest.php @@ -0,0 +1,259 @@ +, 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, 'type' => BeanGraph::EDGE_INJECTS], + ]); +}); + +// 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', 'type' => BeanGraph::EDGE_INJECTS], + ]); +}); + +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'); +}); + +// ───────────────────────────────────────────────────────────────────────────────────────────────────── +// #[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'); +}); diff --git a/packages/admin/tests/CapstoneAdminIntegrationTest.php b/packages/admin/tests/CapstoneAdminIntegrationTest.php new file mode 100644 index 0000000..108e67f --- /dev/null +++ b/packages/admin/tests/CapstoneAdminIntegrationTest.php @@ -0,0 +1,59 @@ +get('/firefly') + ->assertStatus(200) + ->assertHeader('Content-Type', 'text/html; charset=UTF-8') + ->assertSee('Overview', 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 +// /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/Data/ConnectionWizardTest.php b/packages/admin/tests/Data/ConnectionWizardTest.php new file mode 100644 index 0000000..b9610f4 --- /dev/null +++ b/packages/admin/tests/Data/ConnectionWizardTest.php @@ -0,0 +1,109 @@ + 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. + // 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: 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) { + 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'); +}); + +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/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 @@ +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 @@ +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 @@ + $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..cd4a663 --- /dev/null +++ b/packages/admin/tests/Data/DataBrowserWriteTest.php @@ -0,0 +1,260 @@ + + */ +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'); +}); + +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/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 @@ + $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/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/admin/tests/Data/DataRelationsTest.php b/packages/admin/tests/Data/DataRelationsTest.php new file mode 100644 index 0000000..a7a084c --- /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', filters: [new DataFilter('record_id', DataFilter::EQ, '1')]); + + expect($all->total)->toBe(3) + ->and($mine->total)->toBe(2) + ->and($mine->filters[0]->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', 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', filters: [new DataFilter('record_id', DataFilter::EQ, '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', filters: [new DataFilter('no_such_column', DataFilter::EQ, '1')]); + + expect($listing->failed())->toBeFalse() + ->and($listing->total)->toBe(3) + ->and($listing->filters)->toBe([]); +}); + +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 new file mode 100644 index 0000000..ab75226 --- /dev/null +++ b/packages/admin/tests/Data/Fixtures/AdminRecord.php @@ -0,0 +1,50 @@ +|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 +{ + protected $table = 'admin_records'; + + public $timestamps = false; + + protected $guarded = []; + + /** @var list */ + protected $hidden = ['recovery_phrase']; + + /** @return array */ + 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/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 @@ + + */ +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 @@ + + */ +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 @@ + + */ +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 $entities + * @return list + */ + 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 */ + public function findAll(): array + { + return []; + } + + /** + * @param iterable $ids + * @return list + */ + 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 @@ + + */ +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 @@ +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 @@ + + */ +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 @@ + + */ +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 $entities + * @return list + */ + 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 */ + public function findAll(): array + { + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->orderBy('left')->get()->all())); + } + + /** + * @param iterable $ids + * @return list + */ + 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 @@ +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 @@ + + */ +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` 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 $entities + * @return list + */ + 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 */ + public function findAll(): array + { + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->orderBy('id')->get()->all())); + } + + /** + * @param iterable $ids + * @return list + */ + 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 @@ + + */ +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 $entities + * @return list + */ + 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 */ + public function findAll(): array + { + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->orderBy('id')->get()->all())); + } + + /** + * @param iterable $ids + * @return list + */ + 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 @@ + $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 @@ + + */ +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 $entities + * @return list + */ + 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 */ + public function findAll(): array + { + return array_values(array_map(self::hydrate(...), DB::table(self::TABLE)->orderBy('uuid')->get()->all())); + } + + /** + * @param iterable $ids + * @return list + */ + 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 $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 @@ +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(); + }); + + // 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'); + $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 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([ + ['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 $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 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. + * + * @param list $classes + * @param array $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> $classes bean class => the interfaces the row claims + * @param array $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, beans: list} + */ + 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` 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 $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 @@ +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 @@ +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/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 @@ +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/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); +}); 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/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 @@ + */ + 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/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 @@ + */ + protected function configOverrides(): array + { + return [...parent::configOverrides(), 'firefly.management.server.port' => 9001]; + } +} 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/autoconfigure/tests/ReflectionFreeBootTest.php b/packages/autoconfigure/tests/ReflectionFreeBootTest.php index 87afcff..5884480 100644 --- a/packages/autoconfigure/tests/ReflectionFreeBootTest.php +++ b/packages/autoconfigure/tests/ReflectionFreeBootTest.php @@ -22,9 +22,25 @@ function reflectionHits(string $dir): array return $hits; } +/** + * The invariant is about the CACHED boot: once firefly:cache has emitted the manifests, resolving a bean and + * dispatching a request must touch no reflection at all. Compile-time code is therefore allowlisted by name, + * not exempted wholesale — each entry below has to earn its place. + * + * ConstraintScanner reads #[Constraint] attributes off a DTO. The original scan. + * ConstraintManifestCompiler recovers a custom ValidationRule's constructor arguments from its promoted + * properties so #[Rules] survives var_export. Runs only inside firefly:cache; + * a rule that cannot be recovered this way implements Compilable instead, and + * one that does neither is rejected at COMPILE time with an actionable message + * rather than silently losing its state at runtime. + * + * An uncached (development) boot does run these — that is what "scanned boot" means, and it is the documented + * trade-off, not a violation of this invariant. + */ it('the boot path of firefly/autoconfigure and firefly/validation contains no reflection', function () { expect(reflectionHits(__DIR__.'/../src'))->toBe([]) - ->and(reflectionHits(dirname(__DIR__, 2).'/validation/src'))->toBe(['ConstraintScanner.php']); + ->and(reflectionHits(dirname(__DIR__, 2).'/validation/src')) + ->toBe(['ConstraintManifestCompiler.php', 'ConstraintScanner.php']); }); it('firefly/context reflection is still confined to its one scanner (standing M4 invariant)', function () { diff --git a/packages/cli/README.md b/packages/cli/README.md index 92d8c2b..e496c42 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12,7 +12,9 @@ commands. ```bash php artisan firefly:cache -php artisan make:firefly-handler RegisterWidget +# writes App\RegisterWidgetHandler AND the App\RegisterWidget command it handles — a handler whose +# message type cannot be resolved would abort the next firefly:cache, so the pair is generated together. +php artisan make:firefly-handler RegisterWidgetHandler ``` See [CLI](../../docs/cli.md) for the full command reference. 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/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 */ + protected function getOptions(): array + { + return [ + ['plain', null, InputOption::VALUE_NONE, 'Generate a single-action controller with no request DTO.'], + ]; + } } diff --git a/packages/cli/src/Command/Make/MakeHandlerCommand.php b/packages/cli/src/Command/Make/MakeHandlerCommand.php index 3c5b6c0..ff8ffed 100644 --- a/packages/cli/src/Command/Make/MakeHandlerCommand.php +++ b/packages/cli/src/Command/Make/MakeHandlerCommand.php @@ -10,6 +10,25 @@ /** * `make:firefly-handler` — scaffolds a #[CommandHandler] by default, or a #[QueryHandler] with * `--query` (pyfly's `generate handler` parity, split by CQRS side via one flag). + * + * It emits TWO files: the handler AND the message class the handler's `handle()` takes. That pairing is not + * a convenience — it is the fix for a scaffold that used to poison the very next build. + * + * The previous stub typed the parameter `handle(object $command): mixed` with a "replace `object` with the + * concrete Command class" note. `object` is a BUILTIN type, so Firefly\Cqrs\Scanner\HandlerScanner, which + * infers a bare #[CommandHandler]'s message type from handle()'s sole parameter, hit its + * `! $type instanceof ReflectionNamedType || $type->isBuiltin()` guard and threw + * CqrsConfigurationException("Cannot infer the message type for [...]"). HandlerScanner is called + * unconditionally by ManifestCacheWriter::writeManifests(), and nothing there catches: the throw escaped + * `firefly:cache` and aborted the WHOLE compile part-way through, so a developer who ran + * `make:firefly-handler` and then `firefly:cache` — the documented next step — got no handler manifest, no + * event/message/scheduled/security/transactional manifests, and no proxies either. The old stub test only + * string-matched the template for `#[CommandHandler]`, which is why it never noticed; the replacement test + * generates from the stub and runs the real scanner over the result. + * + * Generating the message alongside the handler is also the shape the framework's own fixtures use + * (RegisterWidget + RegisterWidgetHandler, CountWidgets + CountWidgetsHandler): a message DTO must live in + * its own PSR-4 file to be autoloadable, so it cannot simply be appended to the handler's file. */ final class MakeHandlerCommand extends GeneratorCommand { @@ -17,16 +36,114 @@ final class MakeHandlerCommand extends GeneratorCommand protected $name = 'make:firefly-handler'; /** @var string */ - protected $description = 'Create a Firefly #[CommandHandler] (or #[QueryHandler] with --query).'; + protected $description = 'Create a Firefly #[CommandHandler] and its command class (or #[QueryHandler] with --query).'; /** @var string */ protected $type = 'Firefly handler'; + /** + * Set for the duration of the message-class emission so buildClass() picks the message stub instead of + * the handler 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. + */ + private ?string $messageStub = null; + protected function getStub(): string { + if ($this->messageStub !== null) { + return $this->messageStub; + } + return __DIR__.'/../../../stubs/'.($this->option('query') ? 'query-handler.stub' : 'command-handler.stub'); } + /** + * Writes the handler (parent) and then its message class. A false return from the parent means it + * refused — reserved name, or the handler already exists — and in that case nothing else is written, + * so a re-run never drops a stray message DTO next to code it did not generate. + */ + public function handle(): ?bool + { + if (parent::handle() === false) { + return false; + } + + $this->writeMessageClass(); + + return null; + } + + /** + * Emits the message DTO next to the handler, in the same namespace. An existing file is left ALONE and + * reported: the developer has almost certainly already filled it with real properties, and the handler + * that was just generated references it by name either way, so reusing it is the correct outcome. + */ + private function writeMessageClass(): void + { + $name = $this->qualifiedMessageClass(); + $path = $this->getPath($name); + + if ($this->files->exists($path)) { + $this->components->info(sprintf('Firefly handler message [%s] already exists; reusing it.', $path)); + + return; + } + + $this->messageStub = __DIR__.'/../../../stubs/'.($this->option('query') ? 'query-message.stub' : 'command-message.stub'); + + try { + $this->makeDirectory($path); + $this->files->put($path, $this->sortImports($this->buildClass($name))); + } finally { + $this->messageStub = null; + } + + $this->components->info(sprintf('Firefly handler message [%s] created successfully.', $path)); + } + + /** + * Substitutes `{{ message }}` — the message class' SHORT name, since handler and message share a + * namespace and need no import — on top of the parent's `{{ class }}` replacement. Harmless when + * building the message class itself: the message stubs carry no `{{ message }}` placeholder. + * + * 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 MakeControllerCommand's + * getDefaultNamespace() documents). + * + * @param string $stub + * @param string $name + */ + protected function replaceClass($stub, $name): string + { + $stub = parent::replaceClass($stub, $name); + $segments = explode('\\', $this->qualifiedMessageClass()); + + return str_replace(['{{ message }}', '{{message}}'], (string) end($segments), $stub); + } + + /** + * The message class' fully-qualified name, derived from the handler name the developer typed so that a + * nested `make:firefly-handler Widget/RegisterWidgetHandler` puts both files in the same sub-namespace. + * + * A trailing "Handler" is stripped (RegisterWidgetHandler -> RegisterWidget, DemoQueryHandler -> + * DemoQuery); without that suffix there is nothing to strip, so "Command"/"Query" is appended instead + * (RegisterWidget -> RegisterWidgetCommand) — the two names must differ or the handler and its message + * would fight over one file. + */ + private function qualifiedMessageClass(): string + { + $segments = explode('\\', str_replace('/', '\\', ltrim($this->getNameInput(), '\\/'))); + $short = (string) array_pop($segments); + + $segments[] = str_ends_with($short, 'Handler') && strlen($short) > strlen('Handler') + ? substr($short, 0, -strlen('Handler')) + : $short.($this->option('query') ? 'Query' : 'Command'); + + return $this->qualifyClass(implode('\\', $segments)); + } + /** @return list */ protected function getOptions(): array { diff --git a/packages/cli/src/Command/Make/MakeListenerCommand.php b/packages/cli/src/Command/Make/MakeListenerCommand.php index b8d1c91..35dc68a 100644 --- a/packages/cli/src/Command/Make/MakeListenerCommand.php +++ b/packages/cli/src/Command/Make/MakeListenerCommand.php @@ -10,6 +10,17 @@ /** * `make:firefly-listener` — scaffolds an #[EventListener] method by default, or a #[MessageListener] * with `--message` (pyfly's `generate listener` parity, split by transport via one flag). + * + * Both stubs now put a #[Component] stereotype on the class. Without it the generated listener was not a + * bean at all: ComponentScanner::describe() returns null for a class carrying no #[Component]-derived + * attribute, so the class never reached the component manifest and the container held no definition for + * it. Both #[EventListener] and #[MessageListener] are documented as marking "a public BEAN method", and + * EventListenerWiringPass / MessageListenerWiringPass resolve `$container->make($descriptor->class)` fresh + * on every delivery precisely so the fully post-processed bean is used. An unregistered class survives that + * make() only by falling through to Illuminate's reflective auto-build, which produces a plain object + * outside Firefly's lifecycle — no #[Value] injection, no bean post-processing, no #[Transactional] proxy, + * a brand-new instance per message rather than the singleton the app configured. The listener appeared to + * work in a hello-world and quietly lost half its wiring as soon as it depended on anything. */ final class MakeListenerCommand extends GeneratorCommand { diff --git a/packages/cli/src/Command/Make/MakeRepositoryCommand.php b/packages/cli/src/Command/Make/MakeRepositoryCommand.php index 8037a5d..d22397b 100644 --- a/packages/cli/src/Command/Make/MakeRepositoryCommand.php +++ b/packages/cli/src/Command/Make/MakeRepositoryCommand.php @@ -7,8 +7,21 @@ use Illuminate\Console\GeneratorCommand; /** - * `make:firefly-repository` — scaffolds a repository interface extending the framework's - * `Firefly\Data\Repository\CrudRepository` port (pyfly's `generate repository` parity). + * `make:firefly-repository` — scaffolds a concrete #[Repository] bean extending the framework's + * `Firefly\Data\Repository\EloquentRepository` (pyfly's `generate repository` parity). + * + * It used to generate a bare `interface X extends CrudRepository`, which was dead code the moment it was + * written. Nothing in the framework synthesises an implementation for a repository interface (there is no + * Spring-Data-style dynamic proxy here), so no binding ever existed for the generated type: injecting it + * raised a BindingResolutionException, and ComponentScanner skipped it outright — `describe()` returns null + * for `$reflection->isInterface()`, so it was not even in the component manifest to bind. CrudRepository's + * own docblock says as much: "EloquentRepository is the Eloquent-backed implementation; app repositories + * extend that, not this interface directly." + * + * The generated class therefore mirrors the framework's own WidgetRepository fixture: a #[Repository] + * stereotype (so the component scan registers it as a singleton bean), `extends EloquentRepository`, and a + * `$model` class-string the developer repoints at their own model. It is deliberately not `final` — a + * #[Transactional] proxy generated by firefly:cache extends the target class. */ final class MakeRepositoryCommand extends GeneratorCommand { @@ -16,7 +29,7 @@ final class MakeRepositoryCommand extends GeneratorCommand protected $name = 'make:firefly-repository'; /** @var string */ - protected $description = 'Create a Firefly repository interface (extends CrudRepository).'; + protected $description = 'Create a Firefly #[Repository] (extends EloquentRepository).'; /** @var string */ protected $type = 'Firefly repository'; 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/stubs/command-handler.stub b/packages/cli/stubs/command-handler.stub index fa355ad..dab89a6 100644 --- a/packages/cli/stubs/command-handler.stub +++ b/packages/cli/stubs/command-handler.stub @@ -6,14 +6,18 @@ namespace {{ namespace }}; use Firefly\Cqrs\Attributes\CommandHandler; +/** + * Handles the {{ message }} command. + * + * HandlerScanner infers the handled message type from handle()'s SOLE parameter, so that parameter must + * stay a concrete class (a builtin type such as `object` cannot be resolved to a message and makes + * `firefly:cache` fail loud). Point it somewhere else by naming the class explicitly instead: + * #[CommandHandler(SomeOtherCommand::class)]. + */ #[CommandHandler] final class {{ class }} { - /** - * Replace `object` with the concrete Command message class this handler dispatches for - * (or pass it explicitly via #[CommandHandler(SomeCommand::class)]). - */ - public function handle(object $command): mixed + public function handle({{ message }} $command): mixed { return null; } diff --git a/packages/cli/stubs/command-message.stub b/packages/cli/stubs/command-message.stub new file mode 100644 index 0000000..4115279 --- /dev/null +++ b/packages/cli/stubs/command-message.stub @@ -0,0 +1,14 @@ + $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 @@ + + */ + #[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 + */ + #[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 + */ + #[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 + */ + #[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 */ - #[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 + */ + #[GetMapping('/{{ resourcePath }}', name: '{{ resourcePath }}.index')] public function index(): array { return []; diff --git a/packages/cli/stubs/event-listener.stub b/packages/cli/stubs/event-listener.stub index c836fbd..6549b6a 100644 --- a/packages/cli/stubs/event-listener.stub +++ b/packages/cli/stubs/event-listener.stub @@ -4,9 +4,16 @@ declare(strict_types=1); namespace {{ namespace }}; +use Firefly\Container\Attributes\Component; use Firefly\Eda\Attributes\EventListener; use Firefly\Eda\EventEnvelope; +/** + * #[EventListener] marks a public method of a BEAN, so the class itself carries a #[Component] stereotype: + * that is what puts it in the component manifest, and only a managed bean gets constructor autowiring, + * #[Value] injection and bean post-processing when EventListenerWiringPass resolves it per delivery. + */ +#[Component] final class {{ class }} { /** diff --git a/packages/cli/stubs/message-listener.stub b/packages/cli/stubs/message-listener.stub index 47cd2f6..e54fe5e 100644 --- a/packages/cli/stubs/message-listener.stub +++ b/packages/cli/stubs/message-listener.stub @@ -4,9 +4,16 @@ declare(strict_types=1); namespace {{ namespace }}; +use Firefly\Container\Attributes\Component; use Firefly\Messaging\Attributes\MessageListener; use Firefly\Messaging\Message; +/** + * #[MessageListener] marks a public method of a BEAN, so the class itself carries a #[Component] stereotype: + * that is what puts it in the component manifest, and only a managed bean gets constructor autowiring, + * #[Value] injection and bean post-processing when MessageListenerWiringPass resolves it per delivery. + */ +#[Component] final class {{ class }} { /** diff --git a/packages/cli/stubs/query-handler.stub b/packages/cli/stubs/query-handler.stub index 0d746ba..bf6bf97 100644 --- a/packages/cli/stubs/query-handler.stub +++ b/packages/cli/stubs/query-handler.stub @@ -6,14 +6,18 @@ namespace {{ namespace }}; use Firefly\Cqrs\Attributes\QueryHandler; +/** + * Resolves the {{ message }} query. + * + * HandlerScanner infers the resolved message type from handle()'s SOLE parameter, so that parameter must + * stay a concrete class (a builtin type such as `object` cannot be resolved to a message and makes + * `firefly:cache` fail loud). Point it somewhere else by naming the class explicitly instead: + * #[QueryHandler(SomeOtherQuery::class)]. + */ #[QueryHandler] final class {{ class }} { - /** - * Replace `object` with the concrete Query message class this handler resolves for - * (or pass it explicitly via #[QueryHandler(SomeQuery::class)]). - */ - public function handle(object $query): mixed + public function handle({{ message }} $query): mixed { return null; } diff --git a/packages/cli/stubs/query-message.stub b/packages/cli/stubs/query-message.stub new file mode 100644 index 0000000..ab281dd --- /dev/null +++ b/packages/cli/stubs/query-message.stub @@ -0,0 +1,14 @@ + + * A #[Repository] bean over the Eloquent model named by $model. + * + * Point $model at your own model class — Illuminate\Database\Eloquent\Model is only the placeholder the + * generator can guarantee exists, and it is abstract, so every inherited query fails until you replace it. + * Everything else is already wired: extending EloquentRepository inherits the whole + * Crud/PagingAndSorting contract (save/findById/findAll/count/delete, Page/Pageable/Sort), and undeclared + * derived-query methods (findByStatus(...), countByOwnerId(...)) are parsed from their names by __call — + * declare them as @method tags so static analysis sees them. + * + * Deliberately NOT final: firefly:cache emits a #[Transactional] proxy that `extends` this class. + * + * @extends EloquentRepository */ -interface {{ class }} extends CrudRepository +#[Repository] +class {{ class }} extends EloquentRepository { + /** @var class-string */ + protected string $model = Model::class; } diff --git a/packages/cli/tests/Command/Make/GeneratedStubIntegrityTest.php b/packages/cli/tests/Command/Make/GeneratedStubIntegrityTest.php new file mode 100644 index 0000000..6b49580 --- /dev/null +++ b/packages/cli/tests/Command/Make/GeneratedStubIntegrityTest.php @@ -0,0 +1,502 @@ +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); + + // FIRST, before any file assertion: the exact call ManifestCacheWriter::writeManifests() makes. On the + // old stub this line threw CqrsConfigurationException instead of returning, taking the whole + // `firefly:cache` run with it — so it is deliberately the very first thing this test does. + $scan = (new HandlerScanner)->scan(GeneratedApp::psr4()); + + GeneratedApp::lint(GeneratedApp::path().'/StubCommandHandler.php'); + GeneratedApp::lint(GeneratedApp::path().'/StubCommand.php'); + + expect($scan['handlers'])->toHaveCount(1); + expect($scan['handlers'][0]->handlerClass)->toBe('App\\StubCommandHandler') + ->and($scan['handlers'][0]->messageClass)->toBe('App\\StubCommand') + ->and($scan['handlers'][0]->method)->toBe('handle') + ->and($scan['handlers'][0]->kind)->toBe(HandlerKind::Command); +}); + +it('generates a #[QueryHandler] whose message type firefly:cache can actually resolve', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-handler', ['name' => 'StubQueryHandler', '--query' => true]), 0); + + $scan = (new HandlerScanner)->scan(GeneratedApp::psr4()); + + GeneratedApp::lint(GeneratedApp::path().'/StubQueryHandler.php'); + GeneratedApp::lint(GeneratedApp::path().'/StubQuery.php'); + + expect($scan['handlers'])->toHaveCount(1); + expect($scan['handlers'][0]->handlerClass)->toBe('App\\StubQueryHandler') + ->and($scan['handlers'][0]->messageClass)->toBe('App\\StubQuery') + ->and($scan['handlers'][0]->kind)->toBe(HandlerKind::Query); +}); + +it('appends Command/Query when the handler name carries no Handler suffix', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-handler', ['name' => 'StubRegisterWidget']), 0); + + // The message must not collide with the handler over one file, so a name with nothing to strip gets + // the CQRS side appended instead. + GeneratedApp::lint(GeneratedApp::path().'/StubRegisterWidgetCommand.php'); + + $scan = (new HandlerScanner)->scan(GeneratedApp::psr4()); + + expect($scan['handlers'])->toHaveCount(1); + expect($scan['handlers'][0]->messageClass)->toBe('App\\StubRegisterWidgetCommand'); +}); + +it('keeps the handler and its message in the same sub-namespace', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-handler', ['name' => 'Widget/StubNestedHandler']), 0); + + GeneratedApp::lint(GeneratedApp::path().'/Widget/StubNestedHandler.php'); + GeneratedApp::lint(GeneratedApp::path().'/Widget/StubNested.php'); + + $scan = (new HandlerScanner)->scan(GeneratedApp::psr4()); + + expect($scan['handlers'])->toHaveCount(1); + expect($scan['handlers'][0]->handlerClass)->toBe('App\\Widget\\StubNestedHandler') + ->and($scan['handlers'][0]->messageClass)->toBe('App\\Widget\\StubNested'); +}); + +it('never overwrites a message class the developer already wrote', function (): void { + /** @var MakeCommandsTestCase $this */ + $existing = <<<'PHP' + artisan('make:firefly-handler', ['name' => 'StubKeptCommandHandler']), 0); + + expect((string) file_get_contents(GeneratedApp::path().'/StubKeptCommand.php'))->toBe($existing); + + $scan = (new HandlerScanner)->scan(GeneratedApp::psr4()); + expect($scan['handlers'])->toHaveCount(1); + expect($scan['handlers'][0]->messageClass)->toBe('App\\StubKeptCommand'); +}); + +it('generates a repository that is a resolvable bean, not an unbindable interface', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-repository', ['name' => 'StubRepository']), 0); + + GeneratedApp::lint(GeneratedApp::path().'/StubRepository.php'); + + $descriptors = (new ComponentScanner)->scan(GeneratedApp::psr4()); + + expect($descriptors)->toHaveCount(1); + expect($descriptors[0]->class)->toBe('App\\StubRepository') + ->and($descriptors[0]->stereotype)->toBe('repository') + // An interface is not instantiable and ComponentScanner skips it outright — the two facts that + // made the old scaffold impossible to inject. + ->and(GeneratedApp::reflect('App\\StubRepository')->isInstantiable())->toBeTrue() + ->and($descriptors[0]->interfaces)->toContain(CrudRepository::class); +}); + +it('generates listeners that are discoverable beans as well as discoverable listeners', function (string $name, array $options, string $scanned): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-listener', ['name' => $name, ...$options]), 0); + + GeneratedApp::lint(GeneratedApp::path().'/'.$name.'.php'); + + // Half one: the transport scanner sees the annotated method. + $descriptors = $scanned === 'event' + ? (new EventListenerScanner)->scan(GeneratedApp::psr4()) + : (new MessageListenerScanner)->scan(GeneratedApp::psr4()); + + expect($descriptors)->toHaveCount(1); + expect($descriptors[0]->class)->toBe('App\\'.$name); + + // Half two — the half that was missing: the class is a BEAN. Both wiring passes resolve the target + // through the container per delivery, and only a component-manifest entry makes that a managed, + // post-processed Firefly bean rather than an ad-hoc reflective build. + $components = (new ComponentScanner)->scan(GeneratedApp::psr4()); + + expect($components)->toHaveCount(1); + expect($components[0]->class)->toBe('App\\'.$name) + ->and($components[0]->stereotype)->toBe('component'); +})->with([ + 'event listener' => ['StubEventListener', [], 'event'], + 'message listener' => ['StubMessageListener', ['--message' => true], 'message'], +]); + +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' => 'StubOrderController']), 0); + + // 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\\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' + 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\\StubPlainController') + ->and($routes[0]->methodName)->toBe('index') + ->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 { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan($command, ['name' => $name]), 0); + + GeneratedApp::lint(GeneratedApp::path().'/'.$name.'.php'); + + $descriptors = (new ComponentScanner)->scan(GeneratedApp::psr4()); + + expect($descriptors)->toHaveCount(1); + expect($descriptors[0]->class)->toBe('App\\'.$name) + ->and($descriptors[0]->stereotype)->toBe($stereotype); +})->with([ + 'service' => ['make:firefly-service', 'StubService', 'service'], + 'component' => ['make:firefly-component', 'StubComponent', 'component'], +]); + +it('generates a #[ConfigProperties] DTO the config scan binds', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-config-properties', ['name' => 'StubProperties']), 0); + + GeneratedApp::lint(GeneratedApp::path().'/StubProperties.php'); + + $descriptors = (new ConfigPropertiesScanner)->scan(GeneratedApp::psr4()); + + expect($descriptors)->toHaveCount(1); + expect($descriptors[0]->class)->toBe('App\\StubProperties') + ->and($descriptors[0]->prefix)->toBe('StubProperties'); +}); + +it('generates an entity that is a concrete, instantiable Firefly\Domain\Entity', function (): void { + /** @var MakeCommandsTestCase $this */ + ArtisanAssertions::exitCode($this->artisan('make:firefly-entity', ['name' => 'StubEntity']), 0); + + GeneratedApp::lint(GeneratedApp::path().'/StubEntity.php'); + + // class_exists() is the load-bearing assertion: Entity is abstract, so a stub that failed to satisfy + // its contract would fatal here rather than merely lint badly. + expect(class_exists('App\\StubEntity'))->toBeTrue() + ->and(get_parent_class('App\\StubEntity'))->toBe(Entity::class) + ->and(GeneratedApp::reflect('App\\StubEntity')->isInstantiable())->toBeTrue(); + + // An entity carries no stereotype by design, so it must NOT turn up as a bean. + expect((new ComponentScanner)->scan(GeneratedApp::psr4()))->toBe([]); +}); + +it('compiles a whole scaffolded app in one real firefly:cache run', function (): void { + /** @var MakeCommandsTestCase $this */ + $generated = [ + ['make:firefly-controller', ['name' => 'CompiledController']], + ['make:firefly-service', ['name' => 'CompiledService']], + ['make:firefly-component', ['name' => 'CompiledComponent']], + ['make:firefly-repository', ['name' => 'CompiledRepository']], + ['make:firefly-entity', ['name' => 'CompiledEntity']], + ['make:firefly-config-properties', ['name' => 'CompiledProperties']], + ['make:firefly-handler', ['name' => 'CompiledRegisterHandler']], + ['make:firefly-handler', ['name' => 'CompiledCountHandler', '--query' => true]], + ['make:firefly-listener', ['name' => 'CompiledEventListener']], + ['make:firefly-listener', ['name' => 'CompiledMessageListener', '--message' => true]], + ]; + + foreach ($generated as [$command, $arguments]) { + ArtisanAssertions::exitCode($this->artisan($command, $arguments), 0); + } + + $dir = sys_get_temp_dir().'/firefly-stub-cache-'.bin2hex(random_bytes(6)); + config()->set('firefly.scan.paths', GeneratedApp::psr4()); + config()->set('firefly.cache.path', $dir); + + try { + // The end-to-end statement of the whole fix: scaffold one of everything, then run the exact command + // the README and the book tell a developer to run next. On the old handler stub this aborted with an + // uncaught CqrsConfigurationException part-way through ManifestCacheWriter::writeManifests(), so the + // 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::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}"); + } + + // Read the artifacts back through the framework's own loaders — the same call the cached boot makes. + $handlers = HandlerManifest::load($dir.'/'.FireflyCachePaths::HANDLERS); + expect(array_map(static fn (HandlerDescriptor $d): string => $d->handlerClass, $handlers->handlers())) + ->toContain('App\\CompiledRegisterHandler') + ->toContain('App\\CompiledCountHandler'); + + $components = ComponentManifest::load($dir.'/'.FireflyCachePaths::COMPONENT); + expect(array_map(static fn (ComponentDescriptor $d): string => $d->class, $components->components)) + ->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); + } + foreach (glob($dir.'/'.FireflyCachePaths::PROXY_DIR.'/*.php') ?: [] as $file) { + unlink($file); + } + @rmdir($dir.'/'.FireflyCachePaths::PROXY_DIR); + @rmdir($dir); + } +}); diff --git a/packages/cli/tests/Command/Make/MakeCommandsTest.php b/packages/cli/tests/Command/Make/MakeCommandsTest.php index f3f5470..00f9423 100644 --- a/packages/cli/tests/Command/Make/MakeCommandsTest.php +++ b/packages/cli/tests/Command/Make/MakeCommandsTest.php @@ -16,6 +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 — 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')) ?: []); @@ -23,13 +27,17 @@ 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]'], // #[EventListener] always carries a $patterns constructor arg in the generated scaffold, so the // needle checks the opening paren too (an attribute usage never renders as a bare `#[EventListener]` - // here, unlike the parameterless stereotypes above). + // here, unlike the parameterless stereotypes above). The class-level #[Component] that makes the + // listener a bean is asserted behaviourally in GeneratedStubIntegrityTest. 'listener (event)' => ['make:firefly-listener', 'DemoEventListener', 'DemoEventListener.php', "#[EventListener('"], // NOTE: no `Firefly\Data\Attributes\Entity` (or any other) attribute exists anywhere in the // monorepo — grepping every packages/*/src/Attributes dir confirms it. The real framework analog @@ -38,7 +46,11 @@ // entity.stub extends it instead of applying a nonexistent attribute; the needle is adjusted to // match (deviation from the brief's literal `'#[Entity]'`, reported in the task report). 'entity' => ['make:firefly-entity', 'DemoEntity', 'DemoEntity.php', 'extends Entity'], - 'repository' => ['make:firefly-repository', 'DemoRepository', 'DemoRepository.php', 'interface DemoRepository'], + // The repository scaffold is a CONCRETE #[Repository] bean extending EloquentRepository, not the bare + // `interface ... extends CrudRepository` it used to be: nothing in the framework synthesises an + // implementation for a repository interface, so the old output could never be injected and was skipped + // by the component scan entirely. GeneratedStubIntegrityTest holds the behavioural half of that. + 'repository' => ['make:firefly-repository', 'DemoRepository', 'DemoRepository.php', '#[Repository]'], 'config properties' => ['make:firefly-config-properties', 'DemoConfigProperties', 'DemoConfigProperties.php', '#[ConfigProperties('], ]); @@ -64,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/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/cli/tests/Skeleton/SkeletonExampleTest.php b/packages/cli/tests/Skeleton/SkeletonExampleTest.php new file mode 100644 index 0000000..351bb20 --- /dev/null +++ b/packages/cli/tests/Skeleton/SkeletonExampleTest.php @@ -0,0 +1,336 @@ +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"); + } +}); + +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/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 @@ +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/GeneratedApp.php b/packages/cli/tests/Support/GeneratedApp.php new file mode 100644 index 0000000..c32309b --- /dev/null +++ b/packages/cli/tests/Support/GeneratedApp.php @@ -0,0 +1,97 @@ + + */ + public static function psr4(): array + { + return ['App\\' => self::path()]; + } + + /** + * Deletes every generated .php file under `app/`, leaving testbench's own .gitkeep skeleton alone. + * + * Run before AND after each test: the generators append to a directory shared by every test in the + * process, and a leftover file from a neighbouring test would be picked up by the recursive scans here + * and silently change what a scanner returns. + */ + public static function clean(): void + { + $root = self::path(); + + foreach (['/*.php', '/*/*.php', '/*/*/*.php'] as $pattern) { + foreach (glob($root.$pattern) ?: [] as $file) { + unlink($file); + } + } + } + + /** + * Asserts the generated file is syntactically valid PHP by running the real `php -l` over it. + * + * This is the cheap half of the guarantee; the expensive half is that the callers then hand the file to + * a scanner, which class_exists()es it and reflects over it. A stub can lint perfectly and still be + * unusable — the `handle(object $command)` handler stub this suite was written for did exactly that — + * so a lint on its own is never the whole assertion. + */ + public static function lint(string $file): void + { + expect(is_file($file))->toBeTrue("expected {$file} to have been generated"); + + $output = []; + $status = 0; + exec(escapeshellarg(PHP_BINARY).' -l '.escapeshellarg($file).' 2>&1', $output, $status); + + expect($status)->toBe(0, "php -l failed for {$file}: ".implode("\n", $output)); + } + + /** + * A ReflectionClass over a class one of the generators just wrote. + * + * The class_exists() guard is not defensive noise. It is what narrows the plain string a test passes in + * to a class-string for static analysis, and it doubles as an assertion in its own right: an INTERFACE + * (what repository.stub used to emit) leaves class_exists() false, as does a file PHP cannot load, so + * either failure stops here with a readable message instead of a reflection error further down. + * + * @return ReflectionClass + */ + public static function reflect(string $class): ReflectionClass + { + if (! class_exists($class)) { + throw new RuntimeException("[{$class}] was not generated as a loadable class."); + } + + return new ReflectionClass($class); + } + + /** testbench's `app/` directory for the currently booted application. */ + public static function path(): string + { + $path = Container::getInstance()->get('path'); + + return is_string($path) ? rtrim($path, '/') : ''; + } +} diff --git a/packages/cli/tests/Support/GeneratedAppAutoloader.php b/packages/cli/tests/Support/GeneratedAppAutoloader.php new file mode 100644 index 0000000..273e47c --- /dev/null +++ b/packages/cli/tests/Support/GeneratedAppAutoloader.php @@ -0,0 +1,73 @@ +bound('path')) { + return null; + } + + $path = $container->get('path'); + + return is_string($path) ? rtrim($path, '/') : null; + } +} diff --git a/packages/cli/tests/Support/SkeletonApp.php b/packages/cli/tests/Support/SkeletonApp.php new file mode 100644 index 0000000..b6dd125 --- /dev/null +++ b/packages/cli/tests/Support/SkeletonApp.php @@ -0,0 +1,132 @@ + + */ + 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'; + } + + /** + * 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. + * + * 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 $overrides + * @return array + */ + 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..2b4cf49 --- /dev/null +++ b/packages/cli/tests/Support/SkeletonExampleTestCase.php @@ -0,0 +1,114 @@ +write(SkeletonApp::psr4(), self::$cacheDir); + } + + parent::setUp(); + + SkeletonApp::migrate(); + } + + 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> */ + 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. + FireflyCacheServiceProvider::class, + ]; + } + + /** @return array */ + 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..ae97983 --- /dev/null +++ b/packages/cli/tests/Support/SkeletonScannedBootTestCase.php @@ -0,0 +1,63 @@ +> */ + 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, + ]; + } + + /** @return array */ + 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/config/composer.json b/packages/config/composer.json index 609d6b8..1623cd1 100644 --- a/packages/config/composer.json +++ b/packages/config/composer.json @@ -19,6 +19,7 @@ "illuminate/config": "^13.0", "illuminate/container": "^13.0", "illuminate/contracts": "^13.0", + "illuminate/support": "^13.0", "symfony/expression-language": "^7.4|^8.0" }, "extra": { diff --git a/packages/config/src/Binder/ReflectionConfigBinder.php b/packages/config/src/Binder/ReflectionConfigBinder.php index e013d35..8b33915 100644 --- a/packages/config/src/Binder/ReflectionConfigBinder.php +++ b/packages/config/src/Binder/ReflectionConfigBinder.php @@ -13,6 +13,33 @@ * Binds a config array onto a plain readonly DTO by matching constructor parameters to array keys. * Scalars are coerced; a parameter typed as another class is bound recursively from its sub-array. * The seam (ConfigBinder) lets a richer binder be swapped in without touching call sites. + * + * RELAXED BINDING. Key lookup is NOT a straight array_key_exists() on the PHP parameter name, and + * for a concrete reason. A Laravel `config/*.php` file is written by hand, in whatever casing the + * house style of the application prefers, and its values very often arrive from environment + * variables that are SCREAMING_SNAKE by convention; a PHP constructor parameter, meanwhile, is + * camelCase because PSR-12 says so. Matching only the exact name meant those two worlds simply + * never met: `'daily_transfer_limit_minor' => 250000` in the config file bound NOTHING onto + * `public int $dailyTransferLimitMinor`, and because an unmatched parameter with a constructor + * 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 that exact example + * (book/src/03-configuration.md: a `WalletProperties` with camelCase parameters over a + * `config/wallet.php` with snake_case keys), which is how the defect was finally caught: the + * documented, copy-pasteable worked example could not possibly have worked. + * + * So each parameter is now looked up under four spellings, in this fixed precedence order, exactly + * mirroring Spring Boot's relaxed binding: + * + * 1. the 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 is total and data-independent — it depends only on the parameter name, never on the + * iteration order of the config array — so binding is deterministic even when an array carries two + * spellings of the same property at once. Duplicate spellings collapse (a parameter already named + * in snake_case yields two candidates, not four), which also keeps the "tried these keys" text in + * the missing-property exception honest. */ final class ReflectionConfigBinder implements ConfigBinder { @@ -53,7 +80,20 @@ public function bind(string $class, array $config): object private function resolveParameter(string $class, ReflectionParameter $parameter, array $config): mixed { $name = $parameter->getName(); - $value = array_key_exists($name, $config) ? $config[$name] : null; + $candidates = $this->candidateKeys($name); + $value = null; + + foreach ($candidates as $candidate) { + // A present-but-NULL key has always meant "not supplied" here — it is precisely what + // the ubiquitous Laravel idiom `'port' => env('MAIL_PORT')` yields when the variable is + // unset, and Config::required() reads it the same way. It therefore does not stop the + // search: a null under the exact name must not mask a real value written in snake_case, + // or an application would be punished for leaving an unused env() line in place. + if (array_key_exists($candidate, $config) && $config[$candidate] !== null) { + $value = $config[$candidate]; + break; + } + } if ($value === null) { if ($parameter->isDefaultValueAvailable()) { @@ -63,7 +103,12 @@ private function resolveParameter(string $class, ReflectionParameter $parameter, return null; } - throw new ConfigurationException("Missing required configuration property [{$name}] for {$class}."); + throw new ConfigurationException(sprintf( + 'Missing required configuration property [%s] for %s. Tried these keys: %s.', + $name, + $class, + implode(', ', $candidates), + )); } $type = $parameter->getType(); @@ -74,6 +119,40 @@ private function resolveParameter(string $class, ReflectionParameter $parameter, return $this->coerce($type, $value); } + /** + * Every config-array key that may supply $name, most specific first (see the class docblock for + * the precedence rationale). + * + * The camelCase -> snake_case step is two passes rather than the usual one-liner + * `preg_replace('/(? upper boundary (`apiURL` -> `api_URL`); the second breaks + * an acronym that runs into a following word (`HTTPProxy` -> `HTTP_Proxy`). Lower-casing the + * result then yields `api_url` and `http_proxy_host`. + * + * @return non-empty-list + */ + private function candidateKeys(string $name): array + { + $snake = strtolower((string) preg_replace( + ['/([a-z0-9])([A-Z])/', '/([A-Z]+)([A-Z][a-z])/'], + '$1_$2', + $name, + )); + + /** @var non-empty-list $candidates */ + $candidates = array_values(array_unique([ + $name, // exact + $snake, // snake_case + str_replace('_', '-', $snake), // kebab-case + strtoupper($snake), // SCREAMING_SNAKE_CASE + ])); + + return $candidates; + } + private function coerce(ReflectionNamedType $type, mixed $value): mixed { if ($type->isBuiltin()) { diff --git a/packages/config/src/Profile/Profile.php b/packages/config/src/Profile/Profile.php index 652e0b0..6d611ba 100644 --- a/packages/config/src/Profile/Profile.php +++ b/packages/config/src/Profile/Profile.php @@ -7,8 +7,29 @@ use Attribute; /** - * Gates a component to one or more active profiles. The predicate is evaluated by conditional - * registration (firefly/autoconfigure, M5); defined here so config and later milestones share it. + * Gates a component to one or more active profiles: the component exists only when at least one of + * $names is in the active Profiles (OR, never AND — see Profiles::accepts(), which is the single + * implementation of that predicate). + * + * WHAT THIS DOCBLOCK USED TO CLAIM, AND WHY THAT MATTERED. It said the predicate was "evaluated by + * conditional registration (firefly/autoconfigure, M5)". It was not, anywhere: the attribute was + * exported, documented and reachable, and `grep -rn 'Profile::class' packages//src` matched + * ZERO lines of production code. Nothing scanned for it and nothing evaluated it, so every class + * carrying #[Profile('prod')] was registered under every profile — the annotation read as a + * guarantee and behaved as a comment. The lesson is recorded here rather than quietly deleted: a + * docblock that promises an evaluator in another package is a promise nothing tests, and this one + * went unkept through several milestones. + * + * WHAT HONOURS IT TODAY. Within firefly/config the chain is complete and covered by tests: + * ProfileRequirement reads the attribute once at scan time, ConfigPropertiesScanner records the + * result on ConfigPropertiesDescriptor, the compiled config-properties.php carries it, and + * ConfigRegistrar refuses to bind a #[ConfigProperties] DTO whose profiles are not active. + * + * Gating a general #[Component] — anything that is not a #[ConfigProperties] DTO — additionally + * needs firefly/context to record the requirement while it scans and to apply Profiles::accepts() + * in its condition pipeline; firefly/config sits below Context in the layer graph and cannot reach + * up to do it. Until that lands, prefer firefly/context's #[ConditionalOnProfile] for non-DTO + * beans: it is the same predicate, already wired into ConditionEvaluator. */ #[Attribute(Attribute::TARGET_CLASS)] final class Profile diff --git a/packages/config/src/Profile/ProfileRequirement.php b/packages/config/src/Profile/ProfileRequirement.php new file mode 100644 index 0000000..c37db79 --- /dev/null +++ b/packages/config/src/Profile/ProfileRequirement.php @@ -0,0 +1,58 @@ +/src` returned nothing — no scanner ever + * looked for it and no condition ever acted on it, so a class marked #[Profile('prod')] was + * registered under every profile, including the ones the annotation exists to exclude. That is the + * worst class of framework bug: the code says one thing, the runtime does the opposite, and nothing + * fails. + * + * The reading is deliberately factored out of ConfigPropertiesScanner rather than inlined there, + * for two reasons. First, #[Profile] is a GENERAL bean annotation — a #[ConfigProperties] DTO is + * only the bean kind this package happens to own — so every other scanner that needs to record the + * same requirement (firefly/context's ContextScanner over #[Component] classes, in particular) must + * be able to produce byte-identical output without copying a regex-free but still fiddly loop. + * Second, it keeps the normalization rules (trim, drop blanks, de-duplicate, preserve declaration + * order) in ONE place, so a manifest compiled by one scanner and a manifest compiled by another + * cannot disagree about what ['prod', ' prod ', ''] means. + * + * Reflection happens here and nowhere else: the resulting list travels in the compiled + * manifest, so nothing reflects a user class at boot to discover it is profile-gated. + */ +final class ProfileRequirement +{ + /** + * The profiles $class declares, in declaration order, or [] when it declares none. + * + * #[Profile] is TARGET_CLASS and not IS_REPEATABLE, so at most one attribute can be present; + * the loop nonetheless folds every occurrence rather than reading getAttributes()[0], so that + * making the attribute repeatable later is a one-word change to the attribute and nothing else. + * + * @param ReflectionClass $class + * @return list + */ + public static function namesOf(ReflectionClass $class): array + { + $names = []; + + foreach ($class->getAttributes(Profile::class) as $attribute) { + foreach ($attribute->newInstance()->names as $name) { + $name = trim($name); + if ($name !== '' && ! in_array($name, $names, true)) { + $names[] = $name; + } + } + } + + return $names; + } +} diff --git a/packages/config/src/Profile/ProfileResolver.php b/packages/config/src/Profile/ProfileResolver.php index 5dd46be..2a7eadc 100644 --- a/packages/config/src/Profile/ProfileResolver.php +++ b/packages/config/src/Profile/ProfileResolver.php @@ -4,27 +4,139 @@ namespace Firefly\Config\Profile; +use Illuminate\Container\Container; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Support\Env; + /** - * Resolves the active profiles from the environment: FIREFLY_PROFILES_ACTIVE (comma-separated) takes - * precedence; otherwise the single Laravel APP_ENV; otherwise the implicit "default" profile. + * Resolves the active profiles: FIREFLY_PROFILES_ACTIVE (comma-separated) takes precedence; + * otherwise the single Laravel APP_ENV; otherwise the implicit "default" profile. + * + * WHY THIS IS NOT `getenv()`. It used to be — two bare getenv() calls — and that made profiles + * collapse to ['default'] in the two situations where they matter most: + * + * - Under orchestra/testbench. Testbench builds the application and sets its environment on the + * CONFIG REPOSITORY; it never calls putenv(), never writes $_ENV or $_SERVER, and never loads a + * .env file. getenv('APP_ENV') is therefore literally false in every LaraFly test suite, so a + * developer writing a test to prove that their #[Profile('test')] bean is registered watched it + * silently not be — with no error to explain why. + * - Under `php artisan config:cache`. Laravel's LoadEnvironmentVariables bootstrapper returns + * early when the configuration is cached, so .env is never parsed and getenv() sees nothing — + * while the cached repository holds the correct app.env the whole time. Profiles switched + * themselves off in production the moment an application followed the deployment guide. + * + * The fix is to consult, per setting, the three places Laravel itself would look, in this order: + * + * 1. Illuminate\Support\Env — the same reader behind Laravel's env() helper. It sees $_ENV and + * $_SERVER as well as 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 is checked first and beats a cached config value — deliberately, since + * overriding a baked artifact per process is the whole point of an env var. + * 2. The config repository (firefly.profiles.active, app.env) — the cached-config and testbench + * answer. Injected explicitly where a caller has one; otherwise taken from the container's + * 'config' binding, so the zero-argument `new ProfileResolver` call sites that already exist + * across the framework keep working and start seeing cached configuration for free. + * 3. Raw getenv() — kept only as a last resort, for a process that manipulated the environment + * through putenv() after Env's repository was already built, or that runs with no Laravel + * application at all. + * + * A blank or non-scalar value at any level is treated as absent and the search continues, so an + * empty `FIREFLY_PROFILES_ACTIVE=` in a .env cannot blank out a perfectly good APP_ENV. */ final class ProfileResolver { + /** + * @param Repository|null $repository the application's config repository; when null it is + * taken from the container's 'config' binding if there is one + */ + public function __construct(private readonly ?Repository $repository = null) {} + public function resolve(): Profiles { - $explicit = getenv('FIREFLY_PROFILES_ACTIVE'); - if (is_string($explicit) && trim($explicit) !== '') { - return new Profiles($this->split($explicit)); + $explicit = $this->setting('FIREFLY_PROFILES_ACTIVE', 'firefly.profiles.active'); + if ($explicit !== null) { + $names = $this->split($explicit); + if ($names !== []) { + return new Profiles($names); + } } - $env = getenv('APP_ENV'); - if (is_string($env) && trim($env) !== '') { - return new Profiles([trim($env)]); + $environment = $this->setting('APP_ENV', 'app.env'); + if ($environment !== null) { + return new Profiles([$environment]); } return new Profiles(['default']); } + /** + * The first non-blank value for one logical setting, read through the three sources documented + * on the class, or null when none of them supplies one. + */ + private function setting(string $variable, string $key): ?string + { + $candidates = [ + Env::get($variable), + $this->repository()?->get($key), + getenv($variable), + ]; + + foreach ($candidates as $candidate) { + $normalized = $this->normalize($candidate); + if ($normalized !== null) { + return $normalized; + } + } + + return null; + } + + /** + * Flattens whatever a source handed back into a trimmed, non-empty string, or null. + * + * A list is accepted because `firefly.profiles.active` reads far more naturally in a PHP config + * file as `['prod', 'eu']` than as the string 'prod,eu'; it is joined with a comma so that both + * spellings converge on the same split() below. getenv() returns false when unset, and a config + * key can hold anything at all, so every other shape is rejected rather than stringified into + * nonsense like "Array" or "1". + */ + private function normalize(mixed $value): ?string + { + if (is_array($value)) { + $parts = array_filter($value, static fn (mixed $part): bool => is_string($part) || is_int($part) || is_float($part)); + $value = implode(',', array_map(static fn (string|int|float $part): string => (string) $part, $parts)); + } + + if (is_int($value) || is_float($value)) { + $value = (string) $value; + } + + if (! is_string($value) || trim($value) === '') { + return null; + } + + return trim($value); + } + + private function repository(): ?Repository + { + if ($this->repository !== null) { + return $this->repository; + } + + // Container::getInstance() materializes an empty container when the process never built an + // application, which is why this is guarded by bound() rather than a bare get(): no Laravel + // app simply means no repository, not an exception. + $container = Container::getInstance(); + if (! $container->bound('config')) { + return null; + } + + $repository = $container->get('config'); + + return $repository instanceof Repository ? $repository : null; + } + /** * @return list */ diff --git a/packages/config/src/Profile/Profiles.php b/packages/config/src/Profile/Profiles.php index f2a86b7..18c71af 100644 --- a/packages/config/src/Profile/Profiles.php +++ b/packages/config/src/Profile/Profiles.php @@ -31,4 +31,38 @@ public function isEmpty(): bool { return $this->active === []; } + + /** + * THE profile predicate: may a bean that declares $required exist under these active profiles? + * + * Empty $required means the bean declared no requirement at all and is therefore always + * accepted — an unannotated component must never be gated. A non-empty $required is an OR, not + * an AND: #[Profile('dev', 'test')] reads as "either of these", matching Spring's @Profile and, + * deliberately, firefly/context's #[ConditionalOnProfile] evaluation exactly. That parity is + * the whole point of putting the rule here rather than re-deriving it at each gate — the + * framework previously had one copy of the semantics living inside ConditionEvaluator and a + * #[Profile] attribute that no code consumed at all, which is precisely how two spellings of + * the same idea drift apart. + * + * Note what is NOT supported, on purpose: Spring's negated form (@Profile("!prod")) and its + * &/| expression grammar. Adding negation here alone would immediately make #[Profile('!prod')] + * behave differently from #[ConditionalOnProfile('!prod')], so if it is ever wanted it has to + * land in both places in the same change. + * + * @param list $required the profiles a bean declares, or [] when it declares none + */ + public function accepts(array $required): bool + { + if ($required === []) { + return true; + } + + foreach ($required as $profile) { + if ($this->isActive($profile)) { + return true; + } + } + + return false; + } } diff --git a/packages/config/src/Registrar/ConfigRegistrar.php b/packages/config/src/Registrar/ConfigRegistrar.php index c95bcf5..5fdc18f 100644 --- a/packages/config/src/Registrar/ConfigRegistrar.php +++ b/packages/config/src/Registrar/ConfigRegistrar.php @@ -7,6 +7,8 @@ use Firefly\Config\Binder\ConfigBinder; use Firefly\Config\Binder\ReflectionConfigBinder; use Firefly\Config\Config; +use Firefly\Config\Profile\ProfileResolver; +use Firefly\Config\Profile\Profiles; use Firefly\Config\Scanner\ConfigPropertiesDescriptor; use Firefly\Config\Scanner\ConfigPropertiesManifest; use Firefly\Config\Value\ConfigValueResolver; @@ -17,6 +19,15 @@ * Wires config into the container: installs the config-backed ValueResolver (overriding firefly/container's * DefaultValueResolver) and registers each #[ConfigProperties] DTO as a singleton bound from its config * subtree. Idempotent per container. + * + * PROFILE GATING. A DTO that declares #[Profile] is registered only when one of those profiles is + * active. Until this landed, #[Profile] was inert across the entire framework — the attribute + * existed, the documentation described it, and `grep -rn 'Profile::class' packages//src` + * matched nothing, so a DTO marked #[Profile('prod')] was bound in dev, in test and in CI exactly + * as if the annotation were a comment. Skipping the binding (rather than binding a null, or binding + * a "disabled" instance) is the honest failure mode and the one Spring chose: an excluded bean does + * not exist, so injecting it fails loudly at resolution time instead of quietly handing back + * configuration that was meant to be unreachable. */ final class ConfigRegistrar { @@ -24,12 +35,25 @@ final class ConfigRegistrar private ConfigBinder $binder; + private Profiles $profiles; + + /** + * $profiles is optional because the framework's own call site — firefly/context's + * FlushDefinitionsPass — constructs this registrar with two arguments, and a package below + * Context in the layer graph cannot reach up to change it. Falling back to ProfileResolver + * rather than to "no gating" is deliberate: an omitted argument must not silently disable the + * gate, which would reintroduce the exact bug this class now fixes. A caller that already holds + * a resolved Profiles (a BootPass has one on its BootContext) should still pass it, so the whole + * boot agrees on one profile set instead of resolving it twice. + */ public function __construct( private readonly Container $container, private readonly Config $config, ?ConfigBinder $binder = null, + ?Profiles $profiles = null, ) { $this->binder = $binder ?? new ReflectionConfigBinder; + $this->profiles = $profiles ?? (new ProfileResolver)->resolve(); } public function register(ConfigPropertiesManifest $manifest): void @@ -43,6 +67,10 @@ public function register(ConfigPropertiesManifest $manifest): void $this->container->instance(ValueResolver::class, new ConfigValueResolver($this->config)); foreach ($manifest->properties as $descriptor) { + if (! $this->profiles->accepts($descriptor->profiles)) { + continue; + } + $this->registerProperties($descriptor); } } diff --git a/packages/config/src/Scanner/ConfigPropertiesDescriptor.php b/packages/config/src/Scanner/ConfigPropertiesDescriptor.php index 4ceace2..b8e12a5 100644 --- a/packages/config/src/Scanner/ConfigPropertiesDescriptor.php +++ b/packages/config/src/Scanner/ConfigPropertiesDescriptor.php @@ -6,24 +6,50 @@ final readonly class ConfigPropertiesDescriptor { + /** + * @param list $profiles the #[Profile] names the DTO declares, or [] when it declares + * none — read once by ConfigPropertiesScanner and acted on by + * ConfigRegistrar, so #[Profile] is no longer inert metadata + */ public function __construct( public string $class, public string $prefix, + public array $profiles = [], ) {} /** - * @return array{class: string, prefix: string} + * The compiled-manifest row. + * + * `profiles` is OMITTED when empty rather than written as `[]`. That is not micro-optimization: + * the overwhelming majority of DTOs are unconstrained, and emitting the key unconditionally + * would rewrite every single row of every already-committed config-properties.php the first time + * an application re-ran firefly:cache, turning a behaviour-preserving upgrade into a large and + * completely uninformative diff. Omitting it keeps the artifact byte-identical for every DTO + * that has no profile requirement. + * + * @return array{class: string, prefix: string, profiles?: list} */ public function toArray(): array { - return ['class' => $this->class, 'prefix' => $this->prefix]; + $row = ['class' => $this->class, 'prefix' => $this->prefix]; + + if ($this->profiles !== []) { + $row['profiles'] = $this->profiles; + } + + return $row; } /** - * @param array{class: string, prefix: string} $d + * `profiles` is read with a default rather than required, so a config-properties.php compiled by + * an older firefly:cache — or by an older release of firefly/cli, which versions separately — + * still loads, as "no requirement". An upgrade must never have to be sequenced with a cache + * rebuild to avoid an undefined-array-key fatal on boot. + * + * @param array{class: string, prefix: string, profiles?: list} $d */ public static function fromArray(array $d): self { - return new self($d['class'], $d['prefix']); + return new self($d['class'], $d['prefix'], $d['profiles'] ?? []); } } diff --git a/packages/config/src/Scanner/ConfigPropertiesManifest.php b/packages/config/src/Scanner/ConfigPropertiesManifest.php index 0d34ed0..7d72f2f 100644 --- a/packages/config/src/Scanner/ConfigPropertiesManifest.php +++ b/packages/config/src/Scanner/ConfigPropertiesManifest.php @@ -14,7 +14,7 @@ final class ConfigPropertiesManifest public function __construct(public array $properties) {} /** - * @param array $data + * @param array}> $data */ public static function fromArray(array $data): self { @@ -36,7 +36,7 @@ public static function load(string $path): self throw new ConfigurationException("Config-properties manifest at {$path} did not return an array."); } - /** @var array $data */ + /** @var array}> $data */ return self::fromArray($data); } } diff --git a/packages/config/src/Scanner/ConfigPropertiesScanner.php b/packages/config/src/Scanner/ConfigPropertiesScanner.php index 35c2394..1eada72 100644 --- a/packages/config/src/Scanner/ConfigPropertiesScanner.php +++ b/packages/config/src/Scanner/ConfigPropertiesScanner.php @@ -5,14 +5,16 @@ namespace Firefly\Config\Scanner; use Firefly\Config\Attributes\ConfigProperties; +use Firefly\Config\Profile\ProfileRequirement; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use ReflectionClass; /** - * Discovers #[ConfigProperties] DTOs under PSR-4 namespaces. Mirrors firefly/container's ComponentScanner - * idiom: discovery uses class_exists() (autoloads), so each prefix => dir must also be registered with the - * active Composer autoloader; a trailing "\\" on the prefix is optional (normalized internally). + * Discovers #[ConfigProperties] DTOs — and the #[Profile] requirement each one declares — under PSR-4 + * namespaces. Mirrors firefly/container's ComponentScanner idiom: discovery uses class_exists() + * (autoloads), so each prefix => dir must also be registered with the active Composer autoloader; a + * trailing "\\" on the prefix is optional (normalized internally). */ final class ConfigPropertiesScanner { @@ -33,7 +35,15 @@ public function scan(array $psr4): array if ($attrs === []) { continue; } - $descriptors[] = new ConfigPropertiesDescriptor($class, $attrs[0]->newInstance()->prefix); + // The #[Profile] requirement is read HERE, at scan time, and travels in the + // compiled manifest — the same discipline as the prefix itself. Before this the + // attribute was recorded nowhere, so ConfigRegistrar had no way to know a DTO was + // gated and bound it under every profile. + $descriptors[] = new ConfigPropertiesDescriptor( + $class, + $attrs[0]->newInstance()->prefix, + ProfileRequirement::namesOf($reflection), + ); } } diff --git a/packages/config/tests/Binder/ReflectionConfigBinderTest.php b/packages/config/tests/Binder/ReflectionConfigBinderTest.php index 22bfe91..f7a40a4 100644 --- a/packages/config/tests/Binder/ReflectionConfigBinderTest.php +++ b/packages/config/tests/Binder/ReflectionConfigBinderTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use Firefly\Config\Binder\ReflectionConfigBinder; +use Firefly\Config\Tests\Fixtures\AcronymProperties; use Firefly\Config\Tests\Fixtures\DatabaseProperties; use Firefly\Config\Tests\Fixtures\MailProperties; use Firefly\Config\Tests\Fixtures\OptionalProperties; +use Firefly\Config\Tests\Fixtures\WalletProperties; use Firefly\Kernel\Exception\Framework\ConfigurationException; it('binds a flat config array onto a readonly DTO with coercion + defaults', function () { @@ -70,3 +72,92 @@ ->and($db->pool->max)->toBe(10) ->and($db->replicas)->toBe([]); }); + +/** + * RELAXED BINDING (Spring Boot parity). + * + * The binder used to match a constructor parameter against ONE spelling: its exact PHP name. Every + * other spelling a Laravel config file might reasonably use — snake_case, kebab-case, the + * SCREAMING_SNAKE of an env var pasted straight into an array — bound nothing at all and silently + * yielded the constructor default, which is the worst possible failure mode for configuration: + * no exception, no log line, just a wrong number in production. + */ +it('binds a snake_case config key onto a camelCase constructor parameter', function () { + $wallet = (new ReflectionConfigBinder)->bind(WalletProperties::class, [ + 'daily_transfer_limit_minor' => 250_000, + 'default_currency' => 'USD', + ]); + + expect($wallet->dailyTransferLimitMinor)->toBe(250_000) + ->and($wallet->defaultCurrency)->toBe('USD'); +}); + +it('binds kebab-case and SCREAMING_SNAKE config keys onto camelCase constructor parameters', function () { + $kebab = (new ReflectionConfigBinder)->bind(WalletProperties::class, [ + 'daily-transfer-limit-minor' => 111, + 'default-currency' => 'GBP', + ]); + $upper = (new ReflectionConfigBinder)->bind(WalletProperties::class, [ + 'DAILY_TRANSFER_LIMIT_MINOR' => 222, + 'DEFAULT_CURRENCY' => 'CHF', + ]); + + expect($kebab->dailyTransferLimitMinor)->toBe(111) + ->and($kebab->defaultCurrency)->toBe('GBP') + ->and($upper->dailyTransferLimitMinor)->toBe(222) + ->and($upper->defaultCurrency)->toBe('CHF'); +}); + +it('prefers the exact parameter name over every relaxed spelling, in a fixed precedence order', function () { + $binder = new ReflectionConfigBinder; + + // All four spellings present at once: exact wins, then snake, then kebab, then UPPER. + expect($binder->bind(WalletProperties::class, [ + 'dailyTransferLimitMinor' => 1, + 'daily_transfer_limit_minor' => 2, + 'daily-transfer-limit-minor' => 3, + 'DAILY_TRANSFER_LIMIT_MINOR' => 4, + ])->dailyTransferLimitMinor)->toBe(1); + + expect($binder->bind(WalletProperties::class, [ + 'daily_transfer_limit_minor' => 2, + 'daily-transfer-limit-minor' => 3, + 'DAILY_TRANSFER_LIMIT_MINOR' => 4, + ])->dailyTransferLimitMinor)->toBe(2); + + expect($binder->bind(WalletProperties::class, [ + 'daily-transfer-limit-minor' => 3, + 'DAILY_TRANSFER_LIMIT_MINOR' => 4, + ])->dailyTransferLimitMinor)->toBe(3); + + expect($binder->bind(WalletProperties::class, [ + 'DAILY_TRANSFER_LIMIT_MINOR' => 4, + ])->dailyTransferLimitMinor)->toBe(4); +}); + +it('keeps searching the relaxed spellings when a higher-precedence key is present but null', function () { + // A present-but-null key has ALWAYS meant "not supplied" here (it is what the Laravel idiom + // 'key' => env('KEY') yields when the variable is unset), so it must not mask a real value + // written under another spelling — it only ever falls through to the constructor default. + $wallet = (new ReflectionConfigBinder)->bind(WalletProperties::class, [ + 'dailyTransferLimitMinor' => null, + 'daily_transfer_limit_minor' => 777, + ]); + + expect($wallet->dailyTransferLimitMinor)->toBe(777); +}); + +it('relaxes acronyms to the spelling a human would actually write', function () { + $props = (new ReflectionConfigBinder)->bind(AcronymProperties::class, [ + 'api_url' => 'https://api.example.com', + 'http_proxy_host' => 'proxy.internal', + ]); + + expect($props->apiURL)->toBe('https://api.example.com') + ->and($props->HTTPProxyHost)->toBe('proxy.internal'); +}); + +it('names every spelling it tried when a required property is missing', function () { + expect(fn () => (new ReflectionConfigBinder)->bind(MailProperties::class, ['port' => 25])) + ->toThrow(ConfigurationException::class, 'host, HOST'); +}); diff --git a/packages/config/tests/Fixtures/AcronymProperties.php b/packages/config/tests/Fixtures/AcronymProperties.php new file mode 100644 index 0000000..2e55001 --- /dev/null +++ b/packages/config/tests/Fixtures/AcronymProperties.php @@ -0,0 +1,20 @@ + snake_case relaxation. A naive + * "underscore before every capital" rule turns $apiURL into `api_u_r_l` and $HTTPProxyHost into + * `_h_t_t_p_proxy_host`, neither of which any human would ever write in a config file; the binder's + * two-pass regex produces `api_url` and `http_proxy_host` instead. Deliberately NOT annotated with + * #[ConfigProperties] — it exists to be bound directly by the binder test, not to be discovered. + */ +final readonly class AcronymProperties +{ + public function __construct( + public string $apiURL = 'unset', + public string $HTTPProxyHost = 'unset', + ) {} +} diff --git a/packages/config/tests/Fixtures/AuditProperties.php b/packages/config/tests/Fixtures/AuditProperties.php new file mode 100644 index 0000000..dfcffe6 --- /dev/null +++ b/packages/config/tests/Fixtures/AuditProperties.php @@ -0,0 +1,24 @@ +resolve()->all())->toBe(['default']); }); + +/** + * `php artisan config:cache` is the production half of the defect the testbench test covers. + * Laravel's LoadEnvironmentVariables bootstrapper returns EARLY when the configuration is cached, + * so the .env file is never parsed: getenv('APP_ENV') is false in a cached production boot even + * though the cached repository holds the right value under app.env. The old resolver therefore + * reported ['default'] on every cached production process — profiles silently disabled themselves + * the moment an application did the one thing every deployment guide tells it to do. + */ +it('reads APP_ENV from the config repository when the environment is empty (php artisan config:cache)', function () { + $repository = new Repository(['app' => ['env' => 'production']]); + + expect(getenv('APP_ENV'))->toBeFalse() + ->and((new ProfileResolver($repository))->resolve()->all())->toBe(['production']); +}); + +it('reads firefly.profiles.active from the config repository, as a list or as a comma-separated string', function () { + expect((new ProfileResolver(new Repository(['firefly' => ['profiles' => ['active' => ['prod', 'eu']]]])))->resolve()->all()) + ->toBe(['prod', 'eu']) + ->and((new ProfileResolver(new Repository(['firefly' => ['profiles' => ['active' => 'prod, eu']]])))->resolve()->all()) + ->toBe(['prod', 'eu']); +}); + +/** + * PHPUnit's entries, Docker's `--env`, php-fpm's env[] and testbench all populate $_ENV or + * $_SERVER without ever calling putenv(), so getenv() cannot see them. Laravel's own + * Illuminate\Support\Env reads all three, which is exactly why the resolver goes through it. + */ +it('reads an APP_ENV that lives only in $_ENV, where getenv() is blind', function () { + $_ENV['APP_ENV'] = 'qa'; + + expect(getenv('APP_ENV'))->toBeFalse() + ->and((new ProfileResolver)->resolve()->all())->toBe(['qa']); +}); + +it('lets a real environment variable override the config repository', function () { + putenv('FIREFLY_PROFILES_ACTIVE=canary'); + + $repository = new Repository(['firefly' => ['profiles' => ['active' => ['prod']]], 'app' => ['env' => 'production']]); + + expect((new ProfileResolver($repository))->resolve()->all())->toBe(['canary']); +}); + +it('falls back to the container-bound config repository when none is injected', function () { + // The zero-argument `new ProfileResolver` call sites that already exist across the framework + // (FireflyAutoConfigureServiceProvider is one) must keep working AND must keep seeing a cached + // configuration, so the resolver reaches for the application's bound 'config' repository when + // no repository was handed to it. + $container = new Container; + $container->instance('config', new Repository(['app' => ['env' => 'production']])); + Container::setInstance($container); + + expect((new ProfileResolver)->resolve()->all())->toBe(['production']); +}); + +it('ignores a container with no config binding rather than exploding', function () { + Container::setInstance(new Container); + + expect((new ProfileResolver)->resolve()->all())->toBe(['default']); +}); + +it('ignores a blank or non-scalar configured value and keeps looking', function () { + $repository = new Repository([ + 'firefly' => ['profiles' => ['active' => ' ']], + 'app' => ['env' => 'production'], + ]); + + expect((new ProfileResolver($repository))->resolve()->all())->toBe(['production']); +}); diff --git a/packages/config/tests/Profile/ProfileResolverTestbenchTest.php b/packages/config/tests/Profile/ProfileResolverTestbenchTest.php new file mode 100644 index 0000000..0048c80 --- /dev/null +++ b/packages/config/tests/Profile/ProfileResolverTestbenchTest.php @@ -0,0 +1,48 @@ +toBeFalse(); + + // Read the environment back off the repository rather than hard-coding it: testbench's default + // differs between a bare TestCase and a workbench skeleton, and the point of this test is that + // the resolver AGREES WITH LARAVEL, not that Laravel says any particular word. + $environment = config('app.env'); + + expect($environment)->toBeString()->not->toBe('') + ->and((new ProfileResolver)->resolve()->all())->toBe([$environment]) + // The old getenv()-only resolver produced exactly this, for every testbench suite: + ->and((new ProfileResolver)->resolve()->all())->not->toBe(['default']); +}); + +it('prefers FIREFLY_PROFILES_ACTIVE from the config repository over the Laravel environment', function () { + config(['firefly.profiles.active' => ['prod', 'eu']]); + + expect((new ProfileResolver)->resolve()->all())->toBe(['prod', 'eu']); +}); diff --git a/packages/config/tests/Profile/ProfilesTest.php b/packages/config/tests/Profile/ProfilesTest.php index 3ac8e2d..84fcf9c 100644 --- a/packages/config/tests/Profile/ProfilesTest.php +++ b/packages/config/tests/Profile/ProfilesTest.php @@ -18,3 +18,29 @@ expect((new Profiles([]))->isEmpty())->toBeTrue() ->and((new Profiles([]))->isActive('anything'))->toBeFalse(); }); + +/** + * accepts() is THE profile predicate for the whole framework — the one place the question "may this + * bean exist under the currently active profiles?" is answered. It is deliberately a method on the + * Profiles value object rather than a rule re-implemented at each gate, because the alternative is + * what the codebase actually had: a #[Profile] attribute that nothing consumed, and a separate + * #[ConditionalOnProfile] evaluator in firefly/context that quietly owned the only working copy of + * the semantics. + */ +it('accepts an unconstrained bean, whatever the active profiles', function () { + expect((new Profiles(['prod']))->accepts([]))->toBeTrue() + ->and((new Profiles([]))->accepts([]))->toBeTrue(); +}); + +it('accepts a constrained bean when ANY one of its required profiles is active (OR, never AND)', function () { + $profiles = new Profiles(['prod', 'eu']); + + expect($profiles->accepts(['prod']))->toBeTrue() + ->and($profiles->accepts(['dev', 'prod']))->toBeTrue() + ->and($profiles->accepts(['dev']))->toBeFalse() + ->and($profiles->accepts(['dev', 'staging']))->toBeFalse(); +}); + +it('rejects every constrained bean when no profile at all is active', function () { + expect((new Profiles([]))->accepts(['prod']))->toBeFalse(); +}); diff --git a/packages/config/tests/Registrar/ConfigProfileGatingTest.php b/packages/config/tests/Registrar/ConfigProfileGatingTest.php new file mode 100644 index 0000000..97c815a --- /dev/null +++ b/packages/config/tests/Registrar/ConfigProfileGatingTest.php @@ -0,0 +1,74 @@ +/src` matched nothing, so #[Profile('prod')] on a + * #[ConfigProperties] DTO registered the DTO in every profile — the precise opposite of what the + * annotation says. Here the requirement recorded by ConfigPropertiesScanner is finally acted on: + * the registrar does not bind a DTO whose profiles are not active, so injecting it fails loudly + * (Spring's NoSuchBeanDefinitionException shape) instead of silently handing back a bean that was + * meant to be excluded. + */ +function profiledContainer(Profiles $profiles): Container +{ + $config = new Config(new Repository([ + 'mail' => ['host' => 'smtp.example.com'], + 'audit' => ['enabled' => true, 'sink' => 'syslog'], + ])); + $descriptors = (new ConfigPropertiesScanner)->scan([ + 'Firefly\\Config\\Tests\\Fixtures\\' => __DIR__.'/../Fixtures', + ]); + + $container = new Container; + (new ConfigRegistrar($container, $config, profiles: $profiles))->register(new ConfigPropertiesManifest($descriptors)); + + return $container; +} + +it('does not register a profile-gated DTO when none of its profiles is active', function () { + $container = profiledContainer(new Profiles(['dev'])); + + expect($container->bound(AuditProperties::class))->toBeFalse() + // Unconstrained DTOs are unaffected — gating must never be an all-or-nothing switch: + ->and($container->bound(MailProperties::class))->toBeTrue(); +}); + +it('registers a profile-gated DTO when one of its profiles is active', function () { + $container = profiledContainer(new Profiles(['staging'])); + + expect($container->bound(AuditProperties::class))->toBeTrue() + ->and($container->make(AuditProperties::class)->enabled)->toBeTrue() + ->and($container->make(AuditProperties::class)->sink)->toBe('syslog'); +}); + +it('resolves the active profiles itself when the caller does not supply them', function () { + // FlushDefinitionsPass constructs the registrar with two arguments today, so the profile + // argument has to be optional; when it is omitted the registrar must still gate correctly + // rather than fall open. ProfileResolver reads FIREFLY_PROFILES_ACTIVE here. + putenv('FIREFLY_PROFILES_ACTIVE=prod'); + + try { + $config = new Config(new Repository(['audit' => ['enabled' => true]])); + $descriptors = (new ConfigPropertiesScanner)->scan([ + 'Firefly\\Config\\Tests\\Fixtures\\' => __DIR__.'/../Fixtures', + ]); + $container = new Container; + (new ConfigRegistrar($container, $config))->register(new ConfigPropertiesManifest($descriptors)); + + expect($container->bound(AuditProperties::class))->toBeTrue(); + } finally { + putenv('FIREFLY_PROFILES_ACTIVE'); + } +}); diff --git a/packages/config/tests/Scanner/ConfigManifestCompilerTest.php b/packages/config/tests/Scanner/ConfigManifestCompilerTest.php index afd18f7..02085f2 100644 --- a/packages/config/tests/Scanner/ConfigManifestCompilerTest.php +++ b/packages/config/tests/Scanner/ConfigManifestCompilerTest.php @@ -2,10 +2,16 @@ declare(strict_types=1); +use Firefly\Config\Config; +use Firefly\Config\Profile\Profiles; +use Firefly\Config\Registrar\ConfigRegistrar; use Firefly\Config\Scanner\ConfigManifestCompiler; use Firefly\Config\Scanner\ConfigPropertiesManifest; use Firefly\Config\Scanner\ConfigPropertiesScanner; +use Firefly\Config\Tests\Fixtures\AuditProperties; use Firefly\Config\Tests\Fixtures\MailProperties; +use Illuminate\Config\Repository; +use Illuminate\Container\Container; it('compiles a scan to a cached array file and loads it back without reflection', function () { $descriptors = (new ConfigPropertiesScanner)->scan([ @@ -31,3 +37,40 @@ unlink($path); }); + +/** + * The profile requirement has to survive the artifact, not merely the descriptor object: the whole + * point of recording it at scan time is that a production boot reads it out of a compiled file with + * no reflection at all. This walks the full path — scan, compile to disk, load back, register — + * and asserts the gate still fires on the far side. + */ +it('carries a #[Profile] requirement through the compiled artifact and gates registration on it', function () { + $descriptors = (new ConfigPropertiesScanner)->scan([ + 'Firefly\\Config\\Tests\\Fixtures\\' => __DIR__.'/../Fixtures', + ]); + + $path = sys_get_temp_dir().'/firefly-config-profile-manifest-'.bin2hex(random_bytes(6)).'.php'; + (new ConfigManifestCompiler)->write($descriptors, $path); + + $manifest = ConfigPropertiesManifest::load($path); + $profilesByClass = []; + foreach ($manifest->properties as $descriptor) { + $profilesByClass[$descriptor->class] = $descriptor->profiles; + } + + expect($profilesByClass[AuditProperties::class])->toBe(['prod', 'staging']) + ->and($profilesByClass[MailProperties::class])->toBe([]); + + $config = new Config(new Repository(['audit' => ['enabled' => true]])); + + $excluded = new Container; + (new ConfigRegistrar($excluded, $config, profiles: new Profiles(['dev'])))->register($manifest); + + $included = new Container; + (new ConfigRegistrar($included, $config, profiles: new Profiles(['prod'])))->register($manifest); + + expect($excluded->bound(AuditProperties::class))->toBeFalse() + ->and($included->bound(AuditProperties::class))->toBeTrue(); + + unlink($path); +}); diff --git a/packages/config/tests/Scanner/ConfigPropertiesScannerTest.php b/packages/config/tests/Scanner/ConfigPropertiesScannerTest.php index 7873aec..eb8aa01 100644 --- a/packages/config/tests/Scanner/ConfigPropertiesScannerTest.php +++ b/packages/config/tests/Scanner/ConfigPropertiesScannerTest.php @@ -4,6 +4,7 @@ use Firefly\Config\Scanner\ConfigPropertiesDescriptor; use Firefly\Config\Scanner\ConfigPropertiesScanner; +use Firefly\Config\Tests\Fixtures\AuditProperties; use Firefly\Config\Tests\Fixtures\DatabaseProperties; use Firefly\Config\Tests\Fixtures\MailProperties; use Firefly\Config\Tests\Fixtures\Pool; @@ -28,3 +29,54 @@ $d = new ConfigPropertiesDescriptor(MailProperties::class, 'mail'); expect(ConfigPropertiesDescriptor::fromArray($d->toArray()))->toEqual($d); }); + +/** + * #[Profile] used to be a decoration and nothing more: the attribute shipped, the docs described it, + * and `grep -rn 'Profile::class' packages//src` returned NOTHING — no scanner recorded it and no + * condition could act on it, so a bean marked #[Profile('prod')] was registered in every profile, + * including the ones it was explicitly excluded from. The scanner is the first half of the fix: the + * requirement is read ONCE here, at scan time, and travels in the compiled manifest, so no + * reflection is needed at boot to know that a DTO is profile-gated. + */ +it('records a #[Profile] requirement on the descriptor it scans', function () { + $descriptors = (new ConfigPropertiesScanner)->scan([ + 'Firefly\\Config\\Tests\\Fixtures\\' => __DIR__.'/../Fixtures', + ]); + + $byClass = []; + foreach ($descriptors as $d) { + $byClass[$d->class] = $d->profiles; + } + + expect($byClass[AuditProperties::class])->toBe(['prod', 'staging']) + // An unannotated DTO carries no requirement at all — never a sentinel, never null: + ->and($byClass[MailProperties::class])->toBe([]); +}); + +it('round-trips a profile-gated descriptor', function () { + $d = new ConfigPropertiesDescriptor(AuditProperties::class, 'audit', ['prod', 'staging']); + + expect(ConfigPropertiesDescriptor::fromArray($d->toArray()))->toEqual($d); +}); + +/** + * A compiled config-properties.php written by an older firefly:cache (or by an older release of + * firefly/cli, which is a separate package on its own release cadence) has rows with only `class` + * and `prefix`. Those must keep loading as "no profile requirement" rather than blowing up on a + * missing array key, so an upgrade never has to be sequenced with a cache rebuild. + */ +it('loads a legacy descriptor row that predates profile recording', function () { + $d = ConfigPropertiesDescriptor::fromArray(['class' => MailProperties::class, 'prefix' => 'mail']); + + expect($d->profiles)->toBe([]); +}); + +/** + * Conversely, a descriptor with NO requirement must serialize to exactly the two keys it always + * did. That keeps the emitted artifact byte-identical for the overwhelmingly common case, so this + * change cannot show up as a spurious diff in a committed cache file or in firefly/cli's fixtures. + */ +it('keeps the compiled row byte-identical when there is no profile requirement', function () { + expect((new ConfigPropertiesDescriptor(MailProperties::class, 'mail'))->toArray()) + ->toBe(['class' => MailProperties::class, 'prefix' => 'mail']); +}); diff --git a/packages/container/src/Attributes/Qualifier.php b/packages/container/src/Attributes/Qualifier.php index 97970c9..9fa8aad 100644 --- a/packages/container/src/Attributes/Qualifier.php +++ b/packages/container/src/Attributes/Qualifier.php @@ -5,9 +5,46 @@ namespace Firefly\Container\Attributes; use Attribute; +use Illuminate\Contracts\Container\ContextualAttribute; +/** + * Names the ONE bean a resolution should pick when the type alone is ambiguous. + * + * Three targets, two very different jobs: + * + * - On a CLASS it is metadata: ComponentScanner records it on + * ComponentDescriptor::$qualifier and ContainerRegistrar::registerName() aliases + * the component under it, so `getByName('spanish')` works. + * - On a PARAMETER it is an injection instruction: "resolve this argument from the + * bean called $name, not from its declared type". This is the half that was + * DEAD. The attribute declared TARGET_PARAMETER from day one and absolutely + * nothing read it: `#[Qualifier('redisCache')] Cache $cache` silently received + * whatever Cache::class happened to resolve to, with no error and no warning — + * the worst possible failure mode, because the wrong dependency is injected and + * the application keeps running. It was caught by asking for a NON-primary bean + * by name and observing the #[Primary] one arrive instead. + * + * WHY IT IS A ContextualAttribute AND NOT A MANIFEST FIELD. A parameter qualifier + * belongs to a constructor argument, not to a component, so it has no place on + * ComponentDescriptor — and the compiled manifest deliberately describes COMPONENTS, + * not their argument lists. The manifest is what makes DISCOVERY reflection-free at + * runtime; it never claimed to make Illuminate's own container build reflection-free, + * and it cannot: ContainerRegistrar binds `singleton($class, $class)` and lets + * Illuminate autowire, which already reflects every constructor it builds. Illuminate + * exposes exactly one seam on that existing reflection pass — + * Container::whenHasAttribute() over parameter attributes implementing + * ContextualAttribute — and #[Value] has ridden it since M2. Wiring #[Qualifier] the + * same way costs one interface, adds ZERO reflection that was not already happening, + * and leaves the compiled manifest shape untouched, so every manifest already on disk + * keeps loading unchanged. The handler lives in + * ContainerRegistrar::registerQualifierSupport(). + * + * Because Illuminate reads contextual attributes in both Container::resolveDependencies() + * and BoundMethod::addDependencyForCallParameter(), a qualifier works on #[Bean] factory + * method parameters as well as on constructors. + */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)] -final class Qualifier +final class Qualifier implements ContextualAttribute { public function __construct(public string $name) {} } diff --git a/packages/container/src/Descriptor/BeanDescriptor.php b/packages/container/src/Descriptor/BeanDescriptor.php index baccc98..3d93b30 100644 --- a/packages/container/src/Descriptor/BeanDescriptor.php +++ b/packages/container/src/Descriptor/BeanDescriptor.php @@ -14,11 +14,18 @@ public function __construct( public ?string $name, public Scope $scope, /** - * Captured from #[Primary] on the #[Bean] method, but NOT yet - * consulted during registration in this milestone (registerBeans() - * ignores it). Reserved for future bean-collision disambiguation, - * analogous to how ContainerRegistrar::wireInterfaces() uses - * ComponentDescriptor::$primary to pick a default interface impl. + * Captured from #[Primary] on the #[Bean] method: when SEVERAL #[Bean] + * methods produce the same return type, this marks the one the bare type + * resolves to, while every candidate stays reachable under its own + * #[Bean] name. Exactly the role ComponentDescriptor::$primary plays for + * competing interface implementations in + * ContainerRegistrar::wireInterfaces(). + * + * It was inert for a long time — registerBeans() read it NOWHERE, which + * is half of why two beans of one type used to collapse onto a single + * binding with no way to tell them apart. It is consulted now; see + * ContainerRegistrar::registerBeans() for the full rule, including the + * registration-time errors for shapes #[Primary] cannot rescue. */ public bool $primary, public int $order, @@ -29,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 { @@ -44,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 { @@ -63,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/Registrar/ContainerRegistrar.php b/packages/container/src/Registrar/ContainerRegistrar.php index e0d7fff..904d275 100644 --- a/packages/container/src/Registrar/ContainerRegistrar.php +++ b/packages/container/src/Registrar/ContainerRegistrar.php @@ -4,12 +4,17 @@ namespace Firefly\Container\Registrar; +use Closure; +use Firefly\Container\Attributes\Qualifier; use Firefly\Container\Attributes\Value; +use Firefly\Container\Descriptor\BeanDescriptor; use Firefly\Container\Descriptor\ComponentDescriptor; use Firefly\Container\Scanner\ComponentManifest; use Firefly\Container\Scope; use Firefly\Container\Value\DefaultValueResolver; use Firefly\Container\Value\ValueResolver; +use Firefly\Kernel\Exception\Framework\BeanNotFoundException; +use Firefly\Kernel\Exception\Framework\ConfigurationException; use Illuminate\Container\Container; final class ContainerRegistrar @@ -32,18 +37,20 @@ public function register(ComponentManifest $manifest): void $this->container->instance(self::REGISTERED, true); $this->registerValueSupport(); + $this->registerQualifierSupport(); - /** @var array $beanBoundTypes */ - $beanBoundTypes = []; foreach ($manifest->components as $component) { $this->bindClass($component); $this->registerName($component); - foreach ($this->registerBeans($component) as $type) { - $beanBoundTypes[$type] = true; - } } - $this->wireInterfaces($manifest, $beanBoundTypes); + // Beans are registered AFTER every component, in one sweep over the whole + // manifest rather than interleaved per component. Both changes are load-bearing: + // registerBeans() has to see every #[Bean] of a given return type at once to + // detect competing definitions across DIFFERENT #[Configuration] classes, and + // running it last makes the precedence rule unambiguous — where a #[Bean] name + // and a component name collide, the explicitly declared bean wins. + $this->wireInterfaces($manifest, $this->registerBeans($manifest)); } public function tagFor(string $interface): string @@ -74,48 +81,231 @@ private function registerName(ComponentDescriptor $component): void } /** - * @return list the non-empty $bean->returns type-keys actually bound + * Register every #[Bean] factory in the manifest, grouped by the type it produces. + * + * WHAT WAS BROKEN. Each bean was bound under its RETURN TYPE and its name was only + * ever recorded as `alias($bean->returns, $bean->name)`. An alias is a pointer to a + * key, not a binding of its own, so two #[Bean] methods returning the same type + * COLLAPSED: both names pointed at the single type key, that key held whichever + * factory registered last, and `getByName('memoryCache')` and + * `getByName('redisCache')` handed back the very same object. Nothing errored; + * one of the two beans simply never existed. #[Primary] could not break the tie + * either — BeanDescriptor::$primary was read NOWHERE in the bean path (only + * ComponentDescriptor::$primary was, in wireInterfaces(), and only for components). + * + * THE RULE NOW, per return type: + * + * - ONE bean produces the type (overwhelmingly the common case): unchanged. + * The factory is bound on the return type and the name, if any, is aliased to + * it — so the type and the name keep resolving 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 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" of one bean). + * With no #[Primary] the type key is bound to a guard factory that throws a + * NoUniqueBeanDefinition-style ConfigurationException naming the candidates, which + * beats both silently picking one and Illuminate's opaque "Target [X] is not + * instantiable"; the type stays BOUND so #[ConditionalOnMissingBean] still sees + * that a bean of that type exists. + * + * Two shapes cannot be expressed at all and are rejected at REGISTRATION time, + * where the stack trace still points at the manifest rather than at some unlucky + * consumer: competing beans that are anonymous (no name => unreachable, and no way + * to disambiguate), and competing beans sharing one name (one would silently + * overwrite the other). More than one #[Primary] for a type is rejected there too. + * + * @return array every type key CLAIMED by a #[Bean] factory, whether + * or not it resolves — wireInterfaces() must not + * overwrite a contested type with a scanned impl either + */ + private function registerBeans(ComponentManifest $manifest): array + { + /** @var array> $byType */ + $byType = []; + foreach ($manifest->components as $component) { + foreach ($component->beans as $bean) { + // A builtin or untyped return records '' (see ComponentScanner): there + // is no type key to bind, so the bean is not registrable at all. + if ($bean->returns === '') { + continue; + } + $byType[$bean->returns][] = [$component, $bean]; + } + } + + $claimed = []; + foreach ($byType as $type => $candidates) { + $claimed[$type] = true; + + if (count($candidates) === 1) { + [$component, $bean] = $candidates[0]; + $this->bindBean($type, $component, $bean); + + if ($bean->name !== null && $bean->name !== $type) { + // Same last-registration-wins semantics as registerName() above. + $this->container->alias($type, $bean->name); + } + + continue; + } + + $this->bindCompetingBeans($type, $candidates); + } + + return $claimed; + } + + /** + * Register the two-or-more-beans-per-type case validated and named. + * + * @param list $candidates */ - private function registerBeans(ComponentDescriptor $component): array + private function bindCompetingBeans(string $type, array $candidates): void { - $boundTypes = []; + /** @var array $byName */ + $byName = []; + $anonymous = []; + + foreach ($candidates as [$component, $bean]) { + $origin = $component->class.'::'.$bean->method.'()'; + + if ($bean->name === null) { + $anonymous[] = $origin; - foreach ($component->beans as $bean) { - if ($bean->returns === '') { continue; } - $configClass = $component->class; - $method = $bean->method; - $factory = function (Container $c) use ($configClass, $method): mixed { - /** @var object $config */ - $config = $c->make($configClass); - - // The scanner only records #[Bean] on public methods that - // exist on $configClass (see ComponentScanner::beansOf()), - // so this array is guaranteed to be a valid callable; PHPStan - // cannot verify that from a dynamic method-name string alone. - /** @var callable $callable */ - $callable = [$config, $method]; - - return $c->call($callable); - }; - - match ($bean->scope) { - Scope::Singleton => $this->container->singleton($bean->returns, $factory), - Scope::Transient => $this->container->bind($bean->returns, $factory), - Scope::Scoped => $this->container->scoped($bean->returns, $factory), - }; - - if ($bean->name !== null && $bean->name !== $bean->returns) { - // Same last-registration-wins semantics as registerName() above. - $this->container->alias($bean->returns, $bean->name); + if ($bean->name === $type) { + // The contested type key is owned by the GROUP — it is either an alias + // of the #[Primary] winner or the ambiguity guard below. A candidate + // that names itself after that key would have its own binding silently + // replaced by whichever of the two lands last, leaving a declared bean + // with no reachable name at all. + throw new ConfigurationException(sprintf( + '%s is named after the very type it competes for (%s). That name IS the type key, ' + .'which the #[Primary] winner claims for the whole group; give the bean a name of ' + .'its own so it stays individually resolvable.', + $origin, + $type, + )); + } + + if (isset($byName[$bean->name])) { + [$owner, $ownerBean] = $byName[$bean->name]; + + throw new ConfigurationException(sprintf( + "Duplicate #[Bean] name '%s' for type %s: %s and %s both register under it. " + .'A bean name is a container key, so the second would silently overwrite the first; ' + .'give each competing #[Bean] method a distinct name.', + $bean->name, + $type, + $owner->class.'::'.$ownerBean->method.'()', + $origin, + )); } - $boundTypes[] = $bean->returns; + $byName[$bean->name] = [$component, $bean]; + } + + if ($anonymous !== []) { + throw new ConfigurationException(sprintf( + 'No unique bean of type %s: %d #[Bean] methods produce it and %d of them ' + .'declare no name (%s). An anonymous bean is only reachable through its return type, ' + .'which its competitors already claim, so it can never be resolved. Give every competing ' + ."#[Bean] method an explicit name — #[Bean('someName')] — and mark exactly one #[Primary] " + .'to become the default for the bare type.', + $type, + count($candidates), + count($anonymous), + implode(', ', $anonymous), + )); + } + + $primaries = []; + foreach ($byName as $name => [, $bean]) { + if ($bean->primary) { + $primaries[] = $name; + } + } + + if (count($primaries) > 1) { + throw new ConfigurationException(sprintf( + 'Type %s is produced by more than one #[Primary] #[Bean] (%s). #[Primary] exists to ' + .'name the single default for a contested type, so at most one candidate may carry it.', + $type, + implode(', ', $primaries), + )); } - return $boundTypes; + foreach ($byName as $name => [$component, $bean]) { + $this->bindBean($name, $component, $bean); + } + + if ($primaries === []) { + $this->bindAmbiguousType($type, array_keys($byName)); + + return; + } + + // An ALIAS, not a binding: the type must resolve to the very instance the + // primary's own name resolves to, or a Scope::Singleton bean would exist twice. + // No candidate can be named $type (rejected above), so this never self-aliases. + $this->container->alias($primaries[0], $type); + } + + /** + * Bind a contested type with no #[Primary] to a factory that refuses, loudly. + * + * Leaving the type unbound instead would be worse in both directions: a concrete + * return type would silently AUTO-WIRE (bypassing every #[Bean] factory and handing + * back an object the configuration never produced), while an interface would fail + * with Illuminate's "Target [X] is not instantiable" — technically true, entirely + * unhelpful, and not a hint that two beans are competing. + * + * @param list $names the competing bean names, all individually resolvable + */ + private function bindAmbiguousType(string $type, array $names): void + { + $this->container->bind($type, static function () use ($type, $names): never { + throw new ConfigurationException(sprintf( + 'No unique bean of type %s: %d candidates (%s). Mark exactly one #[Bean] method ' + .'#[Primary] to make it the default for this type, or ask for the one you want by name — ' + ."#[Qualifier('%s')] on the injected parameter, or getByName('%s').", + $type, + count($names), + implode(', ', $names), + $names[0], + $names[0], + )); + }); + } + + private function bindBean(string $key, ComponentDescriptor $component, BeanDescriptor $bean): void + { + $factory = $this->beanFactory($component->class, $bean->method); + + match ($bean->scope) { + Scope::Singleton => $this->container->singleton($key, $factory), + Scope::Transient => $this->container->bind($key, $factory), + Scope::Scoped => $this->container->scoped($key, $factory), + }; + } + + private function beanFactory(string $configClass, string $method): Closure + { + return function (Container $c) use ($configClass, $method): mixed { + /** @var object $config */ + $config = $c->make($configClass); + + // The scanner only records #[Bean] on public methods that + // exist on $configClass (see ComponentScanner::beansOf()), + // so this array is guaranteed to be a valid callable; PHPStan + // cannot verify that from a dynamic method-name string alone. + /** @var callable $callable */ + $callable = [$config, $method]; + + return $c->call($callable); + }; } private function registerValueSupport(): void @@ -132,7 +322,43 @@ private function registerValueSupport(): void } /** - * @param array $beanBoundTypes interface/type keys already bound by a #[Bean] factory + * Teach the container to honour #[Qualifier] on an injected parameter. + * + * This is the runtime half of the attribute, and until now it did not exist: + * #[Qualifier] declared TARGET_PARAMETER and nothing anywhere read it, so + * `#[Qualifier('redisCache')] Cache $cache` was injected from Cache::class like an + * unannotated parameter — the wrong bean, silently, with the application none the + * wiser. It rides the SAME Illuminate seam #[Value] already uses (see + * registerValueSupport() and Qualifier's docblock for why that seam, and not the + * compiled manifest, is the right carrier for a per-parameter instruction). + * + * The name is a container key, resolved exactly as getByName() would resolve it, so + * it reaches the bean that registerBeans() bound under that name. An unknown name is + * a BeanNotFoundException naming the qualifier: without the explicit bound() check + * Illuminate would report `Target class [redisCache] does not exist`, which sends + * the reader hunting for a class that was never meant to be one. + */ + private function registerQualifierSupport(): void + { + $this->container->whenHasAttribute( + Qualifier::class, + function (Qualifier $attribute): mixed { + if (! $this->container->bound($attribute->name)) { + throw new BeanNotFoundException(sprintf( + "No bean named '%s' is registered, so #[Qualifier('%s')] cannot be satisfied. " + .'Check the #[Bean] name or the #[Component]/#[Qualifier] name it refers to.', + $attribute->name, + $attribute->name, + )); + } + + return $this->container->make($attribute->name); + }, + ); + } + + /** + * @param array $beanBoundTypes interface/type keys already claimed by a #[Bean] factory */ private function wireInterfaces(ComponentManifest $manifest, array $beanBoundTypes): void { @@ -157,7 +383,10 @@ private function wireInterfaces(ComponentManifest $manifest, array $beanBoundTyp // binding: registerBeans() runs first and, when a #[Bean] method's return // type IS this interface, already bound a Closure factory on $interface. // Without this guard, the default bind() below would silently clobber - // that factory with the scanned implementation's class binding. + // that factory with the scanned implementation's class binding. A type + // left CONTESTED by competing beans counts as claimed too: quietly + // resolving it to a scanned component would hide the ambiguity rather + // than report it. if (isset($beanBoundTypes[$interface])) { continue; } diff --git a/packages/container/src/Scanner/ComponentScanner.php b/packages/container/src/Scanner/ComponentScanner.php index d9381f5..33ac673 100644 --- a/packages/container/src/Scanner/ComponentScanner.php +++ b/packages/container/src/Scanner/ComponentScanner.php @@ -97,6 +97,10 @@ private function describe(string $class): ?ComponentDescriptor /** @var Component $component */ $component = $componentAttrs[0]->newInstance(); + + // The stereotype is a REPORTING label (actuator's /beans, BeanDefinition, + // ContextDescriptor) — never a behavioural switch. See beansOf() below for + // the switch that used to be built on it and the bug that caused. $shortAttr = strtolower((new ReflectionClass($component))->getShortName()); /** @var list $interfaces */ @@ -111,12 +115,71 @@ class: $class, order: $this->orderOf($reflection->getAttributes(Order::class)), qualifier: $this->qualifierOf($reflection->getAttributes(Qualifier::class)), interfaces: $interfaces, - beans: $shortAttr === 'configuration' ? $this->beansOf($reflection) : [], + 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. + * + * WHY THIS IS UNCONDITIONAL. It used to be gated by the caller on a stereotype + * SHORT-NAME STRING comparison — `$shortAttr === 'configuration'` — the single + * place in the scanner that abandoned the ReflectionAttribute::IS_INSTANCEOF + * discipline used everywhere else (describe() finds stereotypes via + * getAttributes(Component::class, IS_INSTANCEOF) precisely so that a subclass of + * a stereotype IS that stereotype). String equality is not subtype equality, so + * the gate silently dropped every bean it did not recognise by exact spelling: + * + * - a user-defined stereotype specialising #[Configuration] — `#[Attribute] final + * class ApiConfiguration extends Configuration {}` — reports the short name + * 'apiconfiguration', so ALL of its #[Bean] methods vanished from the manifest. + * The class itself was still discovered and bound, which made the failure + * especially confusing: the #[Configuration] was present, its beans were not. + * - a #[Bean] method on a plain #[Component] (or on #[Service]/#[Repository]) + * vanished the same way, even though Spring processes bean factory methods on + * ANY component class — its "lite mode" configuration classes. + * + * Nothing is lost by always scanning: a component with no #[Bean] method yields + * an empty list, exactly as the gate used to force. The only stereotype test left + * in this scanner is describe()'s IS_INSTANCEOF Component check — where it belongs. + * * @param ReflectionClass $reflection * @return list */ @@ -143,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/container/tests/Fixtures/ApiBeansConfig.php b/packages/container/tests/Fixtures/ApiBeansConfig.php new file mode 100644 index 0000000..8e11772 --- /dev/null +++ b/packages/container/tests/Fixtures/ApiBeansConfig.php @@ -0,0 +1,22 @@ +scan([ + 'Firefly\\Container\\Tests\\Fixtures\\' => __DIR__.'/../Fixtures', + ]); + $illuminate = new IlluminateContainer; + (new ContainerRegistrar($illuminate))->register(new ComponentManifest($components)); + + return $illuminate; +} + +/** + * Builds a one-component manifest around hand-written BeanDescriptors. The + * registration-time collision guards are about descriptor SHAPE, so the beans + * are declared directly rather than round-tripped through a fixture class. + * + * @param list $beans + */ +function beanCollisionManifest(string $class, array $beans): ComponentManifest +{ + return new ComponentManifest([ + new ComponentDescriptor( + class: $class, + stereotype: 'configuration', + name: null, + scope: Scope::Singleton, + primary: false, + order: 0, + qualifier: null, + interfaces: [], + beans: $beans, + ), + ]); +} + +// A hand-built #[Configuration] stand-in for the collision guards. Kept OUT of +// tests/Fixtures/ so the shared ComponentScanner never picks it up. +final class CollisionConfig +{ + public function first(): Gadget + { + return new Gadget; + } + + public function second(): Gadget + { + return new Gadget; + } +} + +// --- (1) named beans of one type --------------------------------------------- + +it('binds every competing #[Bean] under its own name instead of collapsing them onto one alias', function () { + $c = beanFixtureContainer(); + + // Both names used to be aliases of Cache::class, so BOTH resolved to + // whichever factory was registered last. They must now be distinct beans. + /** @var Cache $memory */ + $memory = $c->make('memoryCache'); + /** @var Cache $redis */ + $redis = $c->make('redisCache'); + + expect($memory)->toBeInstanceOf(MemoryCache::class) + ->and($redis)->toBeInstanceOf(RedisCache::class) + ->and($memory->label())->toBe('memory') + ->and($redis->label())->toBe('redis'); +}); + +it('picks the type-level default from #[Primary] on a #[Bean] method', function () { + $c = beanFixtureContainer(); + + // BeanDescriptor::$primary was read NOWHERE before this fix. + expect($c->make(Cache::class))->toBeInstanceOf(MemoryCache::class); +}); + +it('keeps one singleton behind a bean name and the type default it wins', function () { + $c = beanFixtureContainer(); + + // The type key must ALIAS the winning bean rather than re-bind the factory: + // a second binding would make Cache::class and 'memoryCache' two distinct + // singletons of the same #[Bean] method. Bean names are plain container + // keys, so make() reports `mixed` and each resolution is narrowed here. + /** @var Cache $byType */ + $byType = $c->make(Cache::class); + /** @var Cache $memory */ + $memory = $c->make('memoryCache'); + /** @var Cache $redis */ + $redis = $c->make('redisCache'); + + /** @var Cache $memoryAgain */ + $memoryAgain = $c->make('memoryCache'); + /** @var Cache $redisAgain */ + $redisAgain = $c->make('redisCache'); + + expect($byType)->toBe($memory) + ->and($memoryAgain)->toBe($memory) + ->and($redisAgain)->toBe($redis) + ->and($memory)->not->toBe($redis); +}); + +it('preserves the single-bean-per-type path: the type binding and its name alias stay one instance', function () { + $c = beanFixtureContainer(); + + /** @var Clock $clock */ + $clock = $c->make(Clock::class); + /** @var Clock $named */ + $named = $c->make('utcClock'); + + expect($clock->zone)->toBe('UTC') + ->and($named)->toBe($clock); +}); + +it('leaves a contested type resolvable by name and answers the bare type with a NoUniqueBeanDefinition error', function () { + $c = beanFixtureContainer(); + + // Three named #[Bean] methods return Gadget and none is #[Primary]. + /** @var Gadget $edge */ + $edge = $c->make('edgeGadget'); + /** @var Gadget $lazy */ + $lazy = $c->make('lazyGadget'); + /** @var Gadget $eager */ + $eager = $c->make('eagerGadget'); + + expect($edge)->toBeInstanceOf(Gadget::class) + ->and($lazy)->toBeInstanceOf(Gadget::class) + ->and($eager)->toBeInstanceOf(Gadget::class) + ->and($edge)->not->toBe($lazy) + // The type stays BOUND so #[ConditionalOnMissingBean] still sees that a + // bean of this type exists — resolving it is what fails, loudly. + ->and($c->bound(Gadget::class))->toBeTrue(); + + expect(fn () => $c->make(Gadget::class)) + ->toThrow(ConfigurationException::class, 'No unique bean of type'); +}); + +// --- (1b) registration-time guards ------------------------------------------- + +it('refuses to register two anonymous #[Bean] methods returning the same type', function () { + $manifest = beanCollisionManifest(CollisionConfig::class, [ + new BeanDescriptor('first', Gadget::class, null, Scope::Singleton, false, 0), + new BeanDescriptor('second', Gadget::class, null, Scope::Singleton, false, 0), + ]); + + expect(fn () => (new ContainerRegistrar(new IlluminateContainer))->register($manifest)) + ->toThrow(ConfigurationException::class, 'CollisionConfig::second()'); +}); + +it('refuses to register two #[Bean] methods of one type under the same name', function () { + $manifest = beanCollisionManifest(CollisionConfig::class, [ + new BeanDescriptor('first', Gadget::class, 'gadget', Scope::Singleton, false, 0), + new BeanDescriptor('second', Gadget::class, 'gadget', Scope::Singleton, false, 0), + ]); + + expect(fn () => (new ContainerRegistrar(new IlluminateContainer))->register($manifest)) + ->toThrow(ConfigurationException::class, "Duplicate #[Bean] name 'gadget'"); +}); + +it('refuses to register a competing #[Bean] named after the contested type itself', function () { + // Gadget::class as a bean name IS the contested type key, which the group owns: + // binding the bean there and then pointing the type at the #[Primary] winner would + // leave the first bean declared but unreachable. + $manifest = beanCollisionManifest(CollisionConfig::class, [ + new BeanDescriptor('first', Gadget::class, Gadget::class, Scope::Singleton, false, 0), + new BeanDescriptor('second', Gadget::class, 'other', Scope::Singleton, true, 0), + ]); + + expect(fn () => (new ContainerRegistrar(new IlluminateContainer))->register($manifest)) + ->toThrow(ConfigurationException::class, 'named after the very type it competes for'); +}); + +it('refuses to register more than one #[Primary] #[Bean] for the same type', function () { + $manifest = beanCollisionManifest(CollisionConfig::class, [ + new BeanDescriptor('first', Gadget::class, 'a', Scope::Singleton, true, 0), + new BeanDescriptor('second', Gadget::class, 'b', Scope::Singleton, true, 0), + ]); + + expect(fn () => (new ContainerRegistrar(new IlluminateContainer))->register($manifest)) + ->toThrow(ConfigurationException::class, 'more than one #[Primary]'); +}); + +it('still registers a lone anonymous #[Bean] exactly as before', function () { + $manifest = beanCollisionManifest(CollisionConfig::class, [ + new BeanDescriptor('first', Gadget::class, null, Scope::Singleton, false, 0), + ]); + + $c = new IlluminateContainer; + (new ContainerRegistrar($c))->register($manifest); + + expect($c->make(Gadget::class))->toBeInstanceOf(Gadget::class) + ->and($c->make(Gadget::class))->toBe($c->make(Gadget::class)); +}); + +// --- (2) #[Qualifier] on a constructor parameter ------------------------------ + +it('resolves a constructor #[Qualifier] by bean name, not by parameter type', function () { + $c = beanFixtureContainer(); + + /** @var CacheConsumer $consumer */ + $consumer = $c->make(CacheConsumer::class); + + // Cache::class alone resolves to the #[Primary] MemoryCache, so a RedisCache + // here can only have come from reading #[Qualifier('redisCache')]. + expect($consumer->cache)->toBeInstanceOf(RedisCache::class) + ->and($consumer->cache)->toBe($c->make('redisCache')); +}); + +it('reports an unknown #[Qualifier] name instead of silently falling back to the type', function () { + $c = beanFixtureContainer(); + + $broken = new class(new MemoryCache) + { + public function __construct( + #[Qualifier('noSuchCache')] + public readonly Cache $cache, + ) {} + }; + + expect(fn () => $c->make($broken::class)) + ->toThrow(BeanNotFoundException::class, 'noSuchCache'); +}); + +it('resolves a #[Qualifier] on a #[Bean] factory method parameter too', function () { + $c = beanFixtureContainer(); + + $config = new class + { + #[Bean] + public function probe(#[Qualifier('redisCache')] Cache $cache): Cache + { + return $cache; + } + }; + + expect($c->call([$config, 'probe']))->toBeInstanceOf(RedisCache::class); +}); + +// --- (3) #[Bean] discovery beyond the literal #[Configuration] stereotype ------ + +it('registers #[Bean] methods declared on a custom #[Configuration] subclass stereotype', function () { + $c = beanFixtureContainer(); + + /** @var ApiToken $token */ + $token = $c->make('apiToken'); + + expect($token->value)->toBe('t-42') + ->and($c->make(ApiToken::class))->toBe($token); +}); + +it('registers #[Bean] methods declared on a plain #[Component]', function () { + $c = beanFixtureContainer(); + + /** @var Stamp $stamp */ + $stamp = $c->make('inkStamp'); + + expect($stamp->ink)->toBe('blue') + ->and($c->make(Stamp::class))->toBe($stamp); +}); diff --git a/packages/container/tests/Scanner/ComponentScannerTest.php b/packages/container/tests/Scanner/ComponentScannerTest.php index e7ee7f1..8204cf2 100644 --- a/packages/container/tests/Scanner/ComponentScannerTest.php +++ b/packages/container/tests/Scanner/ComponentScannerTest.php @@ -5,6 +5,8 @@ use Firefly\Container\Descriptor\ComponentDescriptor; use Firefly\Container\Scanner\ComponentScanner; use Firefly\Container\Scope; +use Firefly\Container\Tests\Fixtures\ApiBeansConfig; +use Firefly\Container\Tests\Fixtures\ApiToken; use Firefly\Container\Tests\Fixtures\AppConfig; use Firefly\Container\Tests\Fixtures\Clock; use Firefly\Container\Tests\Fixtures\EdgeConfig; @@ -15,6 +17,8 @@ use Firefly\Container\Tests\Fixtures\LazyWidget; use Firefly\Container\Tests\Fixtures\LoudGreeter; use Firefly\Container\Tests\Fixtures\SpanishGreeter; +use Firefly\Container\Tests\Fixtures\Stamp; +use Firefly\Container\Tests\Fixtures\StampComponent; use Firefly\Container\Tests\Fixtures\Widget; /** @@ -79,6 +83,45 @@ function scanFixtures(): array ->and($config->beans[0]->scope)->toBe(Scope::Singleton); }); +/** + * @return ComponentDescriptor the scanned descriptor for $class + */ +function scannedDescriptor(string $class): ComponentDescriptor +{ + foreach (scanFixtures() as $descriptor) { + if ($descriptor->class === $class) { + return $descriptor; + } + } + + throw new RuntimeException("No descriptor scanned for {$class}."); +} + +it('captures #[Bean] methods declared under a CUSTOM stereotype that specialises #[Configuration]', function () { + // #[ApiConfiguration] extends #[Configuration], so it IS a Configuration by the + // IS_INSTANCEOF discipline the rest of the scanner uses. Its short name is + // 'apiconfiguration' though, and the old `$shortAttr === 'configuration'` gate + // compared strings, so every bean under it was silently dropped. + $config = scannedDescriptor(ApiBeansConfig::class); + + expect($config->stereotype)->toBe('apiconfiguration') + ->and($config->beans)->toHaveCount(1) + ->and($config->beans[0]->method)->toBe('token') + ->and($config->beans[0]->name)->toBe('apiToken') + ->and($config->beans[0]->returns)->toBe(ApiToken::class); +}); + +it('captures #[Bean] methods declared on a plain #[Component], not only on #[Configuration]', function () { + // Spring processes @Bean methods on any @Component ("lite mode"); the short-name + // gate dropped them because the stereotype reads 'component'. + $component = scannedDescriptor(StampComponent::class); + + expect($component->stereotype)->toBe('component') + ->and($component->beans)->toHaveCount(1) + ->and($component->beans[0]->name)->toBe('inkStamp') + ->and($component->beans[0]->returns)->toBe(Stamp::class); +}); + it('records the empty-string return-type contract for builtin/untyped #[Bean] returns', function () { $edge = null; foreach (scanFixtures() as $d) { 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..1a0dce6 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 { @@ -40,33 +61,87 @@ 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 */ private function orderedEagerAbstracts(BootContext $context): array { + $keys = BeanBindingKeys::fromDefinitions($context->definitions); + /** @var list $entries */ $entries = []; 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]; } 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/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/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/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 @@ +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/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/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/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/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/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-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/README.md b/packages/eda-postgres/README.md index 507f3bc..4260cdb 100644 --- a/packages/eda-postgres/README.md +++ b/packages/eda-postgres/README.md @@ -15,6 +15,32 @@ php artisan migrate # creates firefly_eda_outbox php artisan firefly:eda:consume # terminal in-process delivery ``` +## Terminal in-process delivery + +`EventListenerWiringPass` subscribes the app's compiled `#[EventListener]`s by calling `subscribe()` on the +bound `EventPublisher` — under this provider, `PostgresEventPublisher` — which records them on the **shared** +`SubscriberRegistry` singleton. `firefly:eda:consume` resolves that same registry and feeds every claimed row +into it, acking a row only after delivery returns. A handler that throws leaves the row `PENDING` with +`attempts + 1` (or `FAILED` past `max_attempts`), so a failed delivery is retried rather than lost. + +## The optional relay + +`firefly:outbox:relay` forwards committed `PENDING` rows to a **distinct** downstream broker. It is genuinely +optional: an app whose only consumers are `#[EventListener]` handlers never runs it. When you do, name the +downstream with `firefly.eda.postgres.relay.downstream_provider`, which accepts: + +* `rabbitmq` / `kafka` — the shipped adapter, built from that package's own config keys + (`firefly.eda.rabbitmq.exchange`, `firefly.eda.kafka.brokers`); +* the class-string of any `EventPublisher`, or the id of anything bound in the container; +* nothing at all, if you instead bind your own ready-made publisher under the container id + `firefly.eda.relay.downstream` (`RelayDownstream::BINDING`) — the escape hatch for a downstream needing + credentials or transport options this package has no business knowing about. + +The relay never accepts the outbox writer as its own downstream: forwarding through it would re-INSERT every +claimed row as `PENDING` and claim it again forever. An unset or unresolvable value is a **loud** failure +(exit 1, naming the remedy) raised before any row is claimed — never a silent no-op that marks rows +`PUBLISHED` without forwarding them. + See [EDA Brokers](../../docs/modules/eda-brokers.md) for the full outbox reference. Apache-2.0 © Firefly Software Solutions Inc. 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-postgres/src/Console/OutboxRelayCommand.php b/packages/eda-postgres/src/Console/OutboxRelayCommand.php index c2f781e..c413386 100644 --- a/packages/eda-postgres/src/Console/OutboxRelayCommand.php +++ b/packages/eda-postgres/src/Console/OutboxRelayCommand.php @@ -5,19 +5,32 @@ namespace Firefly\Eda\Postgres\Console; use Firefly\Config\Config; -use Firefly\Eda\EventPublisher; use Firefly\Eda\Postgres\Outbox\OutboxRelay; -use Firefly\Eda\Postgres\PostgresEventPublisher; +use Firefly\Eda\Postgres\Outbox\RelayDownstream; +use Firefly\Kernel\Exception\Framework\ConfigurationException; use Illuminate\Console\Command; use Illuminate\Contracts\Container\Container; use Illuminate\Database\Connection; use Illuminate\Database\ConnectionResolverInterface; /** - * OPTIONAL: forwards committed firefly_eda_outbox PENDING rows to a DISTINCT downstream broker configured via - * firefly.eda.postgres.relay.downstream_provider (rabbitmq|kafka). If that key is unset, terminal Postgres delivers - * in-process via firefly:eda:consume, so this command is a documented no-op. It NEVER resolves to PostgresEventPublisher - * (that would re-insert PENDING rows) — the guard below + OutboxRelay's ctor guard both refuse it (B1). + * OPTIONAL: forwards committed firefly_eda_outbox PENDING rows to a DISTINCT downstream broker selected by + * firefly.eda.postgres.relay.downstream_provider. Terminal in-process delivery is NOT this command's job — + * firefly:eda:consume owns that — so an app that only needs #[EventListener] handlers never runs this at all. + * + * WHAT CHANGED AND WHY. This command used to treat downstream_provider as a mere on/off flag and then resolve + * EventPublisher::class from the container. Under firefly.eda.provider=postgres — the only provider that has an + * outbox table to relay — that binding IS the outbox writer, so the guard below rejected the one thing the command + * could ever resolve, and the relay could not be made to work under any configuration. Selection now goes through + * RelayDownstream (see its docblock for the full resolution order and why the sibling adapter packages are + * referenced as class-strings), which returns a REAL downstream publisher or throws a ConfigurationException + * naming exactly what to change. + * + * Both failure modes are now loud. An UNSET key is no longer an exit-0 "no-op": running a relay with nothing to + * relay to is a misconfiguration, and exiting 0 while forwarding nothing is precisely the silent behaviour that hid + * the defect — so it exits FAILURE with a message pointing at firefly:eda:consume for the in-process case. A + * MISCONFIGURED key (unknown name, package not installed, unconstructible class, wrong type, or the outbox writer + * itself) is reported before a single row is claimed, so a failed relay can never mark rows PUBLISHED. */ final class OutboxRelayCommand extends Command { @@ -25,24 +38,16 @@ final class OutboxRelayCommand extends Command protected $signature = 'firefly:outbox:relay {--max-messages= : stop after N published} {--time-limit= : stop after N seconds} {--sleep=1 : seconds between empty batches} {--batch-size=50}'; /** @var string */ - protected $description = 'OPTIONAL: forward committed firefly_eda_outbox rows to a downstream broker (claim -> publish -> mark PUBLISHED/FAILED). No-op unless firefly.eda.postgres.relay.downstream_provider is set.'; + protected $description = 'OPTIONAL: forward committed firefly_eda_outbox rows to the downstream broker named by firefly.eda.postgres.relay.downstream_provider (claim -> publish -> mark PUBLISHED/FAILED).'; public function handle(ConnectionResolverInterface $connections, Container $container, Config $config): int { - $downstreamProvider = $config->has('firefly.eda.postgres.relay.downstream_provider') - ? $config->string('firefly.eda.postgres.relay.downstream_provider') - : null; - - if ($downstreamProvider === null) { - $this->info('firefly:outbox:relay — no firefly.eda.postgres.relay.downstream_provider configured; terminal Postgres delivers in-process via firefly:eda:consume. No-op.'); - - return self::SUCCESS; - } - - /** @var EventPublisher $downstream */ - $downstream = $container->make(EventPublisher::class); - if ($downstream instanceof PostgresEventPublisher) { - $this->error("firefly.eda.postgres.relay.downstream_provider={$downstreamProvider} but the resolved EventPublisher is the Postgres outbox publisher itself — that would re-insert PENDING rows (infinite loop). Bind a real rabbitmq/kafka downstream EventPublisher for the relay."); + try { + $downstream = RelayDownstream::resolve($container, $config); + } catch (ConfigurationException $e) { + // Reported as a clean console error rather than an escaping exception: the operator needs the remedy, + // not a stack trace through the container. + $this->error($e->getMessage()); return self::FAILURE; } diff --git a/packages/eda-postgres/src/Outbox/OutboxPreCommitHook.php b/packages/eda-postgres/src/Outbox/OutboxPreCommitHook.php index 1095634..363e38f 100644 --- a/packages/eda-postgres/src/Outbox/OutboxPreCommitHook.php +++ b/packages/eda-postgres/src/Outbox/OutboxPreCommitHook.php @@ -8,6 +8,7 @@ use Firefly\Cqrs\Event\EdaCommandEventPublisher; use Firefly\Data\Domain\PreCommitEventHook; use Firefly\Domain\DomainEvent; +use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\Postgres\PostgresEventPublisher; use Illuminate\Database\Connection; use Illuminate\Database\ConnectionResolverInterface; @@ -20,12 +21,21 @@ * + CorrelationContext (M5), so the domain->envelope mapping (eventType/payload/per-event destination/transaction_id) * is IDENTICAL to the after-commit bridge — but the resulting INSERT is same-tx on the aggregate's connection. * Non-DomainEvent objects are ignored (the generic after-commit dispatch still handles them). Reflection-free. + * + * The SubscriberRegistry it carries is the app's SHARED singleton, threaded straight through to the per-event + * PostgresEventPublisher. The hook itself never subscribes anything — it is a pure write path — but the publisher's + * subscribe() is what EventListenerWiringPass drives, and making the registry a REQUIRED constructor dependency of + * PostgresEventPublisher is exactly what makes the silent "listener subscribed into the void" regression + * unconstructible (see PostgresEventPublisher's docblock). Passing the shared instance rather than a throwaway + * `new SubscriberRegistry` keeps a single registry per application, so there is never a second, invisible one to + * reason about when debugging a missing delivery. */ final class OutboxPreCommitHook implements PreCommitEventHook { /** @param array $destinations eventClass => destination */ public function __construct( private readonly ConnectionResolverInterface $connections, + private readonly SubscriberRegistry $registry, private readonly string $channel, private readonly string $defaultDestination, private readonly array $destinations, @@ -40,7 +50,7 @@ public function handle(object $event, ?string $connection = null): void $conn = $this->connections->connection($connection); // the aggregate's connection — carries the open tx $emitNotify = $conn instanceof Connection && $conn->getDriverName() === 'pgsql'; - $publisher = new PostgresEventPublisher($conn, $this->channel, $emitNotify); + $publisher = new PostgresEventPublisher($conn, $this->registry, $this->channel, $emitNotify); (new EdaCommandEventPublisher($publisher, $this->defaultDestination, $this->destinations, $this->correlation)) ->publish($event); diff --git a/packages/eda-postgres/src/Outbox/RelayDownstream.php b/packages/eda-postgres/src/Outbox/RelayDownstream.php new file mode 100644 index 0000000..8121de1 --- /dev/null +++ b/packages/eda-postgres/src/Outbox/RelayDownstream.php @@ -0,0 +1,235 @@ +make(EventPublisher::class)`. But firefly:outbox:relay only exists under + * firefly.eda.provider=postgres, and under that provider PostgresOutboxAutoConfiguration binds EventPublisher to + * the outbox WRITER. So the command's own guard (and OutboxRelay's constructor guard behind it) rejected the only + * thing it could ever resolve: relaying rows through the writer would INSERT them straight back into + * firefly_eda_outbox as PENDING — an infinite loop that grows the table forever. The result was a command with two + * reachable outcomes and no third: "no downstream configured, no-op, exit 0" or "the resolved publisher is the + * outbox writer, exit 1". Setting the documented config key could not produce a working relay under any + * configuration; it merely swapped a silent no-op for a loud one. + * + * HOW IT RESOLVES NOW, in order, so an app can always win: + * 1. an explicit container binding under self::BINDING — the escape hatch for a downstream that needs credentials, + * TLS options or anything else this package has no business knowing about; + * 2. the configured value read as a container id or class-string — `downstream_provider` may name a binding or a + * publisher class directly; + * 3. a shipped adapter ALIAS ('rabbitmq' | 'kafka') mapped to that package's publisher, built with the adapter's + * OWN config keys. + * + * WHY THE ADAPTER CLASSES ARE REFERENCED AS STRINGS. deptrac's layer graph forbids an EdaPostgres -> EdaRabbitmq / + * EdaKafka edge (see deptrac.yaml: every broker is a sibling leaf package, depended on by NONE), and rightly so — + * firefly/eda-postgres must not drag php-amqplib or ext-rdkafka into a Postgres-only install. A class-string plus a + * class_exists() guard keeps the compile-time graph clean while still producing a real, fully configured publisher + * when the operator has installed the sibling package. When they have not, self::ADAPTERS names the exact composer + * package to require. + * + * WHY THE ADAPTER'S OWN CONFIG KEYS ARE PASSED EXPLICITLY. Each broker package gates ITS publisher bean behind + * #[ConditionalOnProperty(firefly.eda.provider=)], which is false here by construction (the provider + * is postgres). Resolving the class through the container therefore autowires it with the constructor DEFAULTS, so + * an app that had carefully set firefly.eda.rabbitmq.exchange would have silently relayed to `firefly.events` + * instead — a fresh silent misconfiguration in place of the one being fixed. self::parameters() passes those values + * as named constructor overrides so the relay honours exactly the settings the adapter itself would have read. + * + * WHY VALIDATION IS RUNTIME AND NOT BOOT-TIME. Throwing from EdaPostgresBootServiceProvider::boot() was considered + * and rejected: a downstream bound in ANOTHER provider's boot() may not exist yet when ours runs, so a boot-time + * check would fail applications that are correctly configured, and it would fail every web request and every + * unrelated artisan command over a relay-only concern. The relay command is the one place the value is load-bearing, + * so that is where it is validated — immediately, before a single row is claimed, and never at the first publish. + */ +final class RelayDownstream +{ + /** The config key that selects the relay's downstream. */ + public const string PROVIDER_KEY = 'firefly.eda.postgres.relay.downstream_provider'; + + /** The container id an app may bind to supply a fully constructed downstream publisher itself. */ + public const string BINDING = 'firefly.eda.relay.downstream'; + + /** + * The shipped broker adapters, by the alias an operator writes in `downstream_provider`. + * + * @var array + */ + private const ADAPTERS = [ + 'rabbitmq' => ['class' => 'Firefly\\Eda\\Rabbitmq\\RabbitMqEventPublisher', 'package' => 'firefly/eda-rabbitmq'], + 'kafka' => ['class' => 'Firefly\\Eda\\Kafka\\KafkaEventPublisher', 'package' => 'firefly/eda-kafka'], + ]; + + /** The configured downstream name, or null when the operator has not opted into the relay at all. */ + public static function configuredProvider(Config $config): ?string + { + if (! $config->has(self::PROVIDER_KEY)) { + return null; + } + + $provider = trim($config->string(self::PROVIDER_KEY, '')); + + return $provider === '' ? null : $provider; + } + + /** + * Resolve the downstream publisher, or throw a ConfigurationException that names the exact thing to change. + * Never returns the outbox writer. + */ + public static function resolve(Container $container, Config $config): EventPublisher + { + $provider = self::configuredProvider($config); + + // Route 1 is checked BEFORE the unset-key failure, not after: an app that has bound a ready-made downstream + // has already named it, and demanding a redundant downstream_provider alongside the binding would reject a + // configuration this class's own error message (and the README) tell operators to use. + if ($container->bound(self::BINDING)) { + return self::guard($provider ?? self::BINDING, self::make($container, self::BINDING, [], $provider ?? self::BINDING)); + } + + if ($provider === null) { + throw new ConfigurationException(sprintf( + '%s is not set, so firefly:outbox:relay has no downstream broker to forward to. Set it to one of [%s], ' + .'to the class-string of an EventPublisher, or bind your own under the container id "%s". If you did not ' + .'mean to front a second broker at all, do not run the relay: firefly.eda.provider=postgres already ' + .'delivers committed outbox rows in-process via `php artisan firefly:eda:consume`.', + self::PROVIDER_KEY, + implode('|', array_keys(self::ADAPTERS)), + self::BINDING, + )); + } + + return self::guard($provider, self::instantiate($container, $config, $provider)); + } + + /** + * Walk the remaining resolution routes in order (route 1, the explicit binding, is handled by resolve() so it + * can win even with the key unset). Anything the container cannot build is reported with the + * BindingResolutionException attached, because "which constructor argument could not be satisfied" is the only + * detail that makes an autowiring failure actionable. + */ + private static function instantiate(Container $container, Config $config, string $provider): mixed + { + if ($container->bound($provider) || class_exists($provider)) { + return self::make($container, $provider, [], $provider); + } + + $adapter = self::ADAPTERS[$provider] ?? null; + + if ($adapter === null) { + throw new ConfigurationException(sprintf( + '%s="%s" names neither a shipped adapter [%s], nor a bound container id, nor an existing class. Fix the ' + .'value, or bind your own downstream EventPublisher under the container id "%s".', + self::PROVIDER_KEY, + $provider, + implode('|', array_keys(self::ADAPTERS)), + self::BINDING, + )); + } + + if (! class_exists($adapter['class'])) { + throw new ConfigurationException(sprintf( + '%s="%s" but %s is not installed — %s could not be found. Run `composer require %s`, or bind your own ' + .'downstream EventPublisher under the container id "%s".', + self::PROVIDER_KEY, + $provider, + $adapter['package'], + $adapter['class'], + $adapter['package'], + self::BINDING, + )); + } + + return self::make($container, $adapter['class'], self::parameters($container, $config, $provider), $provider); + } + + /** + * The adapter-specific constructor overrides that carry the sibling package's OWN configuration across the + * provider gate it is standing behind. Named by constructor parameter — which PHP 8 named arguments already + * make part of those classes' public API — so a rename there surfaces here as a loud BindingResolutionException + * rather than a quietly-defaulted broker address. + * + * @return array + */ + private static function parameters(Container $container, Config $config, string $provider): array + { + if ($provider === 'rabbitmq') { + return ['exchange' => $config->string('firefly.eda.rabbitmq.exchange', 'firefly.events')]; + } + + if ($provider === 'kafka') { + // The broker list lives one level down, on KafkaProducerFactory, so it is built here and injected as the + // publisher's `factory` argument — parameter overrides do not reach nested dependencies. + return ['factory' => self::make( + $container, + 'Firefly\\Eda\\Kafka\\KafkaProducerFactory', + ['brokers' => $config->string('firefly.eda.kafka.brokers', '127.0.0.1:9092')], + $provider, + )]; + } + + return []; + } + + /** + * @param array $parameters + */ + private static function make(Container $container, string $id, array $parameters, string $provider): mixed + { + try { + return $container->make($id, $parameters); + } catch (BindingResolutionException $e) { + throw new ConfigurationException(sprintf( + '%s="%s" resolved to "%s", which could not be constructed: %s. Bind a ready-made downstream ' + .'EventPublisher under the container id "%s" instead.', + self::PROVIDER_KEY, + $provider, + $id, + $e->getMessage(), + self::BINDING, + ), previous: $e); + } + } + + /** + * The last line of defence, and the reason OutboxRelay's own constructor guard is not enough on its own: a clear + * message at CONFIGURATION time beats a LogicException from deep inside the relay. Refusing the outbox writer + * here is what keeps `downstream_provider` from ever pointing the relay back at the table it is draining. + */ + private static function guard(string $provider, mixed $downstream): EventPublisher + { + if ($downstream instanceof PostgresEventPublisher) { + throw new ConfigurationException(sprintf( + '%s="%s" resolved to the Postgres outbox writer itself. The relay would re-INSERT every claimed row ' + .'into firefly_eda_outbox as PENDING and claim it again forever. Point it at a DISTINCT broker, or bind ' + .'one under the container id "%s".', + self::PROVIDER_KEY, + $provider, + self::BINDING, + )); + } + + if (! $downstream instanceof EventPublisher) { + throw new ConfigurationException(sprintf( + '%s="%s" resolved to %s, which does not implement %s.', + self::PROVIDER_KEY, + $provider, + get_debug_type($downstream), + EventPublisher::class, + )); + } + + return $downstream; + } +} diff --git a/packages/eda-postgres/src/PostgresEventPublisher.php b/packages/eda-postgres/src/PostgresEventPublisher.php index cc17a40..8295461 100644 --- a/packages/eda-postgres/src/PostgresEventPublisher.php +++ b/packages/eda-postgres/src/PostgresEventPublisher.php @@ -4,6 +4,7 @@ namespace Firefly\Eda\Postgres; +use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\EventPublisher; use Firefly\Eda\Postgres\Outbox\OutboxSchema; use Illuminate\Database\ConnectionInterface; @@ -15,24 +16,42 @@ * INSERT enlists in that transaction and commits ATOMICALLY with the aggregate — the genuine same-tx guarantee. When * emitNotify is true (pgsql), it also fires `SELECT pg_notify('', id)` — inside the SAME tx, so Postgres * queues the NOTIFY and delivers it exactly when the aggregate COMMITS, waking the in-process consumer's LISTEN with - * low latency (M2). The in-process consumer (Task 5) claims PENDING rows and drives #[EventListener] handlers; the - * OPTIONAL firefly:outbox:relay only fronts a distinct downstream broker. subscribe() feeds the shared - * SubscriberRegistry; start()/stop() are no-ops (the relay/consumer own their own connections). It uses only - * ConnectionInterface methods (table()/statement()/raw()) — no getPdo()/getDriverName() — so ConnectionInterface is - * the correct ctor type; the driver-gated emitNotify flag is computed by the caller from the concrete Connection. - * Reflection-free. + * low latency (M2). The in-process consumer (PostgresEventConsumer) claims PENDING rows and drives #[EventListener] + * handlers; the OPTIONAL firefly:outbox:relay only fronts a distinct downstream broker. start()/stop() are no-ops + * (the relay/consumer own their own connections). It uses only ConnectionInterface methods (table()/statement()/raw()) + * — no getPdo()/getDriverName() — so ConnectionInterface is the correct ctor type; the driver-gated emitNotify flag is + * computed by the caller from the concrete Connection. Reflection-free. + * + * WHY THE REGISTRY IS A REQUIRED CONSTRUCTOR DEPENDENCY (silent-data-loss regression, CapstoneOutboxDeliveryTest): + * subscribe() USED TO BE AN EMPTY NO-OP while this very docblock claimed it "feeds the shared SubscriberRegistry". + * That mattered because EventListenerWiringPass — the boot pass that turns the app's compiled #[EventListener] + * manifest into live subscriptions — resolves the bound EventPublisher and calls subscribe() ON IT. Under + * firefly.eda.provider=postgres the bound publisher is THIS class, so every listener the app compiled was handed to + * a method that discarded it, and the SubscriberRegistry the terminal consumer delivers into stayed permanently + * empty. The failure was silent rather than loud because SubscriberRegistry::deliver() on an empty registry returns + * normally: ConsumerLoop read that as a successful delivery and called ack(), flipping the outbox row + * PENDING -> PUBLISHED, never to be claimed again. Domain events were written same-transaction exactly as promised, + * drained exactly as promised, and thrown away — no exception, no log line, nothing. Making the registry a REQUIRED + * parameter (mirroring RabbitMqEventPublisher and KafkaEventPublisher, which have always taken one) means an + * instance of this class that cannot deliver its subscriptions is no longer constructible at all. */ final class PostgresEventPublisher implements EventPublisher { public function __construct( private readonly ConnectionInterface $connection, + private readonly SubscriberRegistry $registry, private readonly string $channel = 'firefly_eda_events', private readonly bool $emitNotify = false, ) {} + /** + * Records the pattern on the SHARED SubscriberRegistry — the same singleton PostgresOutboxAutoConfiguration + * hands to this publisher and that firefly:eda:consume resolves as the ConsumerLoop's sink. The publisher itself + * never invokes handlers: delivery is the terminal consumer's job, driven off committed rows. + */ public function subscribe(string $eventTypePattern, callable $handler): void { - // No-op on the publisher: subscriptions live on the shared SubscriberRegistry the consumer drives. + $this->registry->subscribe($eventTypePattern, $handler); } /** diff --git a/packages/eda-postgres/src/PostgresOutboxAutoConfiguration.php b/packages/eda-postgres/src/PostgresOutboxAutoConfiguration.php index b446b9d..d85f6af 100644 --- a/packages/eda-postgres/src/PostgresOutboxAutoConfiguration.php +++ b/packages/eda-postgres/src/PostgresOutboxAutoConfiguration.php @@ -51,9 +51,15 @@ public function subscriberRegistry(): SubscriberRegistry return new SubscriberRegistry; } + /** + * The outbox WRITER — and, just as importantly, the object EventListenerWiringPass calls subscribe() on. The + * shared SubscriberRegistry singleton above is injected here so those subscriptions land in the SAME registry + * firefly:eda:consume feeds every polled envelope into. Without it the wiring pass subscribed into a publisher + * that discarded handlers, and the consumer then acked undelivered rows (see PostgresEventPublisher's docblock). + */ #[Bean] #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'postgres')] - public function eventPublisher(Config $config, ConnectionResolverInterface $connections): EventPublisher + public function eventPublisher(Config $config, ConnectionResolverInterface $connections, SubscriberRegistry $registry): EventPublisher { $name = $config->has('firefly.eda.postgres.connection') ? $config->string('firefly.eda.postgres.connection') : null; $conn = $connections->connection($name); @@ -62,6 +68,7 @@ public function eventPublisher(Config $config, ConnectionResolverInterface $conn return new PostgresEventPublisher( $conn, + $registry, $config->string('firefly.eda.postgres.channel', 'firefly_eda_events'), $emitNotify, ); @@ -74,10 +81,11 @@ public function eventPublisher(Config $config, ConnectionResolverInterface $conn */ #[Bean] #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'postgres')] - public function outboxPreCommitHook(ConnectionResolverInterface $connections, Config $config, HandlerManifest $manifest, CorrelationContext $correlation): OutboxPreCommitHook + public function outboxPreCommitHook(ConnectionResolverInterface $connections, Config $config, HandlerManifest $manifest, CorrelationContext $correlation, SubscriberRegistry $registry): OutboxPreCommitHook { return new OutboxPreCommitHook( $connections, + $registry, $config->string('firefly.eda.postgres.channel', 'firefly_eda_events'), $config->string('firefly.cqrs.default_destination', 'cqrs.events'), $manifest->destinations(), diff --git a/packages/eda-postgres/tests/CapstoneFixtures/OutboxCapstoneListener.php b/packages/eda-postgres/tests/CapstoneFixtures/OutboxCapstoneListener.php new file mode 100644 index 0000000..cb60215 --- /dev/null +++ b/packages/eda-postgres/tests/CapstoneFixtures/OutboxCapstoneListener.php @@ -0,0 +1,30 @@ +spy->record($envelope->eventType); + } +} diff --git a/packages/eda-postgres/tests/CapstoneOutboxDeliveryTest.php b/packages/eda-postgres/tests/CapstoneOutboxDeliveryTest.php new file mode 100644 index 0000000..a67c853 --- /dev/null +++ b/packages/eda-postgres/tests/CapstoneOutboxDeliveryTest.php @@ -0,0 +1,156 @@ + PUBLISHED and is never re-selected. Every domain event published through the framework's headline + * same-transaction outbox was durably written, drained, and thrown away, with no error anywhere. + * + * Nothing in the pre-existing suite could see this: every other test built its own SubscriberRegistry and + * subscribed to it by hand, which is precisely the wiring the bug broke. + */ +it('drives the app #[EventListener] from a committed outbox row, then marks it PUBLISHED', function () { + /** @var OutboxCapstoneTestCase $this */ + $app = $this->app(); + + // Publish through the CONTAINER-RESOLVED publisher (the outbox writer), inside a business transaction. + DB::transaction(function () use ($app): void { + $app->make(EventPublisher::class)->publish('users', 'user.created', ['id' => 7]); + }); + + expect(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(1); + + /** @var SubscriberRegistry $registry */ + $registry = $app->make(SubscriberRegistry::class); + /** @var EventConsumer $consumer */ + $consumer = $app->make(EventConsumer::class); + + $processed = (new ConsumerLoop)->run( + $consumer, + fn (EventEnvelope $envelope) => $registry->deliver($envelope), + new ConsumerOptions(maxMessages: 1, pollTimeoutMs: 10), + ); + + /** @var ListenerSpy $spy */ + $spy = $app->make(ListenerSpy::class); + + // The handler ACTUALLY RAN, and only then was the row acked. Before the fix the row was PUBLISHED with + // $spy->seen === [] — delivered to nobody, acked anyway. + expect($spy->seen)->toBe(['user.created']) + ->and($processed)->toBe(1) + ->and(DB::table(OutboxSchema::TABLE)->value('status'))->toBe(OutboxSchema::STATUS_PUBLISHED); +}); + +it('populates the SHARED SubscriberRegistry the consumer reads — not a private one', function () { + /** @var OutboxCapstoneTestCase $this */ + $app = $this->app(); + + // The wiring pass subscribed onto the EventPublisher; the registry the consume command resolves must be the + // very same object, otherwise the subscription is invisible at delivery time. + $seen = []; + $app->make(SubscriberRegistry::class)->deliver(new EventEnvelope('user.updated', 'users', ['id' => 1])); + + /** @var ListenerSpy $spy */ + $spy = $app->make(ListenerSpy::class); + $seen = $spy->seen; + + expect($seen)->toBe(['user.updated']); +}); + +it('never acks a row whose delivery throws — it stays PENDING with attempts+1 so it is retried', function () { + /** @var OutboxCapstoneTestCase $this */ + $app = $this->app(); + + DB::transaction(function () use ($app): void { + $app->make(EventPublisher::class)->publish('users', 'user.created', ['id' => 9]); + }); + + /** @var EventConsumer $consumer */ + $consumer = $app->make(EventConsumer::class); + + // A sink that always throws stands in for a handler that exhausted its retries with no DLQ bound. + (new ConsumerLoop)->run( + $consumer, + function (): void { + throw new RuntimeException('handler boom'); + }, + new ConsumerOptions(maxMessages: 1, pollTimeoutMs: 10), + ); + + $row = DB::table(OutboxSchema::TABLE)->first(); + if ($row === null) { + throw new RuntimeException('Expected the committed outbox row to still exist.'); + } + + // Still claimable: a failed delivery is retried, never acked away. + expect($row->status)->toBe(OutboxSchema::STATUS_PENDING) + ->and($row->attempts)->toBe(1) + ->and($row->processed_at)->toBeNull(); +}); + +/** + * THE SAME-TRANSACTION GUARANTEE, re-proven end to end through the CONTAINER-RESOLVED publisher. + * + * The other tests in this package prove atomicity against a hand-constructed PostgresEventPublisher. This one + * proves it for the publisher a booted provider=postgres application actually uses, after the registry became a + * constructor dependency — because the whole value of the outbox is that the business row and the outbox row share + * one commit, and a wiring change that quietly moved the INSERT onto a different connection (or outside the + * caller's transaction) would still pass every delivery assertion above. + */ +it('commits the business write and the outbox write together, and rolls them back together', function () { + /** @var OutboxCapstoneTestCase $this */ + $app = $this->app(); + + Schema::create('capstone_orders', function (Blueprint $table): void { + $table->unsignedBigInteger('id')->primary(); + $table->string('status'); + }); + + // ROLLBACK: the business failure lands AFTER both writes, so neither may survive. A dual-write outbox (a + // publisher on its own connection, or an after-commit hop) would leave the event row behind here. + try { + DB::transaction(function () use ($app): void { + DB::table('capstone_orders')->insert(['id' => 1, 'status' => 'PLACED']); + $app->make(EventPublisher::class)->publish('orders', 'user.placed', ['id' => 1]); + + throw new RuntimeException('business failure after both writes'); + }); + } catch (RuntimeException) { + // expected + } + + expect(DB::table('capstone_orders')->count())->toBe(0) + ->and(DB::table(OutboxSchema::TABLE)->count())->toBe(0); + + // COMMIT: the same two writes, no failure — both are durable, and the outbox row is claimable. + DB::transaction(function () use ($app): void { + DB::table('capstone_orders')->insert(['id' => 2, 'status' => 'PLACED']); + $app->make(EventPublisher::class)->publish('orders', 'user.placed', ['id' => 2]); + }); + + expect(DB::table('capstone_orders')->where('id', 2)->count())->toBe(1) + ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(1); +}); diff --git a/packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest.php b/packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest.php index 956acbe..d6c594d 100644 --- a/packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest.php +++ b/packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest.php @@ -78,7 +78,7 @@ function outbox_decode(mixed $column): mixed $consumer->subscribe(['*']); // LISTEN on the consumer session BEFORE the writer commits $writer->transaction(function () use ($writer, $channel): void { - (new PostgresEventPublisher($writer, $channel, true))->publish('orders', 'order.created', ['id' => 7], ['x-a' => 'b']); + (new PostgresEventPublisher($writer, new SubscriberRegistry, $channel, true))->publish('orders', 'order.created', ['id' => 7], ['x-a' => 'b']); // Still inside the writer's tx: the row is NOT yet visible to the separate consumer session. expect(DB::connection('outbox_consumer')->table(OutboxSchema::TABLE)->count())->toBe(0); }); @@ -110,7 +110,7 @@ function outbox_decode(mixed $column): mixed ->and(outbox_decode($row->headers))->toBe(['x-a' => 'b']); // ---- (4) CRASH-SAFETY: handler throw -> nack -> still PENDING; fresh consumer resumes; past max -> FAILED ---- - (new PostgresEventPublisher($writer, $channel, true))->publish('orders', 'order.updated', ['id' => 8]); + (new PostgresEventPublisher($writer, new SubscriberRegistry, $channel, true))->publish('orders', 'order.updated', ['id' => 8]); $throwingConsumer = new PostgresEventConsumer($consumerConn, $channel); $throwingConsumer->subscribe(['*']); (new ConsumerLoop)->run($throwingConsumer, function (): void { @@ -138,7 +138,7 @@ function outbox_decode(mixed $column): mixed expect($resumed)->toBe(1)->and($resolved->status)->toBe(OutboxSchema::STATUS_PUBLISHED); // Past max_attempts -> FAILED (maxAttempts=1: the first nack fails it). - (new PostgresEventPublisher($writer, $channel, true))->publish('orders', 'order.cancelled', ['id' => 9]); + (new PostgresEventPublisher($writer, new SubscriberRegistry, $channel, true))->publish('orders', 'order.cancelled', ['id' => 9]); $failingConsumer = new PostgresEventConsumer($consumerConn, $channel, 1); $failingConsumer->subscribe(['*']); (new ConsumerLoop)->run($failingConsumer, function (): void { @@ -152,7 +152,7 @@ function outbox_decode(mixed $column): mixed expect($failed->status)->toBe(OutboxSchema::STATUS_FAILED)->and(OutboxRow::asInt($failed->attempts))->toBe(1); // ---- (5) OPTIONAL RELAY -> DISTINCT downstream broker (spy), FOR UPDATE SKIP LOCKED on pgsql ---- - (new PostgresEventPublisher($writer, $channel, true))->publish('billing', 'invoice.raised', ['id' => 10], ['x-c' => 'd']); + (new PostgresEventPublisher($writer, new SubscriberRegistry, $channel, true))->publish('billing', 'invoice.raised', ['id' => 10], ['x-c' => 'd']); $spy = new SpyDownstreamPublisher; $relayed = (new OutboxRelay($writer, $spy, batchSize: 50, maxAttempts: 3, useSkipLocked: true))->relayBatch(); @@ -164,7 +164,7 @@ function outbox_decode(mixed $column): mixed ->and($writer->table(OutboxSchema::TABLE)->where('event_type', 'invoice.raised')->value('status'))->toBe(OutboxSchema::STATUS_PUBLISHED); // B1: the relay ctor HARD-REFUSES a PostgresEventPublisher downstream (would re-insert PENDING rows -> loop). - expect(fn () => new OutboxRelay($writer, new PostgresEventPublisher($writer, $channel))) + expect(fn () => new OutboxRelay($writer, new PostgresEventPublisher($writer, new SubscriberRegistry, $channel))) ->toThrow(LogicException::class, 'PostgresEventPublisher'); Schema::connection('outbox_writer')->dropIfExists(OutboxSchema::TABLE); diff --git a/packages/eda-postgres/tests/OutboxAtLeastOncePathTest.php b/packages/eda-postgres/tests/OutboxAtLeastOncePathTest.php index aac990e..121083e 100644 --- a/packages/eda-postgres/tests/OutboxAtLeastOncePathTest.php +++ b/packages/eda-postgres/tests/OutboxAtLeastOncePathTest.php @@ -8,6 +8,7 @@ use Firefly\Data\Domain\DomainEventDispatcher; use Firefly\Domain\AggregateRoot; use Firefly\Domain\DomainEvent; +use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\Postgres\Outbox\OutboxPreCommitHook; use Firefly\Eda\Postgres\Outbox\OutboxSchema; use Firefly\Testing\FireflyDatabaseTestCase; @@ -25,7 +26,7 @@ /** @var ConnectionResolverInterface $resolver */ $resolver = App::make(ConnectionResolverInterface::class); - $hook = new OutboxPreCommitHook($resolver, 'firefly_eda_events', 'cqrs.events', [], new CorrelationContext); + $hook = new OutboxPreCommitHook($resolver, new SubscriberRegistry, 'firefly_eda_events', 'cqrs.events', [], new CorrelationContext); $afterCommit = new class implements ApplicationEventPublisher { public function publish(object $event): void {} // postgres mode NoOps the eda after-commit leg -> no second write diff --git a/packages/eda-postgres/tests/OutboxPreCommitHookTest.php b/packages/eda-postgres/tests/OutboxPreCommitHookTest.php index 501e48d..72e9003 100644 --- a/packages/eda-postgres/tests/OutboxPreCommitHookTest.php +++ b/packages/eda-postgres/tests/OutboxPreCommitHookTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Firefly\Cqrs\Correlation\CorrelationContext; +use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\Postgres\Outbox\OutboxPreCommitHook; use Firefly\Eda\Postgres\Outbox\OutboxSchema; use Firefly\Eda\Postgres\Tests\Fixtures\OutboxSampleEvent; @@ -34,6 +35,7 @@ $hook = new OutboxPreCommitHook( $connections, + new SubscriberRegistry, 'firefly_eda_events', 'cqrs.events', [OutboxSampleEvent::class => 'orders.events'], @@ -68,7 +70,7 @@ /** @var ConnectionResolverInterface $connections */ $connections = App::make(ConnectionResolverInterface::class); - $hook = new OutboxPreCommitHook($connections, 'firefly_eda_events', 'cqrs.events', [], null); + $hook = new OutboxPreCommitHook($connections, new SubscriberRegistry, 'firefly_eda_events', 'cqrs.events', [], null); try { DB::transaction(function () use ($hook): void { @@ -86,7 +88,7 @@ /** @var ConnectionResolverInterface $connections */ $connections = App::make(ConnectionResolverInterface::class); - $hook = new OutboxPreCommitHook($connections, 'firefly_eda_events', 'cqrs.events', [], null); + $hook = new OutboxPreCommitHook($connections, new SubscriberRegistry, 'firefly_eda_events', 'cqrs.events', [], null); DB::transaction(function () use ($hook): void { $hook->handle(new stdClass, null); diff --git a/packages/eda-postgres/tests/OutboxRelayCommandTest.php b/packages/eda-postgres/tests/OutboxRelayCommandTest.php new file mode 100644 index 0000000..cdce1d7 --- /dev/null +++ b/packages/eda-postgres/tests/OutboxRelayCommandTest.php @@ -0,0 +1,75 @@ +app()->instance(RelayDownstream::BINDING, $spy); + config(['firefly.eda.postgres.relay.downstream_provider' => 'rabbitmq']); + + DB::table(OutboxSchema::TABLE)->insert([ + 'destination' => 'orders', 'channel' => 'firefly_eda_events', 'event_type' => 'order.created', + 'payload' => '{"id":3}', 'headers' => '{}', 'status' => OutboxSchema::STATUS_PENDING, 'attempts' => 0, + ]); + + $exit = $this->runRelay(); + + expect($exit)->toBe(0) + ->and($spy->published)->toHaveCount(1) + ->and($spy->published[0]->eventType)->toBe('order.created') + ->and($spy->published[0]->payload)->toBe(['id' => 3]) + ->and(DB::table(OutboxSchema::TABLE)->value('status'))->toBe(OutboxSchema::STATUS_PUBLISHED); +}); + +it('exits FAILURE with an actionable message when downstream_provider is unset', function () { + /** @var OutboxRelayCommandTestCase $this */ + $exit = $this->runRelay(); + // Kernel::output() drains the buffered output, so it is read exactly once per command run. + $output = $this->relayOutput(); + + // Not exit 0: a relay with nothing to relay to is a misconfiguration, and the old exit-0 no-op is exactly the + // silence this whole fix is about. The message must point at the in-process alternative. + expect($exit)->toBe(1) + ->and($output)->toContain('firefly.eda.postgres.relay.downstream_provider') + ->and($output)->toContain('firefly:eda:consume'); +}); + +it('exits FAILURE naming the missing package when the configured adapter is not installed', function () { + /** @var OutboxRelayCommandTestCase $this */ + config(['firefly.eda.postgres.relay.downstream_provider' => 'nats']); + + expect($this->runRelay())->toBe(1) + ->and($this->relayOutput())->toContain('nats'); +}); + +it('leaves every row PENDING when the downstream cannot be resolved — nothing is acked', function () { + /** @var OutboxRelayCommandTestCase $this */ + config(['firefly.eda.postgres.relay.downstream_provider' => stdClass::class]); + + DB::table(OutboxSchema::TABLE)->insert([ + 'destination' => 'orders', 'channel' => 'firefly_eda_events', 'event_type' => 'order.created', + 'payload' => '{"id":4}', 'headers' => '{}', 'status' => OutboxSchema::STATUS_PENDING, 'attempts' => 0, + ]); + + // stdClass is a real, constructible class, so the container hands one back happily — but it is not an + // EventPublisher. The type guard fires BEFORE a single row is claimed, which is what keeps a misconfigured + // relay from marking rows PUBLISHED without ever forwarding them. + expect($this->runRelay())->toBe(1) + ->and(DB::table(OutboxSchema::TABLE)->value('status'))->toBe(OutboxSchema::STATUS_PENDING) + ->and(DB::table(OutboxSchema::TABLE)->value('attempts'))->toBe(0); +}); diff --git a/packages/eda-postgres/tests/OutboxRelayTest.php b/packages/eda-postgres/tests/OutboxRelayTest.php index f0575e6..c78de23 100644 --- a/packages/eda-postgres/tests/OutboxRelayTest.php +++ b/packages/eda-postgres/tests/OutboxRelayTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\Postgres\Outbox\OutboxRelay; use Firefly\Eda\Postgres\Outbox\OutboxSchema; use Firefly\Eda\Postgres\PostgresEventPublisher; @@ -16,7 +17,7 @@ beforeEach(fn () => Schema::create(OutboxSchema::TABLE, fn (Blueprint $t) => OutboxSchema::blueprint($t))); it('claims PENDING rows, publishes them, and marks them PUBLISHED', function () { - (new PostgresEventPublisher(DB::connection()))->publish('users', 'user.created', ['id' => 1]); + (new PostgresEventPublisher(DB::connection(), new SubscriberRegistry))->publish('users', 'user.created', ['id' => 1]); $spy = new SpyDownstreamPublisher; $count = (new OutboxRelay(DB::connection(), $spy))->relayBatch(); @@ -27,7 +28,7 @@ }); it('is idempotent — a PUBLISHED row is never re-published', function () { - (new PostgresEventPublisher(DB::connection()))->publish('users', 'user.created', ['id' => 1]); + (new PostgresEventPublisher(DB::connection(), new SubscriberRegistry))->publish('users', 'user.created', ['id' => 1]); $spy = new SpyDownstreamPublisher; $relay = new OutboxRelay(DB::connection(), $spy); @@ -38,7 +39,7 @@ }); it('increments attempts and marks FAILED past maxAttempts', function () { - (new PostgresEventPublisher(DB::connection()))->publish('users', 'user.created', ['id' => 1]); + (new PostgresEventPublisher(DB::connection(), new SubscriberRegistry))->publish('users', 'user.created', ['id' => 1]); $spy = new SpyDownstreamPublisher; $spy->fail = true; $relay = new OutboxRelay(DB::connection(), $spy, batchSize: 50, maxAttempts: 2); @@ -55,6 +56,6 @@ }); it('REFUSES a PostgresEventPublisher downstream (B1 — no relay self-reference / re-insert loop)', function () { - expect(fn () => new OutboxRelay(DB::connection(), new PostgresEventPublisher(DB::connection()))) + expect(fn () => new OutboxRelay(DB::connection(), new PostgresEventPublisher(DB::connection(), new SubscriberRegistry))) ->toThrow(LogicException::class, 'PostgresEventPublisher'); }); diff --git a/packages/eda-postgres/tests/OutboxSameTransactionTest.php b/packages/eda-postgres/tests/OutboxSameTransactionTest.php index f677278..a86d071 100644 --- a/packages/eda-postgres/tests/OutboxSameTransactionTest.php +++ b/packages/eda-postgres/tests/OutboxSameTransactionTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\Postgres\Outbox\OutboxSchema; use Firefly\Eda\Postgres\PostgresEventPublisher; use Firefly\Testing\FireflyDatabaseTestCase; @@ -16,7 +17,7 @@ }); it('writes the outbox row INSIDE the caller transaction (present after commit)', function () { - $publisher = new PostgresEventPublisher(DB::connection(), 'firefly_eda_events'); + $publisher = new PostgresEventPublisher(DB::connection(), new SubscriberRegistry, 'firefly_eda_events'); DB::transaction(function () use ($publisher): void { $publisher->publish('users', 'user.created', ['id' => 1], ['x-a' => 'b']); @@ -35,7 +36,7 @@ }); it('rolls the outbox row back WITH the aggregate (absent after rollback)', function () { - $publisher = new PostgresEventPublisher(DB::connection(), 'firefly_eda_events'); + $publisher = new PostgresEventPublisher(DB::connection(), new SubscriberRegistry, 'firefly_eda_events'); try { DB::transaction(function () use ($publisher): void { @@ -60,7 +61,7 @@ Schema::connection('audit')->create(OutboxSchema::TABLE, fn (Blueprint $t) => OutboxSchema::blueprint($t)); DB::connection('audit')->transaction(function (): void { - (new PostgresEventPublisher(DB::connection('audit')))->publish('users', 'user.created', ['id' => 3]); + (new PostgresEventPublisher(DB::connection('audit'), new SubscriberRegistry))->publish('users', 'user.created', ['id' => 3]); }); // The row lands on the aggregate's OWN connection ('audit')... diff --git a/packages/eda-postgres/tests/PostgresEventConsumerTest.php b/packages/eda-postgres/tests/PostgresEventConsumerTest.php index b9e40f0..029f829 100644 --- a/packages/eda-postgres/tests/PostgresEventConsumerTest.php +++ b/packages/eda-postgres/tests/PostgresEventConsumerTest.php @@ -20,7 +20,7 @@ beforeEach(fn () => Schema::create(OutboxSchema::TABLE, fn (Blueprint $t) => OutboxSchema::blueprint($t))); it('delivers each committed PENDING row exactly once and NEVER grows the outbox (B1 regression)', function () { - $publisher = new PostgresEventPublisher(DB::connection()); // emitNotify=false on sqlite + $publisher = new PostgresEventPublisher(DB::connection(), new SubscriberRegistry); // emitNotify=false on sqlite foreach ([1, 2, 3] as $id) { $publisher->publish('users', 'user.created', ['id' => $id]); } @@ -48,7 +48,7 @@ }); it('a fresh consumer (restart) does NOT replay PUBLISHED rows (durable status window, M3)', function () { - (new PostgresEventPublisher(DB::connection()))->publish('users', 'user.created', ['id' => 1]); + (new PostgresEventPublisher(DB::connection(), new SubscriberRegistry))->publish('users', 'user.created', ['id' => 1]); (new ConsumerLoop)->run(new PostgresEventConsumer(DB::connection()), fn () => null, new ConsumerOptions(maxMessages: 1, pollTimeoutMs: 10)); $seen = 0; @@ -64,7 +64,7 @@ function () use (&$seen): void { }); it('poll() swallows a throwing NOTIFY wait and still delivers via the poll-fallback PENDING claim', function () { - (new PostgresEventPublisher(DB::connection()))->publish('users', 'user.created', ['id' => 42]); + (new PostgresEventPublisher(DB::connection(), new SubscriberRegistry))->publish('users', 'user.created', ['id' => 42]); $consumer = new PostgresEventConsumer(DB::connection(), 'firefly_eda_events', 3, function (int $t): void { throw new ErrorException('simulated deprecation-to-exception'); diff --git a/packages/eda-postgres/tests/PostgresEventPublisherTest.php b/packages/eda-postgres/tests/PostgresEventPublisherTest.php index 8998f49..e455bb7 100644 --- a/packages/eda-postgres/tests/PostgresEventPublisherTest.php +++ b/packages/eda-postgres/tests/PostgresEventPublisherTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\Postgres\Outbox\OutboxSchema; use Firefly\Eda\Postgres\PostgresEventPublisher; use Firefly\Testing\FireflyDatabaseTestCase; @@ -16,7 +17,7 @@ }); it('maps destination->channel and stores headers + transaction_id', function () { - (new PostgresEventPublisher(DB::connection(), 'firefly_eda_events')) + (new PostgresEventPublisher(DB::connection(), new SubscriberRegistry, 'firefly_eda_events')) ->publish('order.events', 'order.created', ['id' => 9], ['x-correlation-id' => 'c1']); // Every mapped column present on exactly one row: destination verbatim, channel from the ctor, headers as a @@ -32,7 +33,7 @@ }); it('records an empty headers map as a json object, not an array, with a null transaction_id', function () { - (new PostgresEventPublisher(DB::connection())) + (new PostgresEventPublisher(DB::connection(), new SubscriberRegistry)) ->publish('order.events', 'order.created', ['id' => 9]); // Empty headers must serialize to `{}` (object) not `[]` (array) so the jsonb column stays object-typed. diff --git a/packages/eda-postgres/tests/RelayDownstreamTest.php b/packages/eda-postgres/tests/RelayDownstreamTest.php new file mode 100644 index 0000000..011c580 --- /dev/null +++ b/packages/eda-postgres/tests/RelayDownstreamTest.php @@ -0,0 +1,127 @@ + Firefly\Config\Config -> Repository) autowire. + * + * @param array $values + * @return array{0: Container, 1: Config} + */ +function relayEnv(array $values = []): array +{ + $repository = new Repository($values); + + $container = new Container; + $container->instance(SubscriberRegistry::class, new SubscriberRegistry); + $container->instance(RepositoryContract::class, $repository); + + return [$container, new Config($repository)]; +} + +it('FAILS LOUDLY when downstream_provider is unset instead of selecting nothing', function () { + [$container, $config] = relayEnv(); + + expect(fn () => RelayDownstream::resolve($container, $config)) + ->toThrow(ConfigurationException::class, 'firefly.eda.postgres.relay.downstream_provider'); +}); + +it('resolves the publisher an app bound explicitly under the relay downstream binding', function () { + [$container, $config] = relayEnv(['firefly.eda.postgres.relay.downstream_provider' => 'rabbitmq']); + $spy = new SpyDownstreamPublisher; + $container->instance(RelayDownstream::BINDING, $spy); + + expect(RelayDownstream::resolve($container, $config))->toBe($spy); +}); + +it('honours the explicit binding as the escape hatch even with downstream_provider unset', function () { + // The README and this class's own unset-key error message both offer "bind your own under the container id + // firefly.eda.relay.downstream" as an ALTERNATIVE to setting downstream_provider. Route 1 therefore has to be + // reachable with the key absent, or the documented escape hatch is a dead end that exits 1. + [$container, $config] = relayEnv(); + $spy = new SpyDownstreamPublisher; + $container->instance(RelayDownstream::BINDING, $spy); + + expect(RelayDownstream::resolve($container, $config))->toBe($spy); +}); + +it('resolves a downstream named directly by its class-string', function () { + [$container, $config] = relayEnv(['firefly.eda.postgres.relay.downstream_provider' => SpyDownstreamPublisher::class]); + + expect(RelayDownstream::resolve($container, $config))->toBeInstanceOf(SpyDownstreamPublisher::class); +}); + +it('builds the shipped rabbitmq adapter from its own config keys when the package is installed', function () { + if (! class_exists('Firefly\\Eda\\Rabbitmq\\RabbitMqEventPublisher')) { + $this->markTestSkipped('firefly/eda-rabbitmq is not installed in this environment.'); + } + + [$container, $config] = relayEnv([ + 'firefly.eda.postgres.relay.downstream_provider' => 'rabbitmq', + 'firefly.eda.rabbitmq.exchange' => 'relay.exchange', + ]); + + $downstream = RelayDownstream::resolve($container, $config); + + // A REAL downstream publisher — the whole point of the defect — built with the exchange the app configured + // rather than the adapter's compiled-in default. The exchange is read reflectively because it is a private + // readonly constructor property with no accessor; asserting it is the only way to prove the adapter's OWN + // config key survived the #[ConditionalOnProperty] gate it is standing behind (firefly.eda.provider is + // `postgres` here, so eda-rabbitmq's own bean never fires and cannot have applied it). + expect($downstream)->toBeInstanceOf('Firefly\\Eda\\Rabbitmq\\RabbitMqEventPublisher') + ->and($downstream)->not->toBeInstanceOf(PostgresEventPublisher::class) + ->and((new ReflectionProperty($downstream, 'exchange'))->getValue($downstream))->toBe('relay.exchange'); +}); + +it('names the missing composer package when the provider alias is not installed', function () { + [$container, $config] = relayEnv(['firefly.eda.postgres.relay.downstream_provider' => 'nats']); + + expect(fn () => RelayDownstream::resolve($container, $config)) + ->toThrow(ConfigurationException::class, 'nats'); +}); + +it('REFUSES the outbox writer as its own downstream (no re-insert loop)', function () { + [$container, $config] = relayEnv(['firefly.eda.postgres.relay.downstream_provider' => 'rabbitmq']); + $container->instance(RelayDownstream::BINDING, new PostgresEventPublisher( + new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:'), + new SubscriberRegistry, + )); + + expect(fn () => RelayDownstream::resolve($container, $config)) + ->toThrow(ConfigurationException::class, 'outbox writer'); +}); + +it('REFUSES a bound object that is not an EventPublisher at all', function () { + [$container, $config] = relayEnv(['firefly.eda.postgres.relay.downstream_provider' => 'rabbitmq']); + $container->instance(RelayDownstream::BINDING, new stdClass); + + expect(fn () => RelayDownstream::resolve($container, $config)) + ->toThrow(ConfigurationException::class, EventPublisher::class); +}); diff --git a/packages/eda-postgres/tests/Support/OutboxCapstoneTestCase.php b/packages/eda-postgres/tests/Support/OutboxCapstoneTestCase.php new file mode 100644 index 0000000..a78bcfd --- /dev/null +++ b/packages/eda-postgres/tests/Support/OutboxCapstoneTestCase.php @@ -0,0 +1,91 @@ + */ + protected function fireflyProviders(): array + { + return [ + EdaServiceProvider::class, + EdaWiringProvider::class, + EdaPostgresServiceProvider::class, + ]; + } + + /** + * Seeded BEFORE registration so the #[ConditionalOnProperty(firefly.eda.provider=postgres)] gates on the + * outbox beans (which are evaluated at register time) actually fire. + * + * @return array + */ + protected function configOverrides(): array + { + return ['firefly.eda.provider' => 'postgres']; + } + + /** + * Compile the capstone listener manifest INLINE with the real scanner (exactly what `firefly:cache` emits) and + * bind the ListenerSpy singleton — both before boot, so EventListenerWiringPass has something to subscribe and + * the handler it resolves shares the spy the assertions read. + */ + protected function defineFireflyEnvironment(Application $app): void + { + $app->instance(EventListenerManifest::class, new EventListenerManifest( + (new EventListenerScanner)->scan([ + 'Firefly\\Eda\\Postgres\\Tests\\CapstoneFixtures\\' => dirname(__DIR__).'/CapstoneFixtures', + ]), + )); + $app->singleton(ListenerSpy::class); + + // The bare capstone app registers neither firefly/cqrs nor firefly/data, yet PostgresOutboxAutoConfiguration's + // outboxPreCommitHook()/domainEventDispatcher() beans declare their collaborators as constructor parameters — + // and the container resolves those the moment the configuration class is registered. Supplying the empty + // real objects (never doubles) keeps the capstone about the DELIVERY path without pulling two more capability + // packages into the boot. + $app->instance(HandlerManifest::class, new HandlerManifest([], [])); + $app->instance(CorrelationContext::class, new CorrelationContext); + $app->instance(AggregateTracker::class, new AggregateTracker); + $app->instance(ApplicationEventPublisher::class, new class implements ApplicationEventPublisher + { + public function publish(object $event): void {} + }); + } + + protected function setUp(): void + { + parent::setUp(); + + Schema::create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table)); + } +} diff --git a/packages/eda-postgres/tests/Support/OutboxRelayCommandTestCase.php b/packages/eda-postgres/tests/Support/OutboxRelayCommandTestCase.php new file mode 100644 index 0000000..32d6953 --- /dev/null +++ b/packages/eda-postgres/tests/Support/OutboxRelayCommandTestCase.php @@ -0,0 +1,47 @@ + */ + protected function fireflyProviders(): array + { + return [...parent::fireflyProviders(), EdaPostgresBootServiceProvider::class]; + } + + /** + * `--max-messages=0` makes the daemon loop relay exactly ONE batch and then trip its bound, so the command + * terminates deterministically instead of sleeping between empty batches forever. Goes through Kernel::call() + * rather than $this->artisan() for the same PHPStan reason firefly/eda's EdaConsumeCommandTestCase documents: + * InteractsWithConsole::artisan() is declared `PendingCommand|int`. + */ + protected function runRelay(): int + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->call('firefly:outbox:relay', ['--max-messages' => 0]); + } + + protected function relayOutput(): string + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->output(); + } +} 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/eda/src/Attributes/EventListener.php b/packages/eda/src/Attributes/EventListener.php index 6aeb426..614318c 100644 --- a/packages/eda/src/Attributes/EventListener.php +++ b/packages/eda/src/Attributes/EventListener.php @@ -13,7 +13,21 @@ * delivered by the eda adapters. INERT METADATA ONLY — discovery + subscription live in EventListenerScanner * (the sole reflection site) → EventListenerManifest → EventListenerWiringPass. IS_REPEATABLE: a method may carry * several. A single string is normalised to a one-element pattern list; order follows the #[Order] convention - * (lower first), default 0. + * (lower first), default 0, and IS honoured at dispatch — see EventListenerManifest::ordered(). + * + * PATTERNS ARE EVENT TYPES, `destinations` ARE BROKER ROUTES. The two are separate namespaces and this attribute + * is the only place an application can relate them: + * + * - `$patterns` are fnmatch globs matched IN-PROCESS by SubscriberRegistry against EventEnvelope::$eventType, + * i.e. the SECOND argument of EventPublisher::publish($destination, $eventType, ...). + * - `$destinations` are broker routes — the FIRST argument of publish() — that a long-running consumer must + * bind before anything can arrive at all: a Kafka topic, an AMQP `exchange/routingKey` (or just the routing + * key), a logical outbox destination. They may themselves be fnmatch globs over destination names. + * + * Declaring `destinations` is OPTIONAL and purely an optimisation: firefly:eda:consume subscribes to the union of + * the declared destinations only when EVERY compiled listener declares at least one, and otherwise falls back to + * the catch-all `*` so that no event can be missed. See TopicSubscriptionResolver, which owns that rule and + * explains why a partially-declared manifest MUST NOT be narrowed. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class EventListener @@ -21,11 +35,19 @@ final class EventListener /** @var list */ public readonly array $patterns; + /** @var list */ + public readonly array $destinations; + /** - * @param string|array $patterns + * @param string|array $patterns event-type fnmatch glob(s) + * @param string|array $destinations broker route(s) a consumer must bind for these events */ - public function __construct(string|array $patterns = [], public readonly int $order = 0) - { + public function __construct( + string|array $patterns = [], + public readonly int $order = 0, + string|array $destinations = [], + ) { $this->patterns = is_string($patterns) ? [$patterns] : array_values($patterns); + $this->destinations = is_string($destinations) ? [$destinations] : array_values($destinations); } } diff --git a/packages/eda/src/Boot/EventListenerWiringPass.php b/packages/eda/src/Boot/EventListenerWiringPass.php index 9a6046d..6b978d8 100644 --- a/packages/eda/src/Boot/EventListenerWiringPass.php +++ b/packages/eda/src/Boot/EventListenerWiringPass.php @@ -24,6 +24,14 @@ * RetryingEventHandler with the config-driven retries/retryDelay + the bound DeadLetterStore, and resolves its * target bean FRESH on every dispatch (never caching it at registration) — exactly like RegisterEventListenersPass * — so it always observes the fully post-processed (possibly proxied) bean. + * + * 🔴 It subscribes in EventListenerManifest::ordered() order, NOT all() order. SubscriberRegistry::deliver() + * invokes matching handlers in SUBSCRIPTION order, so this loop is the only place the declared + * #[EventListener(order:)] can be honoured — and before this it looped over all(), which meant the scanned, + * compiled, round-tripped `order` was read from the manifest and then thrown away. Listeners ran in whatever + * sequence the scanner emitted (FQCN sort, then declaration order), so an audit/enrichment listener that + * declared a low order to run first ran wherever its class name sorted, silently. See + * EventListenerManifest::ordered() for the sort and its stable tie-break. */ final class EventListenerWiringPass implements BootPass { @@ -52,7 +60,7 @@ public function run(BootContext $context): void $retryDelayRaw = $context->config->get('firefly.eda.retry_delay', 0.0); $retryDelay = is_numeric($retryDelayRaw) ? (float) $retryDelayRaw : 0.0; - foreach ($manifest->all() as $descriptor) { + foreach ($manifest->ordered() as $descriptor) { $handler = RetryingEventHandler::wrap($this->invoker($container, $descriptor), $retries, $retryDelay, $dlq); foreach ($descriptor->patterns as $pattern) { diff --git a/packages/eda/src/Console/ConsumeEventsCommand.php b/packages/eda/src/Console/ConsumeEventsCommand.php index eccfdcc..ff304e7 100644 --- a/packages/eda/src/Console/ConsumeEventsCommand.php +++ b/packages/eda/src/Console/ConsumeEventsCommand.php @@ -4,6 +4,7 @@ namespace Firefly\Eda\Console; +use Firefly\Config\Config; use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\Consumer\ConsumerLoop; use Firefly\Eda\Consumer\ConsumerOptions; @@ -11,19 +12,29 @@ use Firefly\Eda\Consumer\TopicSubscriptionResolver; use Firefly\Eda\EventEnvelope; use Firefly\Eda\Listener\EventListenerManifest; +use Firefly\Kernel\Exception\Framework\ConfigurationException; use Illuminate\Console\Command; /** * Drives the active broker's EventConsumer until a bound trips or a signal arrives. Resolves the EventConsumer + * SubscriberRegistry a broker package bound (the same registry EventListenerWiringPass populated this process's boot), - * derives concrete subscriptions from the compiled EventListenerManifest, and feeds every polled envelope into the - * registry. A clear error when no broker consumer is bound (memory/queue providers have no long-running consumer — - * queue uses `php artisan queue:work`). + * derives the concrete broker DESTINATIONS to bind, and feeds every polled envelope into the registry. A clear error + * when no broker consumer is bound (memory/queue providers have no long-running consumer — queue uses + * `php artisan queue:work`). + * + * 🔴 DESTINATIONS, NOT EVENT TYPES. This command used to bind the compiled #[EventListener] patterns as broker + * routes — `$consumer->subscribe((new TopicSubscriptionResolver)->resolve($manifest))` where resolve() returned + * `['order.*']`. Publishers route by destination (Kafka topic, AMQP exchange/routing key, outbox column), so an app + * publishing `order.created` to the topic `orders` produced a worker bound to a route nothing writes to: it started + * cleanly, polled forever and consumed nothing. The destinations now come from `--destination` (repeatable), else + * the `firefly.eda.destinations` config list, else the safe catch-all — see TopicSubscriptionResolver for the + * precedence rule and why over-subscribing is the correct default. The bound destinations are echoed at startup so + * a misconfigured worker is visible in the log on line one instead of being diagnosed as "the broker is down". */ final class ConsumeEventsCommand extends Command { /** @var string */ - protected $signature = 'firefly:eda:consume {--max-messages= : stop after N messages} {--time-limit= : stop after N seconds} {--sleep=0 : idle ms between empty polls} {--poll-timeout=5000 : block ms per poll}'; + protected $signature = 'firefly:eda:consume {--destination=* : broker destination(s) to bind; overrides firefly.eda.destinations} {--max-messages= : stop after N messages} {--time-limit= : stop after N seconds} {--sleep=0 : idle ms between empty polls} {--poll-timeout=5000 : block ms per poll}'; /** @var string */ protected $description = 'Run the configured broker EventConsumer, dispatching to #[EventListener] handlers until stopped.'; @@ -43,7 +54,9 @@ public function handle(): int /** @var EventListenerManifest $manifest */ $manifest = $this->laravel->make(EventListenerManifest::class); - $consumer->subscribe((new TopicSubscriptionResolver)->resolve($manifest)); + $destinations = (new TopicSubscriptionResolver)->resolve($manifest, $this->requestedDestinations()); + $consumer->subscribe($destinations); + $this->info('firefly:eda:consume — subscribed to '.implode(', ', $destinations).'.'); $maxMessages = $this->option('max-messages'); $timeLimit = $this->option('time-limit'); @@ -65,4 +78,56 @@ public function handle(): int return self::SUCCESS; } + + /** + * The operator's explicit destinations: `--destination` wins over `firefly.eda.destinations` so one compiled + * manifest can be sharded across several workers (one destination each) without a per-deployment config edit — + * the `queue:work --queue=` idiom. Both are validated element-by-element and rejected loudly rather than + * filtered: a destination silently dropped for being an int or a nested array is a subscription gap, and a + * subscription gap in this command is invisible at runtime (the worker simply never receives those events), + * which is the whole class of bug this file exists to close. + * + * @return list + */ + 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 ($option !== []) { + return $this->strings($option, '--destination'); + } + + /** @var Config $config */ + $config = $this->laravel->make(Config::class); + $configured = $config->get('firefly.eda.destinations', []); + + if (! is_array($configured)) { + throw new ConfigurationException('firefly.eda.destinations must be a list of broker destination strings.'); + } + + return $this->strings($configured, 'firefly.eda.destinations'); + } + + /** + * @param array $values + * @return list + */ + private function strings(array $values, string $source): array + { + $strings = []; + foreach ($values as $value) { + if (! is_string($value)) { + throw new ConfigurationException( + "{$source} must contain only broker destination strings; got ".get_debug_type($value).'.', + ); + } + $strings[] = $value; + } + + return $strings; + } } diff --git a/packages/eda/src/Consumer/EventConsumer.php b/packages/eda/src/Consumer/EventConsumer.php index e2ed7c5..bda8e0e 100644 --- a/packages/eda/src/Consumer/EventConsumer.php +++ b/packages/eda/src/Consumer/EventConsumer.php @@ -13,9 +13,33 @@ interface EventConsumer { /** - * Bind to the concrete broker destinations (topics/queues/channels) derived from the compiled manifest. + * Bind this consumer to the broker routes it should receive from, before the first poll(). * - * @param list $destinations + * THE ROUTING CONTRACT — the one thing an adapter must get right, and the one thing that was wrong. + * + * Every element of $destinations is a DESTINATION: the value a publisher passed as the FIRST argument of + * EventPublisher::publish(string $destination, string $eventType, ...), or an fnmatch glob over such values. + * It is emphatically NOT an event type. `$eventType` — publish()'s SECOND argument, the thing + * #[EventListener] patterns are written against — never appears here and must never be matched against a + * destination. The two are independent namespaces: an app may publish `order.created`, `order.shipped` and + * `order.cancelled` all to one destination `orders`, or the same event type to several destinations. + * ConsumeEventsCommand used to pass the manifest's event-type patterns into this method, which bound + * consumers to routes no publisher ever wrote to; they polled forever and received nothing, silently. See + * TopicSubscriptionResolver for the full account and for where destinations legitimately come from. + * + * An adapter translates each destination into its own subscription model and MUST support the fnmatch + * wildcard `*`, because the resolver's safe default is the catch-all `['*']` — "every destination this + * application publishes to": + * + * - RabbitMQ: an AMQP routing key bound to the work queue (`*`/`**` widen to the AMQP catch-all `#`). + * - Kafka: a topic name, or a `^`-prefixed librdkafka regex when the destination contains `*`. + * - Postgres: ignored — that adapter is bound to a LISTEN channel and claims every PENDING outbox row. + * + * Over-subscribing at the broker is SAFE and expected: SubscriberRegistry applies the fine-grained fnmatch on + * EventEnvelope::$eventType after receipt, so an envelope no #[EventListener] wants is simply dropped. + * Under-subscribing is not recoverable — the message never reaches the process. When in doubt, bind wider. + * + * @param list $destinations publisher destinations (or fnmatch globs over them); never empty */ public function subscribe(array $destinations): void; diff --git a/packages/eda/src/Consumer/TopicSubscriptionResolver.php b/packages/eda/src/Consumer/TopicSubscriptionResolver.php index 8da14d0..0a23531 100644 --- a/packages/eda/src/Consumer/TopicSubscriptionResolver.php +++ b/packages/eda/src/Consumer/TopicSubscriptionResolver.php @@ -7,27 +7,90 @@ use Firefly\Eda\Listener\EventListenerManifest; /** - * The fnmatch->concrete-topic bridge. EventPublisher::subscribe() is fnmatch-pattern-based; a real broker subscribes - * to concrete topics/queues. This collects the distinct event-type patterns the compiled #[EventListener]s declare; - * each adapter maps them to its own subscription model (RabbitMQ topic bindings, a Kafka topic list, a Postgres - * LISTEN channel) and relies on SubscriberRegistry's fnmatch for the fine-grained post-receipt filter. + * Resolves the BROKER DESTINATIONS firefly:eda:consume binds through EventConsumer::subscribe(). + * + * THE DEFECT THIS CLASS USED TO BE. It returned the compiled #[EventListener] patterns — `user.*`, `order.created` + * — and ConsumeEventsCommand handed them straight to EventConsumer::subscribe(). But an #[EventListener] pattern + * is an EVENT TYPE, and every publisher in the framework routes by DESTINATION, the other argument entirely: + * + * EventPublisher::publish(string $destination, string $eventType, array $payload, array $headers = []) + * + * KafkaEventPublisher -> $producer->newTopic($destination) (topic == destination, verbatim) + * RabbitMqEventPublisher -> parseDestination($destination) (exchange + routing key, from destination) + * PostgresEventPublisher -> INSERT ... destination = $destination (outbox column) + * + * Nothing anywhere derives a destination from an event type. So an ordinary app publishing `order.created` to a + * topic called `orders` got a Kafka consumer subscribed to the regex `^order\..*` and a RabbitMQ queue bound to + * the routing key `order.*` — neither of which can ever match `orders`. There was no exception, no warning and no + * failed health check: the worker started, polled forever, and consumed nothing. The two adapter round-trip tests + * did not catch it because they publish to `/order.created`, i.e. they happen to choose a routing key + * equal to the event type, which is a convention and not a contract. + * + * THE CONTRACT NOW. Destinations come from the two places that actually know them, in precedence order: + * + * 1. What the operator asked for — `--destination` on the command, else the `firefly.eda.destinations` config + * list. This is authoritative and is returned verbatim (deduplicated): it is also how one manifest gets + * sharded across several workers, one destination each. + * 2. The union of the `destinations` every compiled listener declared — but ONLY when EVERY listener declared + * at least one. A listener that declared none is a listener whose destination is unknowable from the + * manifest, and binding just the destinations its siblings named would drop its events silently: precisely + * the failure this class exists to prevent. One undeclared listener therefore re-widens the whole worker. + * 3. Otherwise the catch-all CATCH_ALL (`*`). + * + * WHY A CATCH-ALL DEFAULT IS THE CORRECT ONE. Broker subscription is a COARSE filter here; the fine filter is + * SubscriberRegistry's fnmatch on EventEnvelope::$eventType, applied after receipt. Over-subscribing therefore + * costs bandwidth and nothing else, while under-subscribing loses events invisibly — so when the destinations + * are not known, the safe answer is "everything, then filter". Both wildcard-capable adapters already implement + * exactly this translation for `*`: RabbitMqEventConsumer::toRoutingKey() widens `*` to the AMQP catch-all `#`, + * and KafkaEventConsumer::toTopic() rewrites it to the librdkafka regex `^.*`. PostgresEventConsumer ignores the + * argument outright (it is bound to a LISTEN channel, not to per-destination routes), so the default is inert + * there. An operator who wants the narrow subscription back sets `firefly.eda.destinations`. */ final class TopicSubscriptionResolver { /** - * @return list + * The "every destination this application publishes to" wildcard. It is an fnmatch glob, the same dialect + * #[EventListener] patterns and adapter destinations are written in, so an adapter that already translates + * wildcards needs no special case for it. + */ + public const string CATCH_ALL = '*'; + + /** + * @param list $configured operator-supplied destinations (--destination, else firefly.eda.destinations) + * @return list broker destinations, never event-type patterns; never empty */ - public function resolve(EventListenerManifest $manifest): array + public function resolve(EventListenerManifest $manifest, array $configured = []): array { - $patterns = []; - foreach ($manifest->all() as $descriptor) { - foreach ($descriptor->patterns as $pattern) { - if (! in_array($pattern, $patterns, true)) { - $patterns[] = $pattern; - } + if ($configured !== []) { + return $this->distinct($configured); + } + + $listeners = $manifest->all(); + $declared = []; + + foreach ($listeners as $descriptor) { + if ($descriptor->destinations === []) { + // Unknowable destination -> narrowing is no longer safe for ANY listener. Bail to the catch-all + // rather than bind a subset that would silently starve this one. + return [self::CATCH_ALL]; + } + + foreach ($descriptor->destinations as $destination) { + $declared[] = $destination; } } - return $patterns; + // $listeners === [] falls through here too: a worker with no compiled listeners has nothing to narrow + // toward, and an empty subscription list is a shape several adapters would reject outright. + return $declared === [] ? [self::CATCH_ALL] : $this->distinct($declared); + } + + /** + * @param list $destinations + * @return list + */ + private function distinct(array $destinations): array + { + return array_values(array_unique($destinations)); } } 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/eda/src/Listener/EventListenerDescriptor.php b/packages/eda/src/Listener/EventListenerDescriptor.php index e382727..f1899ba 100644 --- a/packages/eda/src/Listener/EventListenerDescriptor.php +++ b/packages/eda/src/Listener/EventListenerDescriptor.php @@ -5,22 +5,29 @@ namespace Firefly\Eda\Listener; /** - * A single compiled eda listener: the target class/method plus the event-type patterns it subscribes to and its - * order. Every field is scalar-or-list so the manifest var_exports as a plain array literal (no closures/objects), - * loaded by require+map in production. Mirrors ScheduledDescriptor. + * A single compiled eda listener: the target class/method, the event-type patterns it subscribes to, its order, + * and the broker destinations it declared (if any). Every field is scalar-or-list so the manifest var_exports as + * a plain array literal (no closures/objects), loaded by require+map in production. Mirrors ScheduledDescriptor. * - * @phpstan-type EventListenerRow array{class: string, method: string, patterns: list, order: int} + * `patterns` and `destinations` are DIFFERENT NAMESPACES and must never be substituted for one another: + * `patterns` are fnmatch globs over EventEnvelope::$eventType (matched in-process by SubscriberRegistry), + * `destinations` are broker routes over EventEnvelope::$destination (bound by EventConsumer::subscribe()). + * Conflating them is exactly the defect TopicSubscriptionResolver's docblock records. + * + * @phpstan-type EventListenerRow array{class: string, method: string, patterns: list, order: int, destinations?: list} */ final readonly class EventListenerDescriptor { /** * @param list $patterns + * @param list $destinations */ public function __construct( public string $class, public string $method, public array $patterns, public int $order = 0, + public array $destinations = [], ) {} /** @@ -33,14 +40,21 @@ public function toArray(): array 'method' => $this->method, 'patterns' => $this->patterns, 'order' => $this->order, + 'destinations' => $this->destinations, ]; } /** + * `destinations` is read with a `?? []` fallback rather than as a required key on purpose: a manifest + * compiled by an older firefly/cli (before listeners could declare broker destinations) is still a valid + * artifact on disk, and an app that upgrades firefly/eda without re-running `firefly:cache` must keep + * booting. A missing key means "this listener declared none", which TopicSubscriptionResolver already + * treats as "cannot narrow safely" — the conservative answer, never a silent subscription gap. + * * @param EventListenerRow $data */ public static function fromArray(array $data): self { - return new self($data['class'], $data['method'], $data['patterns'], $data['order']); + return new self($data['class'], $data['method'], $data['patterns'], $data['order'], $data['destinations'] ?? []); } } diff --git a/packages/eda/src/Listener/EventListenerManifest.php b/packages/eda/src/Listener/EventListenerManifest.php index 2b0e02d..6bc6188 100644 --- a/packages/eda/src/Listener/EventListenerManifest.php +++ b/packages/eda/src/Listener/EventListenerManifest.php @@ -48,10 +48,46 @@ public static function load(string $path): self } /** + * The compiled listeners in the order the manifest was written (scanner order in practice: FQCN-sorted + * classes, then each class's methods in declaration order, then repeated attributes in written order). + * Callers that DISPATCH must use ordered() instead — see its docblock for why. + * * @return list */ public function all(): array { return $this->listeners; } + + /** + * The dispatch order: listeners sorted by their declared #[EventListener(order:)] ASCENDING (lower first, + * the #[Order] convention used throughout the framework), ties keeping compiled-manifest order. + * + * WHY THIS EXISTS. `order` was scanned by EventListenerScanner, carried through EventListenerDescriptor, + * var_export'd into the compiled manifest, read back by fromArray() — and then NEVER CONSULTED. The one + * place that could have used it, EventListenerWiringPass, looped over all() and subscribed in that order, + * and SubscriberRegistry::deliver() fires handlers in subscription order. So the declared order was + * silently discarded and the real dispatch sequence was "whatever the scanner emitted" — effectively FQCN + * order. A listener that declared order: -100 to run an audit hook first ran wherever its class name + * happened to sort. Nothing failed; the ordering was simply a lie. Caught by publishing one event to three + * listeners whose declared orders (30/10/20) disagree with their alphabetical order on every position. + * + * TIE-BREAK. Equal orders keep compiled-manifest order. usort() has been guaranteed STABLE since PHP 8.0, + * so this is a real guarantee and not an accident of the sort implementation — which matters, because a + * non-deterministic tie-break would let an app pass CI and reorder itself in production. Compiled-manifest + * order is itself deterministic (EventListenerScanner sort()s the discovered FQCNs, and reflection returns + * a class's methods in declaration order), so the whole sequence is reproducible from the source tree. + * + * @return list + */ + public function ordered(): array + { + $ordered = $this->listeners; + usort( + $ordered, + static fn (EventListenerDescriptor $a, EventListenerDescriptor $b): int => $a->order <=> $b->order, + ); + + return $ordered; + } } diff --git a/packages/eda/src/Scanner/EventListenerScanner.php b/packages/eda/src/Scanner/EventListenerScanner.php index e314f42..af34f8c 100644 --- a/packages/eda/src/Scanner/EventListenerScanner.php +++ b/packages/eda/src/Scanner/EventListenerScanner.php @@ -35,6 +35,7 @@ class: $class, method: $method->getName(), patterns: $listener->patterns, order: $listener->order, + destinations: $listener->destinations, ); } } diff --git a/packages/eda/tests/Boot/EventListenerOrderingTest.php b/packages/eda/tests/Boot/EventListenerOrderingTest.php new file mode 100644 index 0000000..130f210 --- /dev/null +++ b/packages/eda/tests/Boot/EventListenerOrderingTest.php @@ -0,0 +1,44 @@ + manifest -> EventListenerWiringPass + * -> InMemoryEventBus -> SubscriberRegistry -> handler. Three listeners in tests/OrderedFixtures declare + * order 30 (Alpha), 10 (Beta) and 20 (Gamma); the scanner emits them in FQCN order (Alpha, Beta, Gamma), so the + * compiled manifest order and the declared order disagree on every position. Before the fix the wiring pass + * subscribed in manifest order and the registry fired in subscription order, so this published event ran + * alpha -> beta -> gamma and #[EventListener(order:)] was decorative. It must run beta -> gamma -> alpha. + */ +it('dispatches #[EventListener]s in declared order, not compiled-manifest order', function () { + $descriptors = (new EventListenerScanner)->scan([ + 'Firefly\\Eda\\Tests\\OrderedFixtures\\' => dirname(__DIR__).'/OrderedFixtures', + ]); + + // Guard the premise: if the scanner ever stopped emitting FQCN order this test would pass vacuously. + expect(array_map(static fn ($d) => $d->order, $descriptors))->toBe([30, 10, 20]); + + $context = bootFireflyApp( + ['firefly' => ['eda' => []]], + [EdaServiceProvider::class, EdaWiringProvider::class], + bindings: [ + EventListenerManifest::class => new EventListenerManifest($descriptors), + ListenerSpy::class => new ListenerSpy, + ], + ); + + /** @var EventPublisher $bus */ + $bus = $context->get(EventPublisher::class); + $bus->publish('firefly.events', 'ordered.thing', ['id' => 1]); + + /** @var ListenerSpy $spy */ + $spy = $context->get(ListenerSpy::class); + expect($spy->seen)->toBe(['beta', 'gamma', 'alpha']); +}); diff --git a/packages/eda/tests/Console/ConsumeEventsCommandTest.php b/packages/eda/tests/Console/ConsumeEventsCommandTest.php new file mode 100644 index 0000000..03a412e --- /dev/null +++ b/packages/eda/tests/Console/ConsumeEventsCommandTest.php @@ -0,0 +1,65 @@ +runConsume())->toBe(0) + ->and($this->consumer->subscribed)->toBe([[TopicSubscriptionResolver::CATCH_ALL]]) + ->and($this->consumer->subscribed[0])->not->toContain('order.*'); +}); + +it('subscribes to firefly.eda.destinations when the operator has configured them', function () { + /** @var EdaConsumeCommandTestCase $this */ + $this->setDestinationConfig(['orders', 'users']); + + expect($this->runConsume())->toBe(0) + ->and($this->consumer->subscribed)->toBe([['orders', 'users']]); +}); + +/** + * --destination beats config so an operator can shard one manifest across several workers (one destination each) + * without editing config per deployment — the `queue:work --queue=` idiom. + */ +it('lets --destination override the configured destinations', function () { + /** @var EdaConsumeCommandTestCase $this */ + $this->setDestinationConfig(['orders']); + + expect($this->runConsume(['--destination' => ['users']]))->toBe(0) + ->and($this->consumer->subscribed)->toBe([['users']]); +}); + +/** + * A destination dropped for being the wrong type would be an invisible subscription gap: the worker starts, + * reports success, and simply never receives that route's events. Fail at startup instead of filtering. + */ +it('fails loud when firefly.eda.destinations holds a non-string entry', function () { + /** @var EdaConsumeCommandTestCase $this */ + $this->setDestinationConfig(['orders', 42]); + + $this->runConsume(); +})->throws(ConfigurationException::class); + +it('exits FAILURE without subscribing when no broker EventConsumer is bound', function () { + /** @var EdaConsumeCommandTestCase $this */ + $this->app()->forgetInstance(EventConsumer::class); + + expect($this->runConsume())->toBe(1) + ->and($this->consumer->subscribed)->toBe([]); +}); diff --git a/packages/eda/tests/Consumer/TopicSubscriptionResolverTest.php b/packages/eda/tests/Consumer/TopicSubscriptionResolverTest.php index 5976418..d42cbbe 100644 --- a/packages/eda/tests/Consumer/TopicSubscriptionResolverTest.php +++ b/packages/eda/tests/Consumer/TopicSubscriptionResolverTest.php @@ -6,12 +6,66 @@ use Firefly\Eda\Listener\EventListenerDescriptor; use Firefly\Eda\Listener\EventListenerManifest; -it('collects distinct fnmatch patterns from the compiled manifest', function () { +/** + * THE ROUTING CONTRACT. A broker routes by DESTINATION (the first argument of + * EventPublisher::publish($destination, $eventType, ...)): Kafka produces to topic=$destination, RabbitMQ splits + * it into exchange/routingKey, Postgres stores it on the outbox row. An #[EventListener] pattern is an + * EVENT-TYPE glob, matched in-process by SubscriberRegistry against EventEnvelope::$eventType. They are two + * different namespaces and nothing derives one from the other. + * + * This resolver used to return the manifest's event-type patterns and hand them to EventConsumer::subscribe(), + * i.e. it bound `order.*` as a Kafka topic regex / an AMQP routing key while publishers were producing to a + * topic called `orders`. Nothing errored — the worker just sat there receiving nothing. These cases pin that + * the resolver never again treats a pattern as a destination. + */ +it('never treats an #[EventListener] event-type pattern as a broker destination', function () { $manifest = new EventListenerManifest([ new EventListenerDescriptor('A', 'on', ['user.*', 'order.created'], 0), new EventListenerDescriptor('B', 'on', ['user.*'], 0), ]); - expect((new TopicSubscriptionResolver)->resolve($manifest)) - ->toBe(['user.*', 'order.created']); + $destinations = (new TopicSubscriptionResolver)->resolve($manifest); + + expect($destinations)->toBe([TopicSubscriptionResolver::CATCH_ALL]) + ->and($destinations)->not->toContain('user.*') + ->and($destinations)->not->toContain('order.created'); +}); + +it('falls back to the catch-all when the manifest is empty', function () { + expect((new TopicSubscriptionResolver)->resolve(new EventListenerManifest([]))) + ->toBe([TopicSubscriptionResolver::CATCH_ALL]); +}); + +it('returns the operator-configured destinations verbatim, deduplicated, when they are given', function () { + $manifest = new EventListenerManifest([ + new EventListenerDescriptor('A', 'on', ['user.*'], 0, ['ignored.topic']), + ]); + + expect((new TopicSubscriptionResolver)->resolve($manifest, ['orders', 'users', 'orders'])) + ->toBe(['orders', 'users']); +}); + +it('narrows to the union of declared destinations when every listener declares at least one', function () { + $manifest = new EventListenerManifest([ + new EventListenerDescriptor('A', 'on', ['user.*'], 0, ['users', 'orders']), + new EventListenerDescriptor('B', 'on', ['order.*'], 0, ['orders']), + ]); + + expect((new TopicSubscriptionResolver)->resolve($manifest))->toBe(['users', 'orders']); +}); + +/** + * The safety rule that makes automatic narrowing usable at all: narrowing is only correct when EVERY listener + * has told us which destination it needs. One listener that declares none is a listener whose destination we + * cannot know, and binding only the destinations the others declared would drop its events silently — the exact + * failure mode this whole fix exists to remove. So a single undeclared listener re-widens the subscription to + * the catch-all, and SubscriberRegistry's fnmatch does the fine filter after receipt. + */ +it('re-widens to the catch-all when any listener declares no destination', function () { + $manifest = new EventListenerManifest([ + new EventListenerDescriptor('A', 'on', ['user.*'], 0, ['users']), + new EventListenerDescriptor('B', 'on', ['order.*'], 0), + ]); + + expect((new TopicSubscriptionResolver)->resolve($manifest))->toBe([TopicSubscriptionResolver::CATCH_ALL]); }); diff --git a/packages/eda/tests/Fixtures/ScriptedEventConsumer.php b/packages/eda/tests/Fixtures/ScriptedEventConsumer.php index 35b2990..0410814 100644 --- a/packages/eda/tests/Fixtures/ScriptedEventConsumer.php +++ b/packages/eda/tests/Fixtures/ScriptedEventConsumer.php @@ -9,9 +9,10 @@ use Firefly\Eda\EventEnvelope; /** - * A scripted in-memory EventConsumer fake for ConsumerLoopTest: emits a fixed queue of envelopes then nulls - * (simulating poll timeouts), and records every ack()/nack() delivery tag plus start()/stop() calls so the - * loop's bounded-termination and ack/nack routing can be asserted without a real broker. + * A scripted in-memory EventConsumer fake for ConsumerLoopTest / ConsumeEventsCommandTest: emits a fixed queue + * of envelopes then nulls (simulating poll timeouts), and records every ack()/nack() delivery tag, the + * destination list it was subscribed to, and start()/stop() calls — so the loop's bounded-termination, its + * ack/nack routing, and the command's destination-routing contract can all be asserted without a real broker. */ final class ScriptedEventConsumer implements EventConsumer { @@ -21,6 +22,13 @@ final class ScriptedEventConsumer implements EventConsumer /** @var list */ public array $nacked = []; + /** + * Every destination list handed to subscribe(), in call order — the command's routing contract is asserted on this. + * + * @var list> + */ + public array $subscribed = []; + public bool $started = false; public bool $stopped = false; @@ -30,9 +38,12 @@ final class ScriptedEventConsumer implements EventConsumer */ public function __construct(private array $queue) {} + /** + * @param list $destinations + */ public function subscribe(array $destinations): void { - // no-op: subscription targets aren't asserted by the scripted-consumer tests. + $this->subscribed[] = $destinations; } public function start(): void diff --git a/packages/eda/tests/Listener/EventListenerManifestTest.php b/packages/eda/tests/Listener/EventListenerManifestTest.php index 83c0258..381ad4a 100644 --- a/packages/eda/tests/Listener/EventListenerManifestTest.php +++ b/packages/eda/tests/Listener/EventListenerManifestTest.php @@ -10,7 +10,7 @@ it('round-trips descriptors through compile → require → load', function () { $descriptors = [ new EventListenerDescriptor('App\\Listeners\\A', 'onUser', ['user.*'], 0), - new EventListenerDescriptor('App\\Listeners\\B', 'onOrder', ['order.created', 'order.paid'], 3), + new EventListenerDescriptor('App\\Listeners\\B', 'onOrder', ['order.created', 'order.paid'], 3, ['orders']), ]; $path = sys_get_temp_dir().'/firefly-eda-listeners-'.bin2hex(random_bytes(6)).'.php'; @@ -20,7 +20,9 @@ expect($manifest->all())->toHaveCount(2) ->and($manifest->all()[1]->patterns)->toBe(['order.created', 'order.paid']) - ->and($manifest->all()[1]->order)->toBe(3); + ->and($manifest->all()[1]->order)->toBe(3) + ->and($manifest->all()[1]->destinations)->toBe(['orders']) + ->and($manifest->all()[0]->destinations)->toBe([]); } finally { @unlink($path); } @@ -29,3 +31,25 @@ it('throws a ConfigurationException when the manifest file is missing', function () { EventListenerManifest::load('/no/such/manifest.php'); })->throws(ConfigurationException::class); + +/** + * FORWARD-COMPATIBILITY OF THE ARTIFACT. `destinations` was added to the compiled row after apps were already + * shipping manifests emitted by an older firefly/cli. Those files are still on disk, and an app that upgrades + * firefly/eda without re-running `firefly:cache` must keep booting rather than fataling on a missing array key. + * A legacy row therefore means "declared no destinations", which TopicSubscriptionResolver reads as "cannot + * narrow safely" and answers with the catch-all — conservative, never a silent subscription gap. + */ +it('loads a legacy manifest row that predates the destinations key', function () { + $path = sys_get_temp_dir().'/firefly-eda-legacy-'.bin2hex(random_bytes(6)).'.php'; + try { + file_put_contents($path, " 'App\\\\Listeners\\\\Legacy', 'method' => 'on', 'patterns' => ['user.*'], 'order' => 7]];\n"); + + $listener = EventListenerManifest::load($path)->all()[0]; + + expect($listener->class)->toBe('App\\Listeners\\Legacy') + ->and($listener->order)->toBe(7) + ->and($listener->destinations)->toBe([]); + } finally { + @unlink($path); + } +}); diff --git a/packages/eda/tests/Listener/EventListenerOrderTest.php b/packages/eda/tests/Listener/EventListenerOrderTest.php new file mode 100644 index 0000000..b83015e --- /dev/null +++ b/packages/eda/tests/Listener/EventListenerOrderTest.php @@ -0,0 +1,43 @@ + $d->class, $manifest->ordered()))->toBe(['A', 'B', 'C']) + ->and(array_map(static fn ($d) => $d->class, $manifest->all()))->toBe(['C', 'A', 'B']); +}); + +/** + * The tie-break has to be DETERMINISTIC or "ordered" is a half-promise: two listeners at the same order would + * swap places between runs and an app could pass CI and fail in production. usort() is guaranteed stable as of + * PHP 8.0, so equal orders keep the compiled-manifest sequence — which is itself deterministic, because + * EventListenerScanner sort()s the discovered FQCNs and reflection returns a class's methods in declaration order. + */ +it('keeps compiled-manifest order for listeners that declare the same order (stable tie-break)', function () { + $manifest = new EventListenerManifest([ + new EventListenerDescriptor('Late', 'on', ['x'], 100), + new EventListenerDescriptor('SecondAtZero', 'on', ['x'], 0), + new EventListenerDescriptor('FirstAtZero', 'on', ['x'], 0), + new EventListenerDescriptor('ThirdAtZero', 'on', ['x'], 0), + ]); + + expect(array_map(static fn ($d) => $d->class, $manifest->ordered())) + ->toBe(['SecondAtZero', 'FirstAtZero', 'ThirdAtZero', 'Late']); +}); diff --git a/packages/eda/tests/OrderedFixtures/AlphaListener.php b/packages/eda/tests/OrderedFixtures/AlphaListener.php new file mode 100644 index 0000000..ee996d6 --- /dev/null +++ b/packages/eda/tests/OrderedFixtures/AlphaListener.php @@ -0,0 +1,35 @@ + gamma -> alpha; a dispatch that merely replays the compiled-manifest order runs alpha -> beta -> gamma. + * That gap is what EventListenerOrderingTest asserts on, and it is what the pre-fix wiring pass got wrong. + * + * They live in their OWN directory rather than tests/Fixtures because several existing suites scan tests/Fixtures + * with the real scanner and assert on the exact set of listeners it finds (EventListenerWiringPassTest expects + * `['order.placed']` and nothing else); adding listeners there would have broken those assertions for reasons + * unrelated to ordering. + */ +#[Component] +final class AlphaListener +{ + public function __construct(private readonly ListenerSpy $spy) {} + + #[EventListener('ordered.*', order: 30)] + public function onOrdered(EventEnvelope $envelope): void + { + $this->spy->record('alpha'); + } +} diff --git a/packages/eda/tests/OrderedFixtures/BetaListener.php b/packages/eda/tests/OrderedFixtures/BetaListener.php new file mode 100644 index 0000000..cf9b663 --- /dev/null +++ b/packages/eda/tests/OrderedFixtures/BetaListener.php @@ -0,0 +1,23 @@ +spy->record('beta'); + } +} diff --git a/packages/eda/tests/OrderedFixtures/GammaListener.php b/packages/eda/tests/OrderedFixtures/GammaListener.php new file mode 100644 index 0000000..4a139ec --- /dev/null +++ b/packages/eda/tests/OrderedFixtures/GammaListener.php @@ -0,0 +1,23 @@ +spy->record('gamma'); + } +} diff --git a/packages/eda/tests/Scanner/EventListenerScannerTest.php b/packages/eda/tests/Scanner/EventListenerScannerTest.php index 60b82d4..8fa971f 100644 --- a/packages/eda/tests/Scanner/EventListenerScannerTest.php +++ b/packages/eda/tests/Scanner/EventListenerScannerTest.php @@ -4,21 +4,33 @@ use Firefly\Eda\Listener\EventListenerDescriptor; use Firefly\Eda\Scanner\EventListenerScanner; +use Firefly\Eda\Tests\ScannerFixtures\PlainListener; use Firefly\Eda\Tests\ScannerFixtures\SampleListener; -it('scans a #[EventListener] method into a descriptor carrying its patterns + order', function () { +/** + * The scanner is the sole reflection site, so anything it drops is gone from every downstream stage. `order` was + * already carried here (and was then discarded at dispatch — see EventListenerManifest::ordered()); `destinations` + * is carried for the same reason and must not be quietly lost either, because TopicSubscriptionResolver can only + * narrow a broker subscription from what reaches the compiled manifest. PlainListener pins the other half: a + * listener that declares no destinations compiles to an empty list, which the resolver reads as "cannot narrow". + */ +it('scans a #[EventListener] method into a descriptor carrying its patterns + order + destinations', function () { $descriptors = (new EventListenerScanner)->scan([ 'Firefly\\Eda\\Tests\\ScannerFixtures\\' => __DIR__.'/../ScannerFixtures', ]); - expect($descriptors)->toHaveCount(1); + // EventListenerScanner::classes() sort()s the discovered FQCNs, so PlainListener precedes SampleListener. + expect($descriptors)->toHaveCount(2); - $descriptor = $descriptors[0]; + [$plain, $descriptor] = $descriptors; expect($descriptor)->toBeInstanceOf(EventListenerDescriptor::class) ->and($descriptor->class)->toBe(SampleListener::class) ->and($descriptor->method)->toBe('on') ->and($descriptor->patterns)->toBe(['user.*', 'order.created']) - ->and($descriptor->order)->toBe(5); + ->and($descriptor->order)->toBe(5) + ->and($descriptor->destinations)->toBe(['users', 'orders']) + ->and($plain->class)->toBe(PlainListener::class) + ->and($plain->destinations)->toBe([]); }); it('returns an empty list for a directory with no #[EventListener] methods', function () { diff --git a/packages/eda/tests/ScannerFixtures/PlainListener.php b/packages/eda/tests/ScannerFixtures/PlainListener.php new file mode 100644 index 0000000..f6ef5af --- /dev/null +++ b/packages/eda/tests/ScannerFixtures/PlainListener.php @@ -0,0 +1,18 @@ + */ + protected function fireflyProviders(): array + { + return [EdaServiceProvider::class, EdaWiringProvider::class, EdaConsumerServiceProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return ['firefly.eda.provider' => 'memory']; + } + + protected function defineFireflyEnvironment(Application $app): void + { + $this->consumer = new ScriptedEventConsumer([]); + $app->instance(EventConsumer::class, $this->consumer); + $app->instance(EventListenerManifest::class, new EventListenerManifest([ + new EventListenerDescriptor('App\\Listeners\\Orders', 'onOrder', ['order.*'], 0), + ])); + } + + /** + * Run the command with a zero message budget: ConsumerLoop start()s, immediately trips the max-messages + * bound and stop()s, so subscribe() has run and nothing polls. Goes through Kernel::call() rather than + * $this->artisan() because InteractsWithConsole::artisan() is declared `PendingCommand|int` and every call + * site would otherwise need the union handled for PHPStan (firefly/cli's ArtisanAssertions precedent). + * + * @param array $parameters + */ + protected function runConsume(array $parameters = []): int + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->call('firefly:eda:consume', ['--max-messages' => 0] + $parameters); + } + + /** + * Set firefly.eda.destinations on the booted app. Typed here rather than at each call site because + * Container::make('config') is `mixed` to PHPStan and every test would otherwise need its own annotation. + * + * @param array $destinations + */ + protected function setDestinationConfig(array $destinations): void + { + /** @var Repository $config */ + $config = $this->app()->make('config'); + $config->set('firefly.eda.destinations', $destinations); + } +} diff --git a/packages/firefly/composer.json b/packages/firefly/composer.json index 04ed268..cf2868a 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" @@ -15,7 +23,9 @@ "require": { "php": "^8.3", "firefly/actuator": "*@dev", + "firefly/admin": "*@dev", "firefly/autoconfigure": "*@dev", + "firefly/cli": "*@dev", "firefly/config": "*@dev", "firefly/container": "*@dev", "firefly/context": "*@dev", @@ -26,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", @@ -34,8 +45,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/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..d6e5de3 --- /dev/null +++ b/packages/installer/src/CapabilityCatalog.php @@ -0,0 +1,172 @@ + 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 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 + * 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/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/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/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/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/Metrics/CacheMeterRegistry.php b/packages/observability/src/Metrics/CacheMeterRegistry.php new file mode 100644 index 0000000..548ec99 --- /dev/null +++ b/packages/observability/src/Metrics/CacheMeterRegistry.php @@ -0,0 +1,237 @@ +local = new SimpleMeterRegistry; + } + + /** @param array $tags */ + public function counter(string $name, array $tags = []): Counter + { + return $this->local->counter($name, $tags); + } + + /** @param array $tags */ + public function timer(string $name, array $tags = []): Timer + { + return $this->local->timer($name, $tags); + } + + /** + * @param array $tags + * @param callable(): float $supplier + */ + public function gauge(string $name, array $tags, callable $supplier): Gauge + { + return $this->local->gauge($name, $tags, $supplier); + } + + /** @param array $tags */ + public function increment(string $name, array $tags = [], float $amount = 1.0): void + { + $this->local->increment($name, $tags, $amount); + + $id = $this->identity(MeterType::Counter, $name, $tags); + $this->remember($id, MeterType::Counter, $name, $tags); + $this->add($id.':count', (int) round($amount * self::MICROS)); + } + + /** @param array $tags */ + public function record(string $name, array $tags = [], float $seconds = 0.0): void + { + $this->local->record($name, $tags, $seconds); + + $id = $this->identity(MeterType::Timer, $name, $tags); + $this->remember($id, MeterType::Timer, $name, $tags); + $this->add($id.':count', 1); + $this->add($id.':micros', (int) round($seconds * self::MICROS)); + } + + /** @param array $tags */ + public function setGauge(string $name, array $tags, float $value): void + { + $this->local->setGauge($name, $tags, $value); + + $id = $this->identity(MeterType::Gauge, $name, $tags); + $this->remember($id, MeterType::Gauge, $name, $tags); + $this->put($id.':value', $value); + } + + /** + * Every meter any worker has recorded, rebuilt from the store. + * + * @return list + */ + public function meters(): array + { + $meters = []; + + foreach ($this->index() as $id => $entry) { + $type = MeterType::tryFrom($entry['type']); + if ($type === null) { + continue; + } + + $meters[] = match ($type) { + MeterType::Counter => $this->rebuildCounter($id, $entry), + MeterType::Timer => $this->rebuildTimer($id, $entry), + MeterType::Gauge => $this->rebuildGauge($id, $entry), + }; + } + + return $meters; + } + + /** @param array{type: string, name: string, tags: array} $entry */ + private function rebuildCounter(string $id, array $entry): Counter + { + $counter = new Counter($entry['name'], $entry['tags']); + $counter->increment($this->readInt($id.':count') / self::MICROS); + + return $counter; + } + + /** @param array{type: string, name: string, tags: array} $entry */ + private function rebuildTimer(string $id, array $entry): Timer + { + $timer = new Timer($entry['name'], $entry['tags']); + $count = $this->readInt($id.':count'); + $total = $this->readInt($id.':micros') / self::MICROS; + + // Timer accumulates per-sample; replay the total as one sample per recorded call so both count() + // and totalTimeSeconds() come back right. The per-sample values are not retained by design — this + // registry stores aggregates, not a histogram. + if ($count > 0) { + $each = $total / $count; + for ($i = 0; $i < $count; $i++) { + $timer->record($each); + } + } + + return $timer; + } + + /** @param array{type: string, name: string, tags: array} $entry */ + private function rebuildGauge(string $id, array $entry): Gauge + { + $value = $this->cache->get($this->prefix.$id.':value'); + + return new Gauge($entry['name'], $entry['tags'], static fn (): float => is_numeric($value) ? (float) $value : 0.0); + } + + /** + * The identities of every meter written so far. Kept as one small array so meters() needs a single read + * rather than a key scan, which not every cache driver supports. + * + * @return array}> + */ + private function index(): array + { + $index = $this->cache->get($this->prefix.self::INDEX); + + if (! is_array($index)) { + return []; + } + + /** @var array}> $index */ + return $index; + } + + /** @param array $tags */ + private function remember(string $id, MeterType $type, string $name, array $tags): void + { + $index = $this->index(); + if (isset($index[$id])) { + return; + } + + ksort($tags); + $index[$id] = ['type' => $type->value, 'name' => $name, 'tags' => $tags]; + $this->put(self::INDEX, $index); + } + + private function add(string $key, int $amount): void + { + $full = $this->prefix.$key; + + // increment() returns false on a store that has no value yet (and on drivers that cannot increment a + // missing key), so seed and retry rather than losing the sample. + if ($this->cache->increment($full, $amount) === false) { + $this->cache->add($full, 0, $this->ttlSeconds); + $this->cache->increment($full, $amount); + } + } + + private function put(string $key, mixed $value): void + { + $full = $this->prefix.$key; + + if ($this->ttlSeconds === null) { + $this->cache->forever($full, $value); + + return; + } + + $this->cache->put($full, $value, $this->ttlSeconds); + } + + private function readInt(string $key): int + { + $value = $this->cache->get($this->prefix.$key); + + return is_numeric($value) ? (int) $value : 0; + } + + /** @param array $tags */ + private function identity(MeterType $type, string $name, array $tags): string + { + ksort($tags); + + return $type->value.'|'.$name.'|'.md5(serialize($tags)); + } +} diff --git a/packages/observability/src/ObservabilityAutoConfiguration.php b/packages/observability/src/ObservabilityAutoConfiguration.php index 924c232..a0e18bf 100644 --- a/packages/observability/src/ObservabilityAutoConfiguration.php +++ b/packages/observability/src/ObservabilityAutoConfiguration.php @@ -4,6 +4,7 @@ namespace Firefly\Observability; +use Firefly\Config\Config; use Firefly\Container\Attributes\Bean; use Firefly\Container\Attributes\Configuration; use Firefly\Container\Attributes\Order; @@ -11,6 +12,11 @@ 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; use Firefly\Observability\Metrics\NoOpMetricsRecorder; @@ -19,6 +25,7 @@ use Firefly\Observability\Tracing\NoOpTracer; use Firefly\Observability\Tracing\Tracer; use Illuminate\Container\Container; +use Illuminate\Contracts\Cache\Factory; /** * #[Order(500)] is DELIBERATELY below CqrsAutoConfiguration's #[Order(1000)] (§7 risk 4, mirrors @@ -34,12 +41,35 @@ #[Order(500)] final class ObservabilityAutoConfiguration { + /** + * The meter store. In-memory by default; cache-backed when `firefly.observability.metrics.store` names a + * cache store. + * + * SimpleMeterRegistry keeps meters in process memory, which is right for a long-lived worker (Octane, + * roadrunner) and wrong for PHP's usual deployment: under PHP-FPM each request is a fresh process, so a + * scrape of /actuator/metrics or /actuator/prometheus sees only what that scrape's own request recorded — + * which reads as data but is not. Naming a store swaps in CacheMeterRegistry, whose counters and timers + * accumulate across workers through the store's atomic increment. + * + * Opt-in rather than default: a metrics registry that silently begins 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. + */ #[Bean] #[ConditionalOnProperty(name: 'firefly.observability.metrics.enabled', havingValue: 'true', matchIfMissing: true)] #[ConditionalOnMissingBean(MeterRegistry::class)] - public function meterRegistry(): MeterRegistry + public function meterRegistry(Container $container, Config $config): MeterRegistry { - return new SimpleMeterRegistry; + $store = $config->string('firefly.observability.metrics.store', ''); + if ($store === '' || ! $container->bound('cache')) { + return new SimpleMeterRegistry; + } + + /** @var Factory $factory */ + $factory = $container->make('cache'); + $ttl = $config->int('firefly.observability.metrics.ttl', 0); + + return new CacheMeterRegistry($factory->store($store), 'firefly:metrics:', $ttl > 0 ? $ttl : null); } #[Bean] @@ -82,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/Metrics/CacheMeterRegistryTest.php b/packages/observability/tests/Metrics/CacheMeterRegistryTest.php new file mode 100644 index 0000000..4897b07 --- /dev/null +++ b/packages/observability/tests/Metrics/CacheMeterRegistryTest.php @@ -0,0 +1,113 @@ +; each assertion below knows which concrete meter it asked for. */ +function onlyCounter(Repository $store): Counter +{ + $meters = (new CacheMeterRegistry($store))->meters(); + expect($meters)->toHaveCount(1)->and($meters[0])->toBeInstanceOf(Counter::class); + assert($meters[0] instanceof Counter); + + return $meters[0]; +} + +it('accumulates counters across separate registry instances', function () { + $store = sharedStore(); + + (new CacheMeterRegistry($store))->increment('http.requests', ['route' => '/'], 3); + (new CacheMeterRegistry($store))->increment('http.requests', ['route' => '/'], 2); + + $counter = onlyCounter($store); + + expect($counter->count())->toBe(5.0) + ->and($counter->name())->toBe('http.requests') + ->and($counter->tags())->toBe(['route' => '/']); +}); + +it('accumulates timer count and total duration across instances', function () { + $store = sharedStore(); + + (new CacheMeterRegistry($store))->record('http.latency', [], 0.100); + (new CacheMeterRegistry($store))->record('http.latency', [], 0.300); + + $meters = (new CacheMeterRegistry($store))->meters(); + expect($meters[0])->toBeInstanceOf(Timer::class); + assert($meters[0] instanceof Timer); + + expect($meters[0]->count())->toBe(2) + ->and(round($meters[0]->totalTimeSeconds(), 6))->toBe(0.4); +}); + +it('treats a gauge as a snapshot — last writer wins', function () { + $store = sharedStore(); + + (new CacheMeterRegistry($store))->setGauge('queue.depth', [], 12.0); + (new CacheMeterRegistry($store))->setGauge('queue.depth', [], 7.0); + + $meters = (new CacheMeterRegistry($store))->meters(); + expect($meters[0])->toBeInstanceOf(Gauge::class); + assert($meters[0] instanceof Gauge); + + expect($meters[0]->value())->toBe(7.0); +}); + +it('keeps distinct tag sets as distinct meters', function () { + $store = sharedStore(); + $registry = new CacheMeterRegistry($store); + + $registry->increment('http.requests', ['route' => '/a']); + $registry->increment('http.requests', ['route' => '/b'], 4); + + $byTag = []; + foreach ((new CacheMeterRegistry($store))->meters() as $meter) { + expect($meter)->toBeInstanceOf(Counter::class); + assert($meter instanceof Counter); + $byTag[$meter->tags()['route']] = $meter->count(); + } + + expect($byTag)->toBe(['/a' => 1.0, '/b' => 4.0]); +}); + +it('records tag order-insensitively so the same meter is not split in two', function () { + $store = sharedStore(); + + (new CacheMeterRegistry($store))->increment('jobs', ['b' => '2', 'a' => '1']); + (new CacheMeterRegistry($store))->increment('jobs', ['a' => '1', 'b' => '2']); + + expect(onlyCounter($store)->count())->toBe(2.0); +}); + +it('returns no meters before anything has been recorded', function () { + expect((new CacheMeterRegistry(sharedStore()))->meters())->toBe([]); +}); + +it('still hands back live in-process meters from the factory methods', function () { + $registry = new CacheMeterRegistry(sharedStore()); + + $counter = $registry->counter('local', []); + $counter->increment(2); + + expect($registry->counter('local', []))->toBe($counter) + ->and($counter->count())->toBe(2.0); +}); 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/.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..12d9059 --- /dev/null +++ b/packages/openapi/README.md @@ -0,0 +1,157 @@ +# 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 + +`/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.** + +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. + +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. + +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 ` + + + + + HTML, [ + '__TITLE__' => $this->escape($this->title), + '__SPEC_URL__' => $this->json($specUrl), + '__ASSETS__' => $this->escape($assetBase), + ]); + } + + private function builtIn(string $specUrl): string + { + // NOWDOC, not heredoc. The page embeds a JavaScript application, and heredoc interpolation would + // treat every `$ref`, `$schema` and `$1` in that script as a PHP variable — which is exactly what + // happened: `$ref` silently became the empty string and $ref resolution stopped working. A nowdoc + // takes the script verbatim and the two real substitutions are made explicitly below. + return strtr(<<<'HTML' + + + + + + + __TITLE__ — API reference + + + + +
+
+ __TITLE__API reference + + OpenAPI 3.1 + +
+ +
Loading the document…
+
+ + + + HTML, [ + '__TITLE__' => $this->escape($this->title), + '__SPEC_URL__' => $this->json($specUrl), + ]); + } + + private function swaggerUiFromCdn(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/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 @@ + */ + #[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 @@ + + */ + #[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 + */ + #[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 + */ + #[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 + */ + #[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 @@ + [ + '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', 'builtin'))->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/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 @@ + + */ + #[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 + */ + #[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 + */ + #[GetMapping('/barcode/{code}')] + public function byBarcode(#[PathVariable] string $code): array + { + return ['code' => $code]; + } + + /** @return array */ + #[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 @@ + */ + #[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 @@ + + */ +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 $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 $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 $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>> $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> $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> $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>> $components */ + $components = annotatedDocument()['components']; + /** @var array> $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>> $components */ + $components = annotatedDocument()['components']; + /** @var array> $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 $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 @@ +" 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 */'); + + 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 $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 @@ + $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 $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 $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 @@ + + */ +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 */` 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 $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>> $components */ + $components = documentedDocument()['components']; + /** @var array $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>> $components */ + $components = documentedDocument()['components']; + /** @var array> $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>> $components */ + $components = documentedDocument()['components']; + /** @var array> $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/Generator/HtmlRouteTest.php b/packages/openapi/tests/Generator/HtmlRouteTest.php new file mode 100644 index 0000000..7017363 --- /dev/null +++ b/packages/openapi/tests/Generator/HtmlRouteTest.php @@ -0,0 +1,67 @@ + $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/NestedSchemaTest.php b/packages/openapi/tests/Generator/NestedSchemaTest.php new file mode 100644 index 0000000..ef57ec2 --- /dev/null +++ b/packages/openapi/tests/Generator/NestedSchemaTest.php @@ -0,0 +1,291 @@ +` 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('types a list of scalars identically down the compiled path and the reflected one', function () { + // `list` 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', + ); + + 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 () { + // `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` 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`; 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/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/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 @@ + 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, 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, required: list} $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>} $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` 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` 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>}> $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/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 @@ + */ + #[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 @@ + $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 @@ + $lines The lines to order, at least one. + * @param list $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 @@ +` 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..faca4ff --- /dev/null +++ b/packages/openapi/tests/NestedFixture/LineOptionRequest.php @@ -0,0 +1,31 @@ + 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. 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 $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 @@ + + */ + #[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 @@ + $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/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([]); +}); + +/** + * 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 $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/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 @@ + $shipments + * @param list $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, 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 @@ +} + */ + #[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 newest first + */ + #[GetMapping('/{reference}/shipments')] + public function shipments(#[PathVariable] string $reference): array + { + return []; + } + + /** + * Total declared value per currency. + * + * @return array + */ + #[GetMapping('/totals')] + public function totals(): array + { + return []; + } + + /** + * Books a consignment. + * + * @return array + */ + #[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')] + 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 @@ + $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/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/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 @@ +` 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` is a JSON array and `array` 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', $ref))->toBe(['type' => 'array', 'items' => ['type' => 'integer']]) + ->and(DocType::schema('array', $ref))->toBe(['type' => 'array', 'items' => ['type' => 'integer']]) + ->and(DocType::schema('array', $ref))->toBe(['type' => 'object', 'additionalProperties' => ['type' => 'integer']]) + ->and(DocType::schema('list>', $ref))->toBe([ + 'type' => 'array', + 'items' => ['type' => 'array', 'items' => ['type' => 'integer']], + ]) + ->and(DocType::schema('non-empty-list', $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 $context */ + $context = new ReflectionClass(ConsignmentController::class); + + expect(DocType::schema('Money', $ref, $context))->toBe(['$ref' => '#/components/schemas/Money']) + ->and(DocType::schema('list', $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', $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} $schema */ + expect($prose)->toBe('one page of results') + ->and($schema['properties'])->toHaveKeys(['page', 'size']); + + /** @var ReflectionClass $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/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..8c701ac --- /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..eaecdb1 --- /dev/null +++ b/packages/openapi/tests/Support/FixtureDocument.php @@ -0,0 +1,188 @@ + + */ + public static function psr4(string $directory = 'Fixture'): array + { + return ['Firefly\\OpenApi\\Tests\\'.$directory.'\\' => dirname(__DIR__).'/'.$directory]; + } + + public static function routes(string $directory = 'Fixture'): RouteManifest + { + return new RouteManifest((new RouteScanner)->scan(self::psr4($directory))); + } + + public static function constraints(string $directory = 'Fixture'): ConstraintManifest + { + return ConstraintManifest::fromArray((new ConstraintManifestCompiler)->toArray(AppScan::classes(self::psr4($directory)))); + } + + public static function schemas(string $directory = 'Fixture'): DtoSchemaFactory + { + return new DtoSchemaFactory(self::constraints($directory), new ConstraintSchemaMapper); + } + + public static function generator(?OpenApiProperties $properties = null): OpenApiGenerator + { + return new OpenApiGenerator( + self::routes(), + $properties ?? self::properties(), + new OperationFactory(self::schemas()), + ); + } + + /** + * 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 $document + * @return array + */ + 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 $operation */ + $operation = is_array($node) ? $node : []; + + return $operation; + } + + /** + * @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..4702771 --- /dev/null +++ b/packages/openapi/tests/Web/ViewerPageTest.php @@ -0,0 +1,82 @@ +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 + // 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') + ->and($external[0])->toBe([]) + ->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', 'builtin'); + + 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('/')") + // 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 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($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($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"', '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'); + + 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/resilience/src/Bulkhead.php b/packages/resilience/src/Bulkhead.php index 6cfe14e..dc8f921 100644 --- a/packages/resilience/src/Bulkhead.php +++ b/packages/resilience/src/Bulkhead.php @@ -6,20 +6,71 @@ use Firefly\Resilience\Exception\BulkheadFullException; use Firefly\Resilience\Store\ResilienceStore; +use Throwable; /** - * A cache-backed distributed semaphore. acquire() increments the shared permit counter FIRST, then rejects - * (decrementing back) if it exceeded maxConcurrent — so two racing workers can never both slip past the - * limit (increment is atomic). call() acquires on entry and releases in finally; maxWait > 0 briefly polls - * for a freed permit before rejecting. + * A cache-backed distributed semaphore. call() takes a permit on entry and returns it in finally; maxWait > 0 + * briefly polls for a freed permit before rejecting with BulkheadFullException. + * + * PERMITS ARE EXPIRING LEASES, NOT A COUNTER. The original implementation modelled the semaphore as one + * atomic integer: acquire() incremented it and rejected (decrementing back) when the result exceeded + * maxConcurrent, release() decremented. That is race-free but it is not crash-safe, and it had two defects + * of the same family: + * + * 1. A worker killed between acquire() and release() (OOM kill, deploy SIGKILL, fatal error) left the + * counter incremented FOREVER — no `finally` runs in a process that no longer exists, and nothing in + * the design could ever tell a held permit from an abandoned one. Every crash permanently shrank the + * bulkhead by one, and after maxConcurrent crashes it rejected 100% of traffic until an operator + * flushed the cache by hand. The bulkhead meant to protect the dependency became the outage. + * 2. A release() with no matching acquire() drove the shared counter NEGATIVE, manufacturing capacity out + * of nothing: two stray releases on a max-concurrent-1 bulkhead left it at -2 and the next two callers + * both slipped past the limit — precisely the over-admission the primitive exists to prevent. + * + * The permit set is therefore a list of expiry timestamps under one store record, pruned on every read, and + * release() returns a permit THIS instance actually holds (a per-object LIFO of the leases it took, so + * nested acquisitions unwind correctly and an unmatched release is a no-op). + * + * Three trade-offs were taken deliberately: + * + * * A lease that outlives permitTtl is reclaimed while its holder may still be running, so a call slower + * than the TTL can be joined by one extra caller — bounded, transient over-admission instead of + * 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). + * * acquire()/release() now cost a store mutex round trip instead of a single atomic INCR. That is the + * price of being able to distinguish a live permit from a dead one at all; the operation is still one + * round trip against the same cache, and correctness under crash beats a marginally cheaper counter. + * * On an exotic cache store with no LockProvider, ResilienceStore::withLock degrades to "no critical + * section" (see CacheResilienceStore), so the read-modify-write can over-admit under a genuine race + * where the old INCR could not. Every first-party Laravel store implements LockProvider, and + * CircuitBreaker and RateLimiter already stake their correctness on exactly this seam. */ final class Bulkhead { + /** + * How long the store mutex guarding the permit set is HELD — not how long a caller waits to take it (that + * budget is the store's, see CacheResilienceStore::$lockBlockTimeout). The section is one read and one + * write; the TTL exists only so a worker killed inside it leaves a self-expiring lock rather than a + * tombstone every later worker deadlocks on. + */ + private const LOCK_TTL = 5.0; + + /** + * The leases THIS instance currently holds, newest last. + * + * release() pops from here rather than blindly decrementing a shared number, which is what makes an + * unmatched release a no-op and lets nested acquire()/release() pairs on the same registry-memoized + * instance unwind in LIFO order. + * + * @var list + */ + private array $held = []; + public function __construct( private readonly string $key, private readonly ResilienceStore $store, private readonly int $maxConcurrent = 10, private readonly float $maxWait = 0.0, + private readonly float $permitTtl = 60.0, ) {} public function acquire(): bool @@ -27,11 +78,9 @@ public function acquire(): bool $deadline = microtime(true) + $this->maxWait; do { - $count = $this->store->increment($this->key); - if ($count <= $this->maxConcurrent) { + if ($this->claim()) { return true; } - $this->store->decrement($this->key); if ($this->maxWait <= 0.0) { return false; } @@ -43,7 +92,21 @@ public function acquire(): bool public function release(): void { - $this->store->decrement($this->key); + $lease = array_pop($this->held); + if ($lease === null) { + return; + } + + $this->store->withLock($this->key, self::LOCK_TTL, function () use ($lease): void { + $permits = $this->livingPermits(); + + $index = array_search($lease, $permits, true); + if ($index !== false) { + unset($permits[$index]); + } + + $this->savePermits(array_values($permits)); + }); } /** @@ -61,7 +124,87 @@ public function call(callable $callback): mixed try { return $callback(); } finally { - $this->release(); + try { + $this->release(); + } catch (Throwable) { + // Best effort, and deliberately silent. A throw out of a finally REPLACES whatever the block + // was about to produce, so an unguarded release() against a store too contended to answer + // would turn a successful guarded call into an infrastructure 503, or swap the caller's own + // exception for one the application never raised — in both cases the guarded work has + // already happened and its outcome would simply be lost. Giving up costs one permit until + // its lease expires, which is precisely the abandoned-permit case permitTtl already exists + // to heal. release() itself still propagates, so a caller driving acquire()/release() by + // hand is told when the store is unwell. + } + } + } + + /** + * One atomic attempt at a permit: prune the dead, count the living, take a lease if there is room. + * + * The pruning is persisted even on rejection, so a bulkhead saturated entirely by crashed holders does + * not need a successful acquisition to clean itself up. + */ + private function claim(): bool + { + return (bool) $this->store->withLock($this->key, self::LOCK_TTL, function (): bool { + $permits = $this->livingPermits(); + + if (count($permits) >= $this->maxConcurrent) { + $this->savePermits($permits); + + return false; + } + + $lease = microtime(true) + $this->permitTtl; + $permits[] = $lease; + $this->savePermits($permits); + $this->held[] = $lease; + + return true; + }); + } + + /** + * The permits still within their lease. + * + * A record written by an older deploy is a bare integer counter, not an array, so it reads as "no permits + * held" — a deliberate one-time reset that heals a bulkhead already drained by leaked counts instead of + * carrying the leak across the deploy that fixes it. + * + * @return list + */ + private function livingPermits(): array + { + $raw = $this->store->get($this->key); + $permits = is_array($raw) ? ($raw['permits'] ?? null) : null; + if (! is_array($permits)) { + return []; } + + $now = microtime(true); + + $living = []; + foreach ($permits as $lease) { + if ((is_int($lease) || is_float($lease)) && (float) $lease > $now) { + $living[] = (float) $lease; + } + } + + return $living; + } + + /** + * Persists the permit set, giving the record itself the same TTL as a lease. + * + * Every write refreshes it, so the record only expires once nothing has touched the bulkhead for a full + * TTL — at which point every lease inside it has expired anyway. It is a second, driver-level line of + * defence: even a store the framework never revisits eventually forgets an abandoned permit set. + * + * @param list $permits + */ + private function savePermits(array $permits): void + { + $this->store->put($this->key, ['permits' => $permits], $this->permitTtl > 0.0 ? $this->permitTtl : null); } } diff --git a/packages/resilience/src/CircuitBreaker.php b/packages/resilience/src/CircuitBreaker.php index 305f93f..f6d5b51 100644 --- a/packages/resilience/src/CircuitBreaker.php +++ b/packages/resilience/src/CircuitBreaker.php @@ -10,12 +10,33 @@ /** * A cache-backed circuit breaker. The whole state — CLOSED/OPEN/HALF_OPEN, a bounded window of recent - * outcomes, the open timestamp, and the half-open probe count — lives in ONE store record keyed by $key, so - * a trip on one FPM worker is visible to the next. Every transition runs inside store->withLock, making the - * read-decide-write atomic (no over-admission race). CLOSED opens once the window shows failureThreshold - * failures (or, when failureRateThreshold is set, that ratio over a full window); OPEN rejects until + * outcomes, the open timestamp, and the outstanding half-open probe permits — lives in ONE store record + * keyed by $key, so a trip on one FPM worker is visible to the next. Every transition runs inside + * store->withLock, making the read-decide-write atomic (no over-admission race). CLOSED opens once the + * window shows failureThreshold failures (or, when failureRateThreshold is set, that ratio over a full + * window), never before minimumNumberOfCalls outcomes have accumulated; OPEN rejects until * waitDurationInOpen elapses, then admits up to halfOpenMaxCalls probes; a probe success closes, a probe * failure re-opens. + * + * HALF_OPEN PROBE PERMITS ARE LEASES, NOT COUNTERS. The breaker used to track the probe budget as a plain + * `halfOpenCalls` integer that admit() incremented and only onSuccess()/onFailure() ever reset. Two ordinary + * events left that counter incremented with nothing able to decrement it again: + * + * 1. A probe that threw an exception NOT listed in recordOn. call() consulted records() and, for anything + * it did not record, rethrew without touching the breaker at all — so the probe slot was consumed and + * never returned. + * 2. A worker that died mid-probe (OOM kill, deploy SIGKILL, fatal error). No `finally` runs in a process + * that no longer exists, so the slot was consumed by a caller that will never come back. + * + * Either way the breaker sat in HALF_OPEN with halfOpenCalls === halfOpenMaxCalls forever: admit() rejected + * every subsequent call with CircuitBreakerOpenException, and the ONLY writer that reset the counter was the + * OPEN->HALF_OPEN transition — which could never fire again, because the breaker was no longer OPEN. A + * permanently-wedged breaker fails 100% of traffic to a healthy dependency until an operator flushes the + * cache; it is strictly worse than having no breaker at all. Each probe permit therefore carries an EXPIRY + * (halfOpenProbeTimeout) and admit() prunes expired permits before counting, so an abandoned probe self-heals + * after a bounded delay, and call() explicitly returns the permit when the probe throws something recordOn + * ignores — an ignored exception is neither a success nor a failure (Resilience4j's semantics), so the + * episode stays HALF_OPEN and simply regains its slot. */ final class CircuitBreaker { @@ -25,6 +46,16 @@ final class CircuitBreaker private const HALF_OPEN = 'half_open'; + /** + * How long the store mutex guarding a transition is HELD — not how long a caller waits to take it. That + * waiting budget belongs to the store (see CacheResilienceStore::$lockBlockTimeout), because it is a + * fail-fast policy decision, not a property of the critical section. A transition is one read, a handful + * of array operations and one write, so five seconds of TTL is pure headroom: it exists so that a worker + * killed inside the section leaves a lock that self-expires instead of a tombstone every later worker + * deadlocks on. + */ + private const LOCK_TTL = 5.0; + /** @param list> $recordOn */ public function __construct( private readonly string $key, @@ -35,6 +66,8 @@ public function __construct( private readonly float $waitDurationInOpen = 30.0, private readonly int $halfOpenMaxCalls = 1, private readonly array $recordOn = [Throwable::class], + private readonly int $minimumNumberOfCalls = 0, + private readonly float $halfOpenProbeTimeout = 30.0, ) {} /** @@ -52,6 +85,8 @@ public function call(callable $callback): mixed } catch (Throwable $e) { if ($this->records($e)) { $this->onFailure(); + } else { + $this->releaseProbe(); } throw $e; } @@ -61,30 +96,55 @@ public function call(callable $callback): mixed return $result; } + /** + * The EFFECTIVE state right now, which is not always the state last written to the store. + * + * A breaker that opened and then sat idle keeps `open` in its record until some caller's admit() performs + * the OPEN->HALF_OPEN transition — the transition is lazy, driven by traffic, and there is no timer to + * fire it. Every read-only observer (the actuator gauge, an operator, the observability + * resilience_circuit_breaker_state metric) therefore saw `open` indefinitely for a breaker whose wait + * window had long since elapsed and which would in fact admit the very next call as a probe. Dashboards + * showed a hard-down dependency that was not down, and paging on "breaker still open after N minutes" + * fired on breakers that were only quiet. Reporting the state admit() WOULD decide keeps the gauge and + * the state machine telling the same story, and — because this method must stay a pure read that an + * observer can call at any frequency — it computes the answer without writing the transition back. + */ public function state(): string { - return $this->record()['state']; + $record = $this->record(); + + if ($record['state'] === self::OPEN && $this->openWindowElapsed($record['openedAt'])) { + return self::HALF_OPEN; + } + + return $record['state']; } private function admit(): void { - $this->store->withLock($this->key, 5.0, function (): void { + $this->store->withLock($this->key, self::LOCK_TTL, function (): void { $record = $this->record(); if ($record['state'] === self::OPEN) { - if ((microtime(true) - $record['openedAt']) >= $this->waitDurationInOpen) { + if ($this->openWindowElapsed($record['openedAt'])) { $record['state'] = self::HALF_OPEN; - $record['halfOpenCalls'] = 0; + $record['halfOpenProbes'] = []; } else { throw new CircuitBreakerOpenException; } } if ($record['state'] === self::HALF_OPEN) { - if ($record['halfOpenCalls'] >= $this->halfOpenMaxCalls) { + // Prune first: permits whose lease has run out belong to callers that never came back, and + // counting them would be counting ghosts. This is the self-heal for a worker killed mid-probe. + $probes = $this->livingProbes($record['halfOpenProbes']); + + if (count($probes) >= $this->halfOpenMaxCalls) { throw new CircuitBreakerOpenException; } - $record['halfOpenCalls']++; + + $probes[] = microtime(true) + $this->halfOpenProbeTimeout; + $record['halfOpenProbes'] = $probes; } $this->save($record); @@ -93,10 +153,12 @@ private function admit(): void private function onSuccess(): void { - $this->store->withLock($this->key, 5.0, function (): void { + $this->store->withLock($this->key, self::LOCK_TTL, function (): void { $record = $this->record(); if ($record['state'] === self::HALF_OPEN) { + // A successful probe closes the breaker outright: a fresh record drops the outcome window, + // the open timestamp and every outstanding probe permit in one write. $this->save($this->fresh()); return; @@ -109,7 +171,7 @@ private function onSuccess(): void private function onFailure(): void { - $this->store->withLock($this->key, 5.0, function (): void { + $this->store->withLock($this->key, self::LOCK_TTL, function (): void { $record = $this->record(); $record['outcomes'] = $this->trim([...$record['outcomes'], false]); @@ -117,13 +179,108 @@ private function onFailure(): void $record['state'] = self::OPEN; $record['openedAt'] = microtime(true); $record['outcomes'] = []; + // Re-opening ends the half-open episode, so no permit from it may survive into the next one: + // a leftover lease would silently eat part of the NEXT episode's probe budget. + $record['halfOpenProbes'] = []; } $this->save($record); }); } - /** @return array{state: string, openedAt: float, halfOpenCalls: int, outcomes: list} */ + /** + * Returns the HALF_OPEN probe permit taken by admit() WITHOUT recording an outcome. + * + * This is the release path for an exception outside recordOn — the case that used to wedge the breaker + * permanently (see the class docblock). Such an exception is deliberately invisible to the breaker's + * statistics: it is not evidence the dependency is failing, so it must not re-open the breaker, and it is + * not evidence the dependency recovered, so it must not close it. The one thing it MUST do is give back + * the slot it took, leaving the breaker in the well-defined state it was already in: still HALF_OPEN, + * still waiting for a probe that produces a real verdict. + * + * The permit removed is the newest live one. admit() stamps each permit with `now + halfOpenProbeTimeout`, + * so permits are ordered by claim time and the newest is the one this caller just took; dropping the + * OLDEST instead would hand a slot back on behalf of a probe that is still running and let the episode + * over-admit. + */ + private function releaseProbe(): void + { + try { + $this->releasedProbe(); + } catch (Throwable) { + // Best effort, and deliberately silent. This runs inside call()'s catch block, so anything that + // escapes here REPLACES the exception the caller actually threw: a store too contended to answer + // would turn an exception the breaker was configured to IGNORE into an infrastructure 503 the + // application never raised, discarding the real error on the way. Giving up costs one probe slot + // until its lease expires, which is exactly the abandoned-permit case halfOpenProbeTimeout + // already exists to heal — a bounded, self-correcting loss, against destroying the caller's own + // failure. The store is the only thing that can throw here, and it is the thing that is unwell. + } + } + + /** The write behind releaseProbe(), separated so the swallow above wraps nothing but the store call. */ + private function releasedProbe(): void + { + $this->store->withLock($this->key, self::LOCK_TTL, function (): void { + $record = $this->record(); + + if ($record['state'] !== self::HALF_OPEN) { + return; + } + + $probes = $this->livingProbes($record['halfOpenProbes']); + if ($probes !== []) { + $index = array_search(max($probes), $probes, true); + if ($index !== false) { + unset($probes[$index]); + } + } + + $record['halfOpenProbes'] = array_values($probes); + $this->save($record); + }); + } + + private function openWindowElapsed(float $openedAt): bool + { + return (microtime(true) - $openedAt) >= $this->waitDurationInOpen; + } + + /** + * Drops probe permits whose lease has expired. + * + * A permit is held with `>` rather than `>=` on purpose: it makes halfOpenProbeTimeout: 0.0 mean "do not + * hold probe slots at all" (every half-open call is admitted), which is the only sane reading of a + * zero-length lease and keeps the degenerate configuration from wedging exactly the way the old counter + * did. Any positive value bounds the wedge a dead worker can cause to that many seconds. + * + * @param list $probes + * @return list + */ + private function livingProbes(array $probes): array + { + $now = microtime(true); + + $living = []; + foreach ($probes as $deadline) { + if ($deadline > $now) { + $living[] = $deadline; + } + } + + return $living; + } + + /** + * Reads the persisted record, tolerating anything the cache hands back. + * + * Note the upgrade path: a record written by an older deploy carries `halfOpenCalls` (an int) and no + * `halfOpenProbes`, so it is read as "no outstanding probes". That is deliberate — a breaker already + * wedged by the old counter heals the moment this version reads its record, rather than carrying the + * wedge across the deploy that fixes it. + * + * @return array{state: string, openedAt: float, halfOpenProbes: list, outcomes: list} + */ private function record(): array { $raw = $this->store->get($this->key); @@ -133,23 +290,22 @@ private function record(): array $state = $raw['state'] ?? null; $openedAt = $raw['openedAt'] ?? null; - $halfOpenCalls = $raw['halfOpenCalls'] ?? null; return [ 'state' => is_string($state) ? $state : self::CLOSED, 'openedAt' => is_float($openedAt) ? $openedAt : 0.0, - 'halfOpenCalls' => is_int($halfOpenCalls) ? $halfOpenCalls : 0, + 'halfOpenProbes' => $this->floatList($raw['halfOpenProbes'] ?? []), 'outcomes' => $this->boolList($raw['outcomes'] ?? []), ]; } - /** @return array{state: string, openedAt: float, halfOpenCalls: int, outcomes: list} */ + /** @return array{state: string, openedAt: float, halfOpenProbes: list, outcomes: list} */ private function fresh(): array { - return ['state' => self::CLOSED, 'openedAt' => 0.0, 'halfOpenCalls' => 0, 'outcomes' => []]; + return ['state' => self::CLOSED, 'openedAt' => 0.0, 'halfOpenProbes' => [], 'outcomes' => []]; } - /** @param array{state: string, openedAt: float, halfOpenCalls: int, outcomes: list} $record */ + /** @param array{state: string, openedAt: float, halfOpenProbes: list, outcomes: list} $record */ private function save(array $record): void { $this->store->put($this->key, $record); @@ -164,22 +320,52 @@ private function trim(array $outcomes): array return array_slice($outcomes, -$this->windowSize); } - /** @param list $outcomes */ + /** + * Decides whether the CLOSED window has seen enough failure to trip. + * + * minimumNumberOfCalls is the statistical floor Resilience4j calls the same thing: below it the window + * holds too little evidence to justify taking a dependency out of service. Without one, a breaker + * configured with failure-threshold 1 trips on the first failure a fresh window ever sees — a single + * blip on a low-traffic endpoint blackholes the dependency for wait-duration-in-open, and because a + * probe failure re-opens immediately, one flaky call per wait window is enough to keep it open forever. + * + * @param list $outcomes + */ private function shouldOpen(array $outcomes): bool { + $recorded = count($outcomes); + + if ($recorded < $this->minimumCalls()) { + return false; + } + $failures = count(array_filter($outcomes, static fn (bool $ok): bool => $ok === false)); if ($this->failureRateThreshold !== null) { - if (count($outcomes) < $this->windowSize) { + if ($recorded < $this->windowSize) { return false; } - return ($failures / count($outcomes)) >= $this->failureRateThreshold; + return ($failures / $recorded) >= $this->failureRateThreshold; } return $failures >= $this->failureThreshold; } + /** + * The minimum clamped to what the window can physically hold. + * + * The outcome window is trimmed to windowSize, so a minimumNumberOfCalls above it could never be reached + * and the breaker would never open at all. Clamping rather than throwing is the deliberate choice: this + * runs on the hot path of every recorded failure, and the failure mode of a config typo must be "the + * breaker still protects you" rather than "protection is silently off" or "the guarded call now throws a + * configuration error instead of the real one". + */ + private function minimumCalls(): int + { + return max(0, min($this->minimumNumberOfCalls, $this->windowSize)); + } + /** @return list */ private function boolList(mixed $value): array { @@ -195,6 +381,23 @@ private function boolList(mixed $value): array return $out; } + /** @return list */ + private function floatList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $out = []; + foreach ($value as $entry) { + if (is_int($entry) || is_float($entry)) { + $out[] = (float) $entry; + } + } + + return $out; + } + private function records(Throwable $e): bool { foreach ($this->recordOn as $type) { diff --git a/packages/resilience/src/ResilienceAutoConfiguration.php b/packages/resilience/src/ResilienceAutoConfiguration.php index d2d3ab3..d1109c3 100644 --- a/packages/resilience/src/ResilienceAutoConfiguration.php +++ b/packages/resilience/src/ResilienceAutoConfiguration.php @@ -24,9 +24,9 @@ final class ResilienceAutoConfiguration { #[Bean] #[ConditionalOnMissingBean(ResilienceStore::class)] - public function resilienceStore(Repository $cache): ResilienceStore + public function resilienceStore(Repository $cache, Config $config): ResilienceStore { - return new CacheResilienceStore($cache); + return new CacheResilienceStore($cache, $this->lockBlockTimeout($config)); } #[Bean] @@ -35,4 +35,26 @@ public function resilienceRegistry(Config $config, ResilienceStore $store): Resi { return ResilienceRegistry::fromConfig($config, $store); } + + /** + * How long a resilience primitive waits for the shared state mutex before failing fast, from + * `firefly.resilience.store.lock-block-timeout` (a duration string such as `250ms`, or a bare number of + * seconds). + * + * This is exposed as configuration rather than left hardcoded because the right answer depends on the + * cache driver: an in-process 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 + * previous hardcoded five seconds was the worst of both — long enough to turn a contended fail-fast rate + * limiter into a five-second stall on an FPM worker, and applied identically to every deployment. + */ + private function lockBlockTimeout(Config $config): float + { + $value = $config->get('firefly.resilience.store.lock-block-timeout'); + + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + return is_string($value) ? Duration::parse($value) : CacheResilienceStore::DEFAULT_LOCK_BLOCK_TIMEOUT; + } } diff --git a/packages/resilience/src/ResilienceRegistry.php b/packages/resilience/src/ResilienceRegistry.php index 55d32ba..1f7c387 100644 --- a/packages/resilience/src/ResilienceRegistry.php +++ b/packages/resilience/src/ResilienceRegistry.php @@ -69,6 +69,8 @@ public function circuitBreaker(string $name): CircuitBreaker waitDurationInOpen: $this->seconds($c, 'wait-duration-in-open', 30.0), halfOpenMaxCalls: $this->int($c, 'half-open-max-calls', 1), recordOn: $this->classList($c, 'record-on'), + minimumNumberOfCalls: $this->int($c, 'minimum-number-of-calls', 0), + halfOpenProbeTimeout: $this->seconds($c, 'half-open-probe-timeout', 30.0), ); } @@ -90,6 +92,7 @@ public function bulkhead(string $name): Bulkhead store: $this->store, maxConcurrent: $this->int($c = $this->instance('bulkhead', $name), 'max-concurrent', 10), maxWait: $this->seconds($c, 'max-wait', 0.0), + permitTtl: $this->seconds($c, 'permit-ttl', 60.0), ); } diff --git a/packages/resilience/src/Store/CacheResilienceStore.php b/packages/resilience/src/Store/CacheResilienceStore.php index 2a884b6..846d283 100644 --- a/packages/resilience/src/Store/CacheResilienceStore.php +++ b/packages/resilience/src/Store/CacheResilienceStore.php @@ -4,6 +4,8 @@ namespace Firefly\Resilience\Store; +use Firefly\Kernel\Exception\Infrastructure\ServiceUnavailableException; +use Illuminate\Contracts\Cache\Lock; use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Contracts\Cache\Repository; @@ -12,10 +14,57 @@ * (add/increment/decrement), and withLock uses the store's atomic lock so a token-bucket or breaker * transition is a genuine critical section. State persists across FPM requests whenever the driver does * (file/database/redis); with the array driver it is per-request (fine for tests/single-shot). + * + * withLock separates two numbers the original implementation conflated, which is what made a fail-fast + * primitive block for seconds and then blow up as a 500: + * + * * $ttlSeconds — how long the mutex is HELD once taken. It bounds the damage of a worker dying inside the + * critical section: the lock self-expires instead of leaving a tombstone. Callers pass seconds of + * headroom over a section that is really microseconds of work. + * * $lockBlockTimeout — how long a caller is willing to WAIT to take the mutex. This is a fail-fast policy + * decision that belongs to the store, not to the pattern, and it must be SMALL. + * + * The old code passed the same number for both (`$store->lock($key, $seconds)->block($seconds, ...)`) and + * every caller passed 5.0. A RateLimiter configured with timeout: 0 — i.e. "reject instantly rather than make + * the caller wait" — would therefore sit for five seconds on a contended key, holding an FPM worker the whole + * time, and then throw Illuminate\Contracts\Cache\LockTimeoutException: a plain \Exception that no framework + * error mapper recognises, so it surfaced to the client as a bare HTTP 500. Under exactly the load the rate + * limiter exists to shed, the limiter became a latency amplifier and an error source of its own. + * + * The wait is now its own configurable budget, defaulting to DEFAULT_LOCK_BLOCK_TIMEOUT, and exhausting it + * raises ServiceUnavailableException — a kernel infrastructure exception that renders as 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" and is a status a caller, a load balancer and an SLO dashboard + * all already know how to read. */ final class CacheResilienceStore implements ResilienceStore { - public function __construct(private readonly Repository $cache) {} + /** + * How long a caller waits for the state mutex before giving up, in seconds. + * + * Half a second is chosen for a fail-fast primitive: the critical section it guards is a single cache + * read plus a single cache write, so anything beyond a few milliseconds means real contention, and the + * poll interval below gives roughly a hundred attempts inside the budget — generous for a legitimate + * hand-off between two workers, and an order of magnitude below the five seconds that made the old + * behaviour indistinguishable from a hang. + */ + public const DEFAULT_LOCK_BLOCK_TIMEOUT = 0.5; + + /** + * How long to sleep between acquisition attempts, in microseconds. + * + * Illuminate\Cache\Lock::block() is deliberately not used here: its wait budget is documented as an int + * number of seconds and it sleeps a fixed 250ms between attempts, so it cannot express a sub-second + * budget at all — a 0.5s budget would collapse into "one attempt, then throw", and a 1s budget into + * "three attempts, spending most of a second asleep". Polling at 5ms lets a genuine hand-off be picked up + * almost immediately while still bounding the wait precisely. + */ + private const LOCK_POLL_MICROSECONDS = 5000; + + public function __construct( + private readonly Repository $cache, + private readonly float $lockBlockTimeout = self::DEFAULT_LOCK_BLOCK_TIMEOUT, + ) {} public function get(string $key): mixed { @@ -59,8 +108,48 @@ public function withLock(string $key, float $ttlSeconds, callable $callback): mi return $callback(); } - $seconds = (int) max(1, ceil($ttlSeconds > 0 ? $ttlSeconds : 5.0)); + $lock = $store->lock($key.':lock', (int) max(1, ceil($ttlSeconds > 0 ? $ttlSeconds : 5.0))); + $deadline = microtime(true) + max(0.0, $this->lockBlockTimeout); + + while (true) { + if ((bool) $lock->get()) { + return $this->runAndRelease($lock, $callback); + } + + // Checked AFTER the attempt so a zero budget still means "try once", not "never try". + if (microtime(true) >= $deadline) { + throw new ServiceUnavailableException( + sprintf( + 'Timed out after %.3fs waiting for the resilience state lock [%s].', + max(0.0, $this->lockBlockTimeout), + $key, + ), + 'RESILIENCE_STORE_LOCK_TIMEOUT', + ); + } + + usleep(self::LOCK_POLL_MICROSECONDS); + } + } - return $store->lock($key.':lock', $seconds)->block($seconds, $callback); + /** + * Runs the critical section and releases the mutex whatever happens. + * + * The finally is load-bearing: without it, a guarded callable that throws (a breaker's admit() rejecting + * with CircuitBreakerOpenException does exactly that) would leave the lock held until its TTL expired, + * and every transition on that key in the meantime would fail-fast into a 503. + * + * @template T + * + * @param callable(): T $callback + * @return T + */ + private function runAndRelease(Lock $lock, callable $callback): mixed + { + try { + return $callback(); + } finally { + $lock->release(); + } } } diff --git a/packages/resilience/tests/BulkheadTest.php b/packages/resilience/tests/BulkheadTest.php index 1e6215c..e13139b 100644 --- a/packages/resilience/tests/BulkheadTest.php +++ b/packages/resilience/tests/BulkheadTest.php @@ -6,6 +6,7 @@ use Firefly\Resilience\Exception\BulkheadFullException; use Firefly\Resilience\Store\CacheResilienceStore; use Firefly\Resilience\Store\InMemoryResilienceStore; +use Firefly\Resilience\Tests\Fixtures\ContendedResilienceStore; use Illuminate\Cache\ArrayStore; use Illuminate\Cache\Repository; @@ -40,3 +41,54 @@ expect($bulkheadA->acquire())->toBeTrue() ->and($bulkheadB->acquire())->toBeFalse(); // A holds the only permit, in the shared cache }); + +it('reclaims a permit whose holder died, once the permit TTL elapses', function () { + // The leak: acquire() used to be a bare atomic increment with no expiry, so a worker killed between + // acquire() and release() (OOM, deploy SIGKILL, fatal error — no finally runs in a dead process) + // subtracted one permit from the bulkhead PERMANENTLY. Enough crashes and max-concurrent permits are + // all "held" by processes that no longer exist, and the bulkhead rejects 100% of traffic until an + // operator flushes the cache by hand. Permits therefore carry a deadline and are pruned on read. + $store = new InMemoryResilienceStore; + $crashed = new Bulkhead('bh:crash', $store, maxConcurrent: 1, permitTtl: 0.05); + $survivor = new Bulkhead('bh:crash', $store, maxConcurrent: 1, permitTtl: 0.05); + + expect($crashed->acquire())->toBeTrue() // this holder never releases: it "dies" here + ->and($survivor->acquire())->toBeFalse(); // still within the TTL, the permit is legitimately held + + usleep(80_000); + + expect($survivor->acquire())->toBeTrue(); // the dead holder's permit has expired and is reclaimed +}); + +it('ignores a release() that this holder never matched with an acquire()', function () { + // The old decrement-based counter let a stray release() push the shared counter NEGATIVE, manufacturing + // capacity out of nothing: two unmatched releases on a max-concurrent-1 bulkhead left it at -2, and the + // next TWO callers both slipped past the limit. A release is now the return of a permit this instance + // actually holds, so an unmatched one is a no-op. + $store = new InMemoryResilienceStore; + $bulkhead = new Bulkhead('bh:unmatched', $store, maxConcurrent: 1); + + $bulkhead->release(); + $bulkhead->release(); + + expect($bulkhead->acquire())->toBeTrue() + ->and((new Bulkhead('bh:unmatched', $store, maxConcurrent: 1))->acquire())->toBeFalse(); +}); + +it('never lets the permit return destroy the guarded call\'s own result or exception', function () { + // call() returns the permit in a finally. That cleanup now goes through the store mutex, and the store + // is fail-fast: a contended lock raises ServiceUnavailableException. A throw out of a finally REPLACES + // whatever the block was about to produce, so an unguarded release() would turn a perfectly successful + // call into a 503, and would swap a caller's own DomainException for an unrelated infrastructure one — + // the guarded work would already have happened, and its outcome would be lost. Returning the permit is + // best effort: the lease deadline is the backstop that makes it safe to give up. + $store = new ContendedResilienceStore(failFromCall: 2); // the claim succeeds, the release cannot + + expect((new Bulkhead('bh:cleanup', $store, maxConcurrent: 1))->call(fn (): string => 'ok'))->toBe('ok'); + + $store = new ContendedResilienceStore(failFromCall: 2); + + expect(fn () => (new Bulkhead('bh:cleanup', $store, maxConcurrent: 1))->call( + fn () => throw new DomainException('the caller\'s own failure'), + ))->toThrow(DomainException::class, 'the caller\'s own failure'); +}); diff --git a/packages/resilience/tests/CircuitBreakerTest.php b/packages/resilience/tests/CircuitBreakerTest.php index 4dc18c7..0144ba7 100644 --- a/packages/resilience/tests/CircuitBreakerTest.php +++ b/packages/resilience/tests/CircuitBreakerTest.php @@ -6,6 +6,7 @@ use Firefly\Resilience\CircuitBreaker; use Firefly\Resilience\Store\CacheResilienceStore; use Firefly\Resilience\Store\InMemoryResilienceStore; +use Firefly\Resilience\Tests\Fixtures\ContendedResilienceStore; use Illuminate\Cache\ArrayStore; use Illuminate\Cache\Repository; @@ -49,9 +50,11 @@ $breaker->call(fn () => throw new RuntimeException('x')); } catch (RuntimeException) { } - expect($breaker->state())->toBe('open'); + // waitDurationInOpen 0 means the open window is due the instant it starts, so the EFFECTIVE state is + // already HALF_OPEN — state() reports what admit() would decide, not the last state written to the store. + expect($breaker->state())->toBe('half_open'); - // waitDurationInOpen 0 => the next admit flips OPEN->HALF_OPEN, the probe succeeds, the breaker closes. + // The next admit flips OPEN->HALF_OPEN for real, the probe succeeds, and the breaker closes. expect($breaker->call(fn (): string => 'ok'))->toBe('ok') ->and($breaker->state())->toBe('closed'); }); @@ -66,3 +69,126 @@ expect($breaker->state())->toBe('closed'); }); + +it('releases the HALF_OPEN probe permit when the probe throws an exception outside record-on', function () { + // The permanent-wedge regression: call() only reached onFailure() for exceptions listed in record-on, so + // a probe that threw anything else returned WITHOUT giving the half-open slot back. admit() then found + // the probe budget exhausted on every subsequent call and rejected forever — the breaker could never + // reach CLOSED or OPEN again, because OPEN->HALF_OPEN is the only transition that resets the budget and + // the breaker was no longer OPEN. + $breaker = new CircuitBreaker( + 'cb:wedge', + new InMemoryResilienceStore, + failureThreshold: 1, + waitDurationInOpen: 0.0, + halfOpenMaxCalls: 1, + recordOn: [LogicException::class], + ); + + try { + $breaker->call(fn () => throw new LogicException('recorded')); + } catch (LogicException) { + } + expect($breaker->state())->toBe('half_open'); // waitDurationInOpen 0 => the probe window is already due + + // The probe throws something record-on ignores: neither success nor failure, but the slot must come back. + try { + $breaker->call(fn () => throw new RuntimeException('ignored by record-on')); + } catch (RuntimeException) { + } + + // Pre-fix this threw CircuitBreakerOpenException here, and for every call thereafter, forever. + expect($breaker->call(fn (): string => 'ok'))->toBe('ok') + ->and($breaker->state())->toBe('closed'); +}); + +it('reclaims an abandoned HALF_OPEN probe permit once its deadline passes (a dead worker self-heals)', function () { + // A worker that dies between admit() and the outcome write leaves a probe permit nobody will ever + // return; there is no finally to run in a process that is gone. The permit therefore carries a deadline, + // and admit() prunes expired permits before counting. This test writes the persisted record directly + // because that is precisely what a half-dead worker leaves behind — the shape IS the contract here. + $store = new InMemoryResilienceStore; + $breaker = new CircuitBreaker('cb:dead', $store, waitDurationInOpen: 30.0, halfOpenMaxCalls: 1); + + $store->put('cb:dead', [ + 'state' => 'half_open', + 'openedAt' => microtime(true), + 'halfOpenProbes' => [microtime(true) + 30.0], // still-live probe held by a worker that is running + 'outcomes' => [], + ]); + expect(fn () => $breaker->call(fn (): string => 'unreached'))->toThrow(CircuitBreakerOpenException::class); + + $store->put('cb:dead', [ + 'state' => 'half_open', + 'openedAt' => microtime(true), + 'halfOpenProbes' => [microtime(true) - 0.001], // the holder died; the deadline has passed + 'outcomes' => [], + ]); + expect($breaker->call(fn (): string => 'ok'))->toBe('ok') + ->and($breaker->state())->toBe('closed'); +}); + +it('state() reports the EFFECTIVE state, flipping stale OPEN to HALF_OPEN once the wait window is due', function () { + // The actuator/observability gauge reads state() without making a call, so a breaker whose open window + // had already elapsed was reported as OPEN indefinitely — the dashboard showed a hard-down dependency + // while the breaker was in fact ready to probe. state() must agree with what admit() would decide. + $breaker = new CircuitBreaker('cb:effective', new InMemoryResilienceStore, failureThreshold: 1, waitDurationInOpen: 0.05); + + try { + $breaker->call(fn () => throw new RuntimeException('down')); + } catch (RuntimeException) { + } + + expect($breaker->state())->toBe('open'); // inside the wait window: genuinely OPEN + + usleep(80_000); + + expect($breaker->state())->toBe('half_open'); // the window is due: admit() would let a probe through +}); + +it('does not open until minimum-number-of-calls outcomes have been recorded in the window', function () { + // Without a minimum, a breaker with failure-threshold 1 trips on the very first failure a fresh window + // ever sees — one blip on a low-traffic endpoint takes the dependency out for wait-duration-in-open. + $breaker = new CircuitBreaker( + 'cb:minimum', + new InMemoryResilienceStore, + failureThreshold: 1, + windowSize: 10, + minimumNumberOfCalls: 3, + ); + + $boom = fn () => throw new RuntimeException('down'); + + foreach ([1, 2] as $ignored) { + try { + $breaker->call($boom); + } catch (RuntimeException) { + } + expect($breaker->state())->toBe('closed'); // fewer than 3 recorded calls: not enough evidence + } + + try { + $breaker->call($boom); + } catch (RuntimeException) { + } + + expect($breaker->state())->toBe('open'); +}); + +it('never lets the probe-permit return replace the exception the caller actually threw', function () { + // Returning the HALF_OPEN permit for an exception outside record-on is a store write, and the store is + // fail-fast: a contended lock raises ServiceUnavailableException. That cleanup runs inside call()'s + // catch block, so if it is allowed to throw it REPLACES the caller's exception — and an exception the + // breaker was explicitly configured to ignore would come back as an infrastructure 503 the application + // never raised. Pass-through for a non-recorded exception is the contract; the permit's deadline is the + // backstop that makes abandoning the cleanup safe. + $breaker = new CircuitBreaker( + 'cb:cleanup', + new ContendedResilienceStore(failFromCall: 2), // admit() succeeds, releaseProbe() cannot + waitDurationInOpen: 0.0, + recordOn: [LogicException::class], + ); + + expect(fn () => $breaker->call(fn () => throw new RuntimeException('ignored by record-on'))) + ->toThrow(RuntimeException::class, 'ignored by record-on'); +}); diff --git a/packages/resilience/tests/Fixtures/ContendedResilienceStore.php b/packages/resilience/tests/Fixtures/ContendedResilienceStore.php new file mode 100644 index 0000000..bd84cf3 --- /dev/null +++ b/packages/resilience/tests/Fixtures/ContendedResilienceStore.php @@ -0,0 +1,74 @@ +inner = new InMemoryResilienceStore; + } + + public function get(string $key): mixed + { + return $this->inner->get($key); + } + + public function put(string $key, mixed $value, ?float $ttlSeconds = null): void + { + $this->inner->put($key, $value, $ttlSeconds); + } + + public function add(string $key, mixed $value, ?float $ttlSeconds = null): bool + { + return $this->inner->add($key, $value, $ttlSeconds); + } + + public function increment(string $key, int $by = 1): int + { + return $this->inner->increment($key, $by); + } + + public function decrement(string $key, int $by = 1): int + { + return $this->inner->decrement($key, $by); + } + + public function forget(string $key): void + { + $this->inner->forget($key); + } + + public function withLock(string $key, float $ttlSeconds, callable $callback): mixed + { + $this->lockCalls++; + + if ($this->lockCalls >= $this->failFromCall) { + throw new ServiceUnavailableException( + sprintf('Timed out after 0.500s waiting for the resilience state lock [%s].', $key), + 'RESILIENCE_STORE_LOCK_TIMEOUT', + ); + } + + return $this->inner->withLock($key, $ttlSeconds, $callback); + } +} diff --git a/packages/resilience/tests/ResilienceAutoConfigurationTest.php b/packages/resilience/tests/ResilienceAutoConfigurationTest.php index 4765c1f..e9485bf 100644 --- a/packages/resilience/tests/ResilienceAutoConfigurationTest.php +++ b/packages/resilience/tests/ResilienceAutoConfigurationTest.php @@ -6,6 +6,7 @@ use Firefly\Container\Attributes\Configuration; use Firefly\Container\Attributes\Order; use Firefly\Context\Condition\Attributes\ConditionalOnMissingBean; +use Firefly\Kernel\Exception\Infrastructure\ServiceUnavailableException; use Firefly\Resilience\ResilienceAutoConfiguration; use Firefly\Resilience\ResilienceRegistry; use Firefly\Resilience\Store\CacheResilienceStore; @@ -30,9 +31,28 @@ it('its bean factories build the Cache-backed store and a config-driven registry', function () { $config = new Config(new Repository(['firefly' => ['resilience' => []]])); - $store = (new ResilienceAutoConfiguration)->resilienceStore(new CacheRepository(new ArrayStore)); + $store = (new ResilienceAutoConfiguration)->resilienceStore(new CacheRepository(new ArrayStore), $config); $registry = (new ResilienceAutoConfiguration)->resilienceRegistry($config, new InMemoryResilienceStore); expect($store)->toBeInstanceOf(CacheResilienceStore::class) ->and($registry)->toBeInstanceOf(ResilienceRegistry::class); }); + +it('reads the store lock-block timeout from config so a fail-fast primitive is not stuck with a default', function () { + // The bean must honour firefly.resilience.store.lock-block-timeout; a duration string is parsed the same + // way every other resilience duration is. Asserted through behaviour: a 0-second budget makes a + // contended lock reject immediately rather than wait. + $arrayStore = new ArrayStore; + $config = new Config(new Repository([ + 'firefly' => ['resilience' => ['store' => ['lock-block-timeout' => '0ms']]], + ])); + + $store = (new ResilienceAutoConfiguration)->resilienceStore(new CacheRepository($arrayStore), $config); + expect($arrayStore->lock('cfg:lock', 10)->get())->toBeTrue(); + + $started = microtime(true); + expect(fn () => $store->withLock('cfg', 5.0, static fn (): string => 'unreached')) + ->toThrow(ServiceUnavailableException::class); + + expect(microtime(true) - $started)->toBeLessThan(0.2); +}); diff --git a/packages/resilience/tests/ResilienceRegistryTest.php b/packages/resilience/tests/ResilienceRegistryTest.php index 6c9813a..4491bcf 100644 --- a/packages/resilience/tests/ResilienceRegistryTest.php +++ b/packages/resilience/tests/ResilienceRegistryTest.php @@ -69,3 +69,34 @@ function makeResilienceRegistry(array $resilience = []): ResilienceRegistry it('an empty resilience config still rejects unknown names cleanly', function () { expect(fn () => makeResilienceRegistry()->circuitBreaker('none'))->toThrow(ConfigurationException::class, '(none configured)'); }); + +it('wires the circuit breaker minimum-number-of-calls key through to the built instance', function () { + // A registry key that never reaches the constructor is indistinguishable from a typo, so assert the + // BEHAVIOUR the key buys rather than the object's shape: with a minimum of 3, two failures on a + // failure-threshold-1 breaker must not trip it. + $breaker = makeResilienceRegistry([ + 'circuit-breaker' => ['strict' => ['failure-threshold' => 1, 'minimum-number-of-calls' => 3]], + ])->circuitBreaker('strict'); + + foreach ([1, 2] as $ignored) { + try { + $breaker->call(fn () => throw new RuntimeException('down')); + } catch (RuntimeException) { + } + } + + expect($breaker->state())->toBe('closed'); +}); + +it('wires the bulkhead permit-ttl key through to the built instance', function () { + // permit-ttl is what reclaims a crashed holder's permit; a 50ms TTL makes that observable in-test. + $bulkhead = makeResilienceRegistry([ + 'bulkhead' => ['tiny' => ['max-concurrent' => 1, 'permit-ttl' => '50ms']], + ])->bulkhead('tiny'); + + expect($bulkhead->acquire())->toBeTrue(); // acquired and deliberately never released + + usleep(80_000); + + expect($bulkhead->acquire())->toBeTrue(); // the abandoned permit expired and was reclaimed +}); diff --git a/packages/resilience/tests/Store/ResilienceStoreTest.php b/packages/resilience/tests/Store/ResilienceStoreTest.php index 67ae74c..defa127 100644 --- a/packages/resilience/tests/Store/ResilienceStoreTest.php +++ b/packages/resilience/tests/Store/ResilienceStoreTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Firefly\Kernel\Exception\Infrastructure\ServiceUnavailableException; use Firefly\Resilience\Store\CacheResilienceStore; use Firefly\Resilience\Store\InMemoryResilienceStore; use Illuminate\Cache\ArrayStore; @@ -49,3 +50,63 @@ ->and($store->decrement('n'))->toBe(1) ->and($store->withLock('x', 1.0, static fn (): string => 'ok'))->toBe('ok'); }); + +it('fails fast on a contended lock and maps the timeout to a 503 instead of a raw LockTimeoutException', function () { + // The defect: withLock() passed the SAME number as both the lock TTL and the blocking wait, and every + // caller passed 5.0 — so a fail-fast rate limiter would sit for five seconds on a contended key and then + // throw Illuminate's LockTimeoutException, which no framework mapper knows about, so it surfaced as a + // bare HTTP 500. Waiting is now a separate, configurable budget and the timeout is a first-class 503 + // (a kernel infrastructure exception the framework's error mapper already renders correctly). + $arrayStore = new ArrayStore; + $store = new CacheResilienceStore(new Repository($arrayStore), lockBlockTimeout: 0.1); + + // A different owner holds the mutex for the next ten seconds: withLock() cannot possibly get it. + expect($arrayStore->lock('rl:contended:lock', 10)->get())->toBeTrue(); + + $started = microtime(true); + $thrown = null; + try { + $store->withLock('rl:contended', 5.0, static fn (): string => 'unreached'); + } catch (Throwable $e) { + $thrown = $e; + } + $elapsed = microtime(true) - $started; + + expect($thrown)->toBeInstanceOf(ServiceUnavailableException::class); + assert($thrown instanceof ServiceUnavailableException); + + expect($thrown->httpStatus())->toBe(503) + ->and($thrown->errorCode())->toBe('RESILIENCE_STORE_LOCK_TIMEOUT') + ->and($thrown->getMessage())->toContain('rl:contended') + ->and($elapsed)->toBeLessThan(1.0); // the old hardcoded blocking wait was five whole seconds +}); + +it('honours a zero lock-block timeout as a single non-blocking attempt', function () { + $arrayStore = new ArrayStore; + $store = new CacheResilienceStore(new Repository($arrayStore), lockBlockTimeout: 0.0); + + expect($arrayStore->lock('nb:lock', 10)->get())->toBeTrue(); + + $started = microtime(true); + expect(fn () => $store->withLock('nb', 5.0, static fn (): string => 'unreached')) + ->toThrow(ServiceUnavailableException::class); + + expect(microtime(true) - $started)->toBeLessThan(0.2); +}); + +it('releases the lock after the critical section so the next caller is admitted', function () { + $store = new CacheResilienceStore(new Repository(new ArrayStore), lockBlockTimeout: 0.1); + + expect($store->withLock('serial', 5.0, static fn (): int => 1))->toBe(1) + ->and($store->withLock('serial', 5.0, static fn (): int => 2))->toBe(2); +}); + +it('releases the lock even when the critical section throws', function () { + $store = new CacheResilienceStore(new Repository(new ArrayStore), lockBlockTimeout: 0.1); + + expect(fn () => $store->withLock('boom', 5.0, static fn () => throw new RuntimeException('inside'))) + ->toThrow(RuntimeException::class); + + // A leaked mutex here would make every later transition on this key throw a 503 until the TTL expired. + expect($store->withLock('boom', 5.0, static fn (): string => 'ok'))->toBe('ok'); +}); 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/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/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/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/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/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(); +}); 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/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/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/packages/validation/src/Constraint/ConstraintManifest.php b/packages/validation/src/Constraint/ConstraintManifest.php index de07f1f..844af5b 100644 --- a/packages/validation/src/Constraint/ConstraintManifest.php +++ b/packages/validation/src/Constraint/ConstraintManifest.php @@ -13,6 +13,12 @@ * ['@rule' => Iban::class] / ['@rule' => DecimalScale::class, 'args' => [2]], because ValidationRule * objects do not var_export cleanly. fromArray()/load() rehydrate envelopes back to rule objects. * + * 'args' is POSITIONAL and complete: the compiler emits every constructor parameter in declaration order, so + * rehydrate() can splat it without knowing anything about the rule. The key is absent, rather than an empty + * list, for a rule with no constructor state. Producing that list is ConstraintManifestCompiler's job and the + * subtle part of this round-trip — read its class docblock for what is recoverable, what is refused at + * compile time, and the production-only failure that came from getting it wrong. + * * @phpstan-type RuleEnvelope array{'@rule': class-string, args?: list} */ final class ConstraintManifest @@ -68,6 +74,9 @@ public function rulesFor(string $class): array } /** + * The only place a compiled envelope becomes an object again. Positional splat, no reflection, no + * per-rule knowledge — everything needed to rebuild the rule was decided by the compiler. + * * @param RuleEnvelope $entry */ private static function rehydrate(array $entry): ValidationRule diff --git a/packages/validation/src/Constraint/ConstraintManifestCompiler.php b/packages/validation/src/Constraint/ConstraintManifestCompiler.php index 7db3111..f407945 100644 --- a/packages/validation/src/Constraint/ConstraintManifestCompiler.php +++ b/packages/validation/src/Constraint/ConstraintManifestCompiler.php @@ -5,14 +5,41 @@ namespace Firefly\Validation\Constraint; use Firefly\Kernel\Exception\Framework\ConfigurationException; -use Firefly\Validation\Rule\DecimalScale; +use Firefly\Validation\Rule\Compilable; use Illuminate\Contracts\Validation\ValidationRule; +use ReflectionClass; +use ReflectionProperty; +use UnitEnum; /** - * Compile-time façade over ConstraintScanner. Delegates the single reflection pass to the scanner, then - * serialises each ValidationRule object into a var_export-safe envelope. The only arg-bearing shipped rule - * is DecimalScale, so envelope() special-cases exactly it (via its additive scale() accessor); every other - * rule serialises to a bare ['@rule' => Class]. Never called on the cached path. + * Compile-time façade over ConstraintScanner. Delegates the single reflection pass over the DTO to the + * scanner, then serialises each ValidationRule object into a var_export-safe envelope. Never called on the + * cached path. + * + * THE DEFECT THIS CLASS WAS REWRITTEN FOR. A rule object cannot be written into a PHP array literal, so it is + * stored as ['@rule' => Class] plus an optional positional 'args' list and rebuilt with `new $class(...$args)` + * by ConstraintManifest::rehydrate(). envelope() used to hard-code the ONE shipped rule that carried + * constructor state (DecimalScale, via its scale() accessor) and emit a bare ['@rule' => Class] for + * everything else. That was fine for first-party rules and silently wrong for the #[Rules] escape hatch, + * whose whole purpose is app-local and third-party rules: #[Rules(new StartsWith('ACME-'))] compiled to + * ['@rule' => StartsWith::class], and the cached application then booted a `new StartsWith()` — an + * ArgumentCountError at boot if the constructor parameter was required, or, if it had a default, something + * far worse: a rule that validated a DIFFERENT prefix from the one written in the source, in production only, + * because the uncached path (which keeps the live object) behaved correctly in every test and every local run. + * + * WHAT WE DO INSTEAD, AND WHY THIS SHAPE. Arbitrary object graphs genuinely cannot be var_export'd, but the + * constructor ARGUMENTS of the overwhelmingly common rule shape can be recovered exactly: PHP 8 constructor + * promotion guarantees a property per parameter, with the parameter's name, so reflection reads back the very + * values the rule was built with. That covers first-party rules (DecimalScale and Size now compile through + * the generic path — the special case is gone) and ordinary third-party ones with no ceremony at all. + * + * Recovery stops where honesty does. A constructor that assigns in its body, renames, or normalises its input + * cannot be inverted, and a promoted argument may still be un-exportable (a DateTimeImmutable, a PSR logger, + * a closure). Guessing there would ship a rule that behaves differently from the one written — the very + * failure being fixed — so both cases throw a ConfigurationException from the COMPILER, naming the rule, the + * offending parameter, and the two ways out: promote the parameter, or implement + * Firefly\Validation\Rule\Compilable and declare the arguments explicitly. A cache build fails loudly on a + * developer's machine or in CI instead of a request failing in production. * * @phpstan-type RuleEnvelope array{'@rule': class-string, args?: list} */ @@ -82,10 +109,105 @@ private function serialise(array $scanned): array */ private function envelope(ValidationRule $rule): array { - if ($rule instanceof DecimalScale) { - return ['@rule' => $rule::class, 'args' => [$rule->scale()]]; + // Keyed by parameter name on the reflection path and by position on the Compilable one, purely so a + // rejection can name what the developer wrote; array_values() then flattens it to the positional list + // rehydrate() splats. PHP preserves insertion order, so declaration order survives the flattening. + $arguments = $rule instanceof Compilable + ? $rule->constructorArguments() + : $this->recoverArguments($rule); + + /** @var mixed $argument */ + foreach ($arguments as $label => $argument) { + $this->assertExportable($rule, is_int($label) ? '#'.$label : '$'.$label, $argument); + } + + // A stateless rule keeps the bare two-key envelope it has always had: 'args' => [] would be noise in + // every generated manifest, and `new $class()` is exactly right for a rule with nothing to restore. + return $arguments === [] + ? ['@rule' => $rule::class] + : ['@rule' => $rule::class, 'args' => array_values($arguments)]; + } + + /** + * Reads a rule's constructor arguments back out of its promoted properties, keyed by parameter name. + * + * @return array + */ + private function recoverArguments(ValidationRule $rule): array + { + $constructor = (new ReflectionClass($rule))->getConstructor(); + if ($constructor === null) { + return []; + } + + $arguments = []; + foreach ($constructor->getParameters() as $parameter) { + $name = $parameter->getName(); + + if (! $parameter->isPromoted()) { + throw new ConfigurationException(sprintf( + 'Cannot compile the validation rule %s: its constructor parameter $%s is not promoted to a ' + .'property, so the compiled constraint manifest has no way to recover the value it was built ' + .'with. Promote the parameter (e.g. "private readonly" in the constructor signature), or ' + .'implement %s to declare the rule\'s constructor arguments explicitly.', + $rule::class, + $name, + Compilable::class, + )); + } + + $property = new ReflectionProperty($rule, $name); + if (! $property->isInitialized($rule)) { + throw new ConfigurationException(sprintf( + 'Cannot compile the validation rule %s: its promoted property $%s is uninitialised, so the ' + .'compiled constraint manifest cannot record the argument. Implement %s to declare the ' + .'rule\'s constructor arguments explicitly.', + $rule::class, + $name, + Compilable::class, + )); + } + + /** @var mixed $value */ + $value = $property->getValue($rule); + $arguments[$name] = $value; + } + + return $arguments; + } + + /** + * Rejects, at compile time, any argument var_export cannot write as a re-parseable literal. + * + * var_export handles null, scalars, arrays of those, and (since PHP 8.1) enum cases, which it writes as + * \Fully\Qualified::Case. Everything else it writes as \Some\Class::__set_state(...), which fatals on load + * unless the class implements that magic — so an unchecked object argument would turn a green cache build + * into a broken production boot. Failing here names the rule while the developer is looking at it. + */ + private function assertExportable(ValidationRule $rule, string $label, mixed $argument): void + { + if ($argument === null || is_scalar($argument) || $argument instanceof UnitEnum) { + return; + } + + if (is_array($argument)) { + /** @var mixed $element */ + foreach ($argument as $element) { + $this->assertExportable($rule, $label, $element); + } + + return; } - return ['@rule' => $rule::class]; + throw new ConfigurationException(sprintf( + 'Cannot compile the validation rule %s: constructor argument %s is of type %s, which cannot be ' + .'written into the compiled constraint manifest (only null, scalars, enum cases and arrays of those ' + .'survive var_export). Give the rule scalar constructor state, or implement %s to declare arguments ' + .'that do.', + $rule::class, + $label, + get_debug_type($argument), + Compilable::class, + )); } } diff --git a/packages/validation/src/Constraint/ConstraintScanner.php b/packages/validation/src/Constraint/ConstraintScanner.php index bd646c4..c485a53 100644 --- a/packages/validation/src/Constraint/ConstraintScanner.php +++ b/packages/validation/src/Constraint/ConstraintScanner.php @@ -4,6 +4,7 @@ namespace Firefly\Validation\Constraint; +use Firefly\Validation\Rule\NullAware; use Firefly\Validation\Valid; use Illuminate\Contracts\Validation\ValidationRule; use ReflectionAttribute; @@ -11,7 +12,9 @@ use ReflectionNamedType; /** - * The ONE reflection site in packages/validation/src (grep invariant). Reflects a DTO's + * The primary reflection site in packages/validation/src, and — together with ConstraintManifestCompiler's + * rule-argument recovery — one of only two, both COMPILE-time only: nothing on the cached runtime path + * reflects, which is the invariant that actually matters. Reflects a DTO's * constructor-promoted properties (plus any plain typed properties) once, at COMPILE time, reads each * property's #[Constraint] attributes via IS_INSTANCEOF, merges toRules() in declaration order, and * cascades one #[Valid] level into dot-prefixed nested keys. Recursion is guarded by an ANCESTOR set @@ -24,6 +27,8 @@ * AddressPayload $beneficiary } it yields `beneficiary.postcode` (etc.). Production loads the compiled * ConstraintManifest instead (require+map); this class runs only at cache time or, in tests, inline via * ConstraintManifestCompiler. + * + * Assembling a property's list is also where Jakarta's NULL contract is applied — see applyNullContract(). */ final class ConstraintScanner { @@ -62,6 +67,7 @@ private function scanClass(string $class, array $ancestors): array $parameter->getAttributes(Constraint::class, ReflectionAttribute::IS_INSTANCEOF), $this->hasValid($parameter->getAttributes(Valid::class)), $this->classTypeOf($parameter->getType()), + $parameter->getType()?->allowsNull() ?? true, $class, $ancestors, $rules, @@ -77,6 +83,7 @@ private function scanClass(string $class, array $ancestors): array $property->getAttributes(Constraint::class, ReflectionAttribute::IS_INSTANCEOF), $this->hasValid($property->getAttributes(Valid::class)), $this->classTypeOf($property->getType()), + $property->getType()?->allowsNull() ?? true, $class, $ancestors, $rules, @@ -88,6 +95,7 @@ private function scanClass(string $class, array $ancestors): array /** * @param list> $constraintAttributes + * @param bool $acceptsNull whether the property's DECLARED type admits null (see applyNullContract()) * @param class-string $class the class currently being scanned (pushed onto $ancestors on descent) * @param list $ancestors * @param array> $rules @@ -97,6 +105,7 @@ private function collect( array $constraintAttributes, bool $valid, ?string $nestedClass, + bool $acceptsNull, string $class, array $ancestors, array &$rules, @@ -108,7 +117,7 @@ private function collect( } } if ($propertyRules !== []) { - $rules[$name] = $propertyRules; + $rules[$name] = $this->applyNullContract($propertyRules, $acceptsNull); } if ($valid && $nestedClass !== null && ! in_array($nestedClass, $ancestors, true)) { @@ -118,6 +127,62 @@ private function collect( } } + /** + * Applies Jakarta Bean Validation's null contract to a property's assembled rule list. + * + * Jakarta is explicit: `null` is a VALID value for every constraint except @NotNull (and the constraints + * that subsume it, @NotEmpty/@NotBlank). `@Email String backupEmail` accepts null; it is @NotNull's job, + * and only @NotNull's job, to say that a value is required. LaraFly did the opposite. Illuminate + * validates a rule when the attribute is PRESENT (Validator::presentOrRuleIsImplicit -> validatePresent, + * which is Arr::has and therefore true for a key holding null), so a payload of ['backupEmail' => null] + * ran the `email` rule against null and failed it. That was wrong on its own terms and INCONSISTENT with + * the neighbouring case: omit the key entirely and the same rule is skipped, so {"backupEmail": null} was + * rejected while {} was accepted — two spellings of "no value" with opposite outcomes, which is how the + * defect surfaced: clients were punished for serialising their optional fields explicitly. + * + * The fix is one flag, decided at compile time and baked into the manifest: prepend Laravel's `nullable`, + * which makes Validator::isNotNullIfMarkedAsNullable skip every NON-implicit rule when the value is null. + * The relationship it establishes is the simple one Jakarta describes: ABSENT and PRESENT-BUT-NULL now + * behave identically for every constraint, and a constraint that means to reject either must say so. + * + * The flag is withheld from a property whose DECLARED TYPE does not admit null, and that qualification is + * load-bearing rather than tidiness. Jakarta's contract is stated for Java references, every one of which + * can hold null; PHP's equivalent statement is the type declaration, and `public readonly string $email` + * has already said that null is not a value this field can take. Marking it `nullable` would let a + * {"email": null} body pass validation and then blow up one line later in the web layer's + * `new $dto(...$named)` with a TypeError — a 500 where the payload used to get a 422. So the null + * contract applies exactly where the DTO admits null (`?string $backupEmail`, a union with null, `mixed`, + * or an untyped property), and a non-nullable property keeps rejecting an explicit null through whatever + * rule it already carries. Absent-vs-present-null therefore agree wherever null is a legal value for the + * property, which is the whole of the case the contract is about. + * + * Two kinds of rule deliberately keep firing through the flag. Implicit rule STRINGS — `required` and its + * family, emitted by #[NotEmpty]/#[NotBlank] — are exempt from `nullable` by Illuminate's own design, so + * they still reject null; there the flag merely suppresses the type/format rules queued behind them, + * collapsing three meaningless complaints about a null ("must be a string", "format is invalid", ...) into + * the one that matters. Rule OBJECTS are never implicit, so a rule whose entire purpose is null would be + * silently disabled instead: those implement Firefly\Validation\Rule\NullAware (Firefly's NotNull does), + * and their presence withholds the flag from the property altogether. + * + * @param list $propertyRules + * @param bool $acceptsNull whether the property's declared type admits null + * @return list + */ + private function applyNullContract(array $propertyRules, bool $acceptsNull): array + { + if (! $acceptsNull) { + return $propertyRules; + } + + foreach ($propertyRules as $rule) { + if ($rule instanceof NullAware) { + return $propertyRules; + } + } + + return ['nullable', ...$propertyRules]; + } + /** * @param list> $attributes */ diff --git a/packages/validation/src/Constraint/Size.php b/packages/validation/src/Constraint/Size.php index 918a04c..9f0be47 100644 --- a/packages/validation/src/Constraint/Size.php +++ b/packages/validation/src/Constraint/Size.php @@ -5,7 +5,21 @@ namespace Firefly\Validation\Constraint; use Attribute; - +use Firefly\Validation\Rule\Size as SizeRule; + +/** + * Jakarta's @Size: a LENGTH/SIZE constraint, always, whatever else is declared on the property. + * + * This 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 without a word of warning. It now wraps the + * first-party Firefly\Validation\Rule\Size, which measures the value and never reads the sibling list; see + * that rule for the full account of the defect and of what "measurable" means per type. + * + * An unbounded #[Size] (neither min nor max) still contributes nothing: it constrains nothing, and emitting a + * rule object that can never fail would only add noise to the compiled manifest. + */ #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] final class Size implements Constraint { @@ -14,20 +28,13 @@ public function __construct( public readonly ?int $max = null, ) {} + /** @return list */ public function toRules(): array { - if ($this->min !== null && $this->max !== null) { - return ['between:'.$this->min.','.$this->max]; - } - - if ($this->min !== null) { - return ['min:'.$this->min]; - } - - if ($this->max !== null) { - return ['max:'.$this->max]; + if ($this->min === null && $this->max === null) { + return []; } - return []; + return [new SizeRule($this->min, $this->max)]; } } diff --git a/packages/validation/src/Rule/Compilable.php b/packages/validation/src/Rule/Compilable.php new file mode 100644 index 0000000..2020bfb --- /dev/null +++ b/packages/validation/src/Rule/Compilable.php @@ -0,0 +1,32 @@ + Class, 'args' => [...]] and rebuilt with + * `new $class(...$args)` at load time. ConstraintManifestCompiler recovers `args` automatically for the + * ordinary PHP 8 shape — every constructor parameter promoted to a property — because promotion guarantees a + * property mirrors each parameter, so reflection can read the values back out. + * + * Promotion is not always possible: a constructor may normalise its input (upper-casing, parsing, deriving), + * assign to differently named properties, or accept a value it does not keep. Reflection cannot invert any of + * that, and guessing would compile a rule that behaves differently from the one the developer wrote. Such a + * rule implements this interface and answers for itself; the arguments must be var_export-safe (null, + * scalars, enums, or arrays of those) and, applied to the constructor, must produce an equivalent rule. + * + * A rule that is neither promotion-shaped nor Compilable is rejected at COMPILE time with an actionable + * ConfigurationException, never silently rehydrated with defaults at boot. + */ +interface Compilable +{ + /** + * @return list positional constructor arguments, in declaration order + */ + public function constructorArguments(): array; +} diff --git a/packages/validation/src/Rule/NotNull.php b/packages/validation/src/Rule/NotNull.php index e53b93d..88d9501 100644 --- a/packages/validation/src/Rule/NotNull.php +++ b/packages/validation/src/Rule/NotNull.php @@ -11,8 +11,13 @@ * The @NotNull semantic Laravel lacks a pure-string rule for: reject ONLY a strict null, while allowing * an empty string, 0, and false (which `required` would wrongly reject). Paired with `present` by the * #[NotNull] constraint so the field must also be present in the payload. + * + * It is NullAware because that is its entire point. Every other constraint now compiles behind Laravel's + * `nullable` flag so a present-but-null value skips it (Jakarta: only @NotNull rejects null); a rule object + * is never implicit to Illuminate, so `nullable` would skip this rule too and #[NotNull] would quietly stop + * rejecting anything. The marker tells the ConstraintScanner to withhold the flag from this property. */ -final class NotNull implements ValidationRule +final class NotNull implements NullAware, ValidationRule { public function validate(string $attribute, mixed $value, Closure $fail): void { diff --git a/packages/validation/src/Rule/NullAware.php b/packages/validation/src/Rule/NullAware.php new file mode 100644 index 0000000..32109c2 --- /dev/null +++ b/packages/validation/src/Rule/NullAware.php @@ -0,0 +1,26 @@ +min; + } + + public function max(): ?int + { + return $this->max; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if ($value === null) { + return; + } + + $size = $this->measure($value); + + if ($size === null) { + $fail('The :attribute has no measurable size; a size constraint applies to strings, arrays and countables.'); + + return; + } + + if ($this->min !== null && $size < $this->min) { + $fail($this->max !== null + ? "The :attribute size must be between {$this->min} and {$this->max}." + : "The :attribute size must be at least {$this->min}."); + + return; + } + + if ($this->max !== null && $size > $this->max) { + $fail($this->min !== null + ? "The :attribute size must be between {$this->min} and {$this->max}." + : "The :attribute size must not be greater than {$this->max}."); + } + } + + private function measure(mixed $value): ?int + { + if (is_string($value)) { + return mb_strlen($value); + } + + if (is_array($value) || $value instanceof Countable) { + return count($value); + } + + if (is_int($value) || is_float($value)) { + return mb_strlen((string) $value); + } + + return null; + } +} diff --git a/packages/validation/tests/Constraint/ConstraintManifestRoundTripTest.php b/packages/validation/tests/Constraint/ConstraintManifestRoundTripTest.php index cb762de..6c6f505 100644 --- a/packages/validation/tests/Constraint/ConstraintManifestRoundTripTest.php +++ b/packages/validation/tests/Constraint/ConstraintManifestRoundTripTest.php @@ -14,6 +14,7 @@ $account = $rows[MoneyTransferRequest::class]['account']; expect($account[0])->toBe('required') + // A stateless rule keeps the bare two-key envelope: 'args' => [] would be noise in every manifest. ->and($account[3])->toBe(['@rule' => Iban::class]) ->and($rows[MoneyTransferRequest::class]['beneficiary.postcode'][0])->toBe(['@rule' => PostalCode::class]); }); @@ -22,6 +23,8 @@ $compiler = new ConstraintManifestCompiler; $rows = $compiler->toArray([DecimalDto::class]); + // The scale is recovered from DecimalScale's PROMOTED constructor property, with no per-rule special + // case in the compiler: the same generic path that carries a third-party rule's arguments. expect($rows[DecimalDto::class]['ratio'][0])->toBe(['@rule' => DecimalScale::class, 'args' => [3]]); $manifest = ConstraintManifest::fromArray($rows); diff --git a/packages/validation/tests/Constraint/ConstraintScannerTest.php b/packages/validation/tests/Constraint/ConstraintScannerTest.php index 15ce056..ebe6313 100644 --- a/packages/validation/tests/Constraint/ConstraintScannerTest.php +++ b/packages/validation/tests/Constraint/ConstraintScannerTest.php @@ -6,18 +6,36 @@ use Firefly\Validation\Rule\Iban; use Firefly\Validation\Rule\PositiveMoney; use Firefly\Validation\Rule\PostalCode; +use Firefly\Validation\Rule\Size; use Firefly\Validation\Tests\Fixtures\Constraint\MoneyTransferRequest; use Firefly\Validation\Tests\Fixtures\Constraint\SelfReferential; it('merges each promoted property constraint into declaration-ordered rules', function () { $rules = (new ConstraintScanner)->scan(MoneyTransferRequest::class); + // `account` is declared `string`, not `?string`, so Jakarta's null contract does NOT add `nullable` to + // it: the type has already said null is not a value this field can hold. See ConstraintScanner's + // applyNullContract(), and JakartaNullSemanticsTest for the nullable-typed side of the same rule. expect($rules['account'][0])->toBe('required') ->and($rules['account'][1])->toBe('string') ->and($rules['account'][2])->toBe('regex:/\S/') ->and($rules['account'][3])->toBeInstanceOf(Iban::class) - ->and($rules['amount'][0])->toBeInstanceOf(PositiveMoney::class) - ->and($rules['reference'])->toBe(['required', 'string', 'regex:/\S/', 'max:140']); + ->and($rules['account'])->not->toContain('nullable') + ->and($rules['amount'][0])->toBeInstanceOf(PositiveMoney::class); +}); + +it('compiles #[Size] to a size-measuring rule object, never a polymorphic rule string', function () { + $rules = (new ConstraintScanner)->scan(MoneyTransferRequest::class); + + $size = $rules['reference'][3]; + if (! $size instanceof Size) { + throw new RuntimeException('expected #[Size] to compile to a Size rule object'); + } + + // `max:140` would have meant "the number is at most 140" the moment any sibling emitted `numeric`. + expect($rules['reference'])->not->toContain('max:140') + ->and($rules['reference'][0])->toBe('required') + ->and($size->max())->toBe(140); }); it('cascades one #[Valid] level into dot-prefixed nested keys', function () { diff --git a/packages/validation/tests/Constraint/CoreConstraintsBatchOneTest.php b/packages/validation/tests/Constraint/CoreConstraintsBatchOneTest.php index aa84899..94755d7 100644 --- a/packages/validation/tests/Constraint/CoreConstraintsBatchOneTest.php +++ b/packages/validation/tests/Constraint/CoreConstraintsBatchOneTest.php @@ -14,12 +14,14 @@ use Firefly\Validation\Constraint\PositiveOrZero; use Firefly\Validation\Constraint\Size; use Firefly\Validation\Rule\NotNull as NotNullRule; +use Firefly\Validation\Rule\Size as SizeRule; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Translation\ArrayLoader; use Illuminate\Translation\PotentiallyTranslatedString; use Illuminate\Translation\Translator; /** @return list the failure messages the rule emitted (empty === passed) */ -function runNotNullRule(NotNullRule $rule, mixed $value): array +function runCoreRule(ValidationRule $rule, mixed $value): array { $messages = []; $translator = new Translator(new ArrayLoader, 'en'); @@ -42,19 +44,36 @@ function runNotNullRule(NotNullRule $rule, mixed $value): array }); it('the NotNull rule rejects only strict null, allowing empty string / 0 / false', function () { - expect(runNotNullRule(new NotNullRule, null))->not->toBe([]) - ->and(runNotNullRule(new NotNullRule, ''))->toBe([]) - ->and(runNotNullRule(new NotNullRule, 0))->toBe([]) - ->and(runNotNullRule(new NotNullRule, false))->toBe([]); + expect(runCoreRule(new NotNullRule, null))->not->toBe([]) + ->and(runCoreRule(new NotNullRule, ''))->toBe([]) + ->and(runCoreRule(new NotNullRule, 0))->toBe([]) + ->and(runCoreRule(new NotNullRule, false))->toBe([]); }); -it('maps Size to min/max/between', function () { - expect((new Size(min: 2))->toRules())->toBe(['min:2']) - ->and((new Size(max: 8))->toRules())->toBe(['max:8']) - ->and((new Size(min: 2, max: 8))->toRules())->toBe(['between:2,8']) +it('maps Size to a size-measuring rule object, and an unbounded Size to nothing', function () { + // Deliberately NOT Laravel's min:/max:/between: strings any more: those read the property's OTHER rules + // to decide whether they compare a length or a number. See SizeLengthSemanticsTest for the collision. + $bounded = (new Size(min: 2, max: 8))->toRules(); + + expect($bounded)->toHaveCount(1) + ->and($bounded[0])->toBeInstanceOf(SizeRule::class) + ->and($bounded[0]->min())->toBe(2) + ->and($bounded[0]->max())->toBe(8) + ->and((new Size(min: 2))->toRules()[0]->max())->toBeNull() + ->and((new Size(max: 8))->toRules()[0]->min())->toBeNull() ->and((new Size)->toRules())->toBe([]); }); +it('the Size rule measures characters and elements, never magnitude', function () { + expect(runCoreRule(new SizeRule(min: 3), '12'))->not->toBe([]) + ->and(runCoreRule(new SizeRule(min: 3), 12345))->toBe([]) + ->and(runCoreRule(new SizeRule(max: 2), [1, 2, 3]))->not->toBe([]) + ->and(runCoreRule(new SizeRule(max: 2), new ArrayObject([1, 2])))->toBe([]) + ->and(runCoreRule(new SizeRule(min: 2), 'ñá'))->toBe([]) + ->and(runCoreRule(new SizeRule(min: 1), null))->toBe([]) + ->and(runCoreRule(new SizeRule(min: 1), true))->not->toBe([]); +}); + it('maps the numeric bound constraints', function (Constraint $constraint, array $expected) { expect($constraint->toRules())->toBe($expected); })->with([ diff --git a/packages/validation/tests/Constraint/CustomRuleCompilationTest.php b/packages/validation/tests/Constraint/CustomRuleCompilationTest.php new file mode 100644 index 0000000..c99e0c2 --- /dev/null +++ b/packages/validation/tests/Constraint/CustomRuleCompilationTest.php @@ -0,0 +1,86 @@ +toArray([CustomRulePayload::class]); + + expect($rows[CustomRulePayload::class]['sku']) + ->toBe([['@rule' => StartsWith::class, 'args' => ['ACME-', true]]]); +}); + +it('rehydrates a custom rule that still carries its constructor state', function () { + $rows = (new ConstraintManifestCompiler)->toArray([CustomRulePayload::class]); + $manifest = ConstraintManifest::fromArray($rows); + + $sku = $manifest->rulesFor(CustomRulePayload::class)['sku'][0]; + expect($sku)->toBeInstanceOf(StartsWith::class); + + // The behavioural half of the round-trip: a rule rebuilt with a DEFAULTED constructor would have + // accepted anything, so assert the compiled rule still enforces the prefix it was configured with. + $validator = ValidatorHarness::beanValidator(CustomRulePayload::class); + + expect(fn () => $validator->validate(['sku' => 'OTHER-1', 'tag' => 'URGENT'], CustomRulePayload::class)) + ->toThrow(ValidationException::class); + + expect($validator->validate(['sku' => 'ACME-1', 'tag' => 'URGENT'], CustomRulePayload::class)) + ->toBe(['sku' => 'ACME-1', 'tag' => 'URGENT']); +}); + +it('survives the var_export round-trip to disk', function () { + $path = sys_get_temp_dir().'/fc-custom-rules-'.bin2hex(random_bytes(6)).'.php'; + + try { + (new ConstraintManifestCompiler)->write([CustomRulePayload::class], $path); + $rehydrated = ConstraintManifest::load($path)->rulesFor(CustomRulePayload::class)['tag'][0]; + + expect($rehydrated)->toBeInstanceOf(NormalisingRule::class); + } finally { + @unlink($path); + } +}); + +it('lets a rule declare its own constructor arguments when reflection cannot', function () { + $rows = (new ConstraintManifestCompiler)->toArray([CustomRulePayload::class]); + + expect($rows[CustomRulePayload::class]['tag']) + ->toBe([['@rule' => NormalisingRule::class, 'args' => ['URGENT']]]); +}); + +it('fails loudly at COMPILE time for a rule whose constructor state cannot be recovered', function () { + expect(fn () => (new ConstraintManifestCompiler)->toArray([OpaqueRulePayload::class])) + ->toThrow(ConfigurationException::class, OpaqueConstructorRule::class); +}); + +it('fails loudly at COMPILE time for constructor state var_export cannot write', function () { + expect(fn () => (new ConstraintManifestCompiler)->toArray([CutoffRulePayload::class])) + ->toThrow(ConfigurationException::class, CutoffRule::class); +}); + +final class OpaqueRulePayload +{ + public function __construct( + #[Rules(new OpaqueConstructorRule('ACME'))] + public readonly string $sku, + ) {} +} + +final class CutoffRulePayload +{ + public function __construct( + #[Rules(new CutoffRule(new DateTimeImmutable('2030-01-01T00:00:00+00:00')))] + public readonly string $issuedAt, + ) {} +} diff --git a/packages/validation/tests/Constraint/JakartaNullSemanticsTest.php b/packages/validation/tests/Constraint/JakartaNullSemanticsTest.php new file mode 100644 index 0000000..8de029a --- /dev/null +++ b/packages/validation/tests/Constraint/JakartaNullSemanticsTest.php @@ -0,0 +1,127 @@ + $data + * @return list the fields that failed (empty === the payload validated) + */ +function contactFailures(array $data): array +{ + try { + ValidatorHarness::beanValidator(OptionalContactPayload::class)->validate($data, OptionalContactPayload::class); + + return []; + } catch (ValidationException $e) { + return array_values(array_unique(array_map(static fn ($fieldError) => $fieldError->field, $e->fieldErrors()))); + } +} + +/** + * @param array $data + * @return list the fields that failed (empty === the payload validated) + */ +function strictFailures(array $data): array +{ + try { + ValidatorHarness::beanValidator(StrictContactPayload::class)->validate($data, StrictContactPayload::class); + + return []; + } catch (ValidationException $e) { + return array_values(array_unique(array_map(static fn ($fieldError) => $fieldError->field, $e->fieldErrors()))); + } +} + +it('treats a present-but-null value as valid for every constraint except the null-rejecting ones', function () { + expect(contactFailures([ + 'primaryEmail' => 'ada@example.test', + 'backupEmail' => null, + 'displayName' => 'Ada', + ]))->toBe([]); +}); + +it('makes an absent key and a present-null key agree', function () { + $absent = contactFailures(['primaryEmail' => 'ada@example.test', 'displayName' => 'Ada']); + $presentNull = contactFailures(['primaryEmail' => 'ada@example.test', 'backupEmail' => null, 'displayName' => 'Ada']); + + expect($absent)->toBe([])->and($presentNull)->toBe($absent); +}); + +it('still rejects null where #[NotNull] or #[NotBlank] asked for it', function () { + expect(contactFailures(['primaryEmail' => null, 'displayName' => 'Ada']))->toBe(['primaryEmail']) + ->and(contactFailures(['primaryEmail' => 'ada@example.test', 'displayName' => null]))->toBe(['displayName']); +}); + +it('still rejects an ABSENT key where #[NotNull] asked for presence', function () { + expect(contactFailures(['displayName' => 'Ada']))->toBe(['primaryEmail']); +}); + +it('still rejects a non-null value that violates the constraint', function () { + expect(contactFailures([ + 'primaryEmail' => 'ada@example.test', + 'backupEmail' => 'not-an-email', + 'displayName' => 'Ada', + ]))->toBe(['backupEmail']); +}); + +it('marks only the properties that no constraint guards against null', function () { + $rules = (new ConstraintScanner)->scan(OptionalContactPayload::class); + + expect($rules['backupEmail'][0])->toBe('nullable') + ->and($rules['primaryEmail'])->not->toContain('nullable') + ->and($rules['displayName'][0])->toBe('nullable'); +}); + +it('scopes the null contract to the property that declares the constraints, not to a nested subtree', function () { + // Worth stating precisely, because it is the one place LaraFly still diverges from Jakarta. Jakarta would + // read `beneficiary: null` as "nothing to cascade into" and pass: @Valid cascades, it does not require. + // LaraFly flattens the cascade into dot-prefixed keys (`beneficiary.street`), and Illuminate runs IMPLICIT + // rules — the `required` behind #[NotBlank] — whether or not the key exists, so the nested requirement + // still fires from under a null parent. + // + // That is NOT the defect fixed here, and the fix deliberately does not reach it: the null contract is + // per-PROPERTY, deciding what a present-but-null value means for the constraints declared ON that + // property, and `beneficiary` declares none of its own. Suppressing a whole subtree would mean rewriting + // each nested presence rule against its parent (`required` -> `required_with:beneficiary`), a separate + // change with its own blast radius across the web layer's hydration. Pinned here so it stays a decision + // rather than drifting. + $payload = [ + 'account' => 'GB82WEST12345698765432', + 'amount' => '19.99', + 'reference' => 'Invoice 42', + 'beneficiary' => null, + ]; + + try { + ValidatorHarness::beanValidator(MoneyTransferRequest::class)->validate($payload, MoneyTransferRequest::class); + throw new RuntimeException('expected the nested requirement to fire'); + } catch (ValidationException $e) { + $fields = array_map(static fn ($fieldError) => $fieldError->field, $e->fieldErrors()); + + expect($fields)->toContain('beneficiary.street') + // ...and the null contract still holds for the top-level properties beside it. + ->and($fields)->not->toContain('account'); + } +}); + +it('withholds the null contract from a property whose declared type cannot hold null', function () { + // The contract is Jakarta's, but its precondition is PHP's: null is "a valid value for every constraint + // but @NotNull" only where null is a value the property can actually take. A non-nullable `string` has + // already refused null in its type, so #[Email] keeps rejecting an explicit null there — otherwise the + // payload would pass validation and then die in the web layer's `new $dto(...$named)` with a TypeError, + // downgrading a 422 into a 500. The nullable twin beside it still gets the skip. + expect(strictFailures(['required' => null, 'optional' => 'ada@example.test']))->toBe(['required']) + ->and(strictFailures(['required' => 'ada@example.test', 'optional' => null]))->toBe([]) + ->and(strictFailures(['required' => 'ada@example.test', 'optional' => 'nope']))->toBe(['optional']); + + $rules = (new ConstraintScanner)->scan(StrictContactPayload::class); + expect($rules['required'])->not->toContain('nullable') + ->and($rules['optional'][0])->toBe('nullable'); +}); diff --git a/packages/validation/tests/Constraint/SizeLengthSemanticsTest.php b/packages/validation/tests/Constraint/SizeLengthSemanticsTest.php new file mode 100644 index 0000000..98971fa --- /dev/null +++ b/packages/validation/tests/Constraint/SizeLengthSemanticsTest.php @@ -0,0 +1,60 @@ + $data + * @return list the fields that failed (empty === the payload validated) + */ +function sizeFailures(array $data): array +{ + try { + ValidatorHarness::beanValidator(SizedCodePayload::class)->validate($data, SizedCodePayload::class); + + return []; + } catch (ValidationException $e) { + return array_values(array_unique(array_map(static fn ($fieldError) => $fieldError->field, $e->fieldErrors()))); + } +} + +it('keeps #[Size] on LENGTH semantics when a sibling constraint emits the numeric rule', function () { + // 7 satisfies #[Min(5)] as a NUMBER and violates #[Size(min: 3)] as a LENGTH ("7" is one character). + // Under Laravel's polymorphic between:/min:/max:, the sibling `numeric` rule flipped getSize() to the + // value, so 3 <= 7 <= 8 held and the payload sailed through: a silently unenforced length constraint. + expect(sizeFailures(['code' => 7, 'label' => 'ok']))->toBe(['code']); +}); + +it('does not let the numeric rule invent a #[Size] violation either', function () { + // The mirror image, and the more damaging half: 12345678 is eight characters, comfortably inside + // #[Size(min: 3, max: 8)], but as a NUMBER it dwarfs the max of 8 — so the old assembly rejected a + // perfectly valid payload with a message about a length the value never violated. + expect(sizeFailures(['code' => 12345678, 'label' => 'ok']))->toBe([]); +}); + +it('measures strings, arrays and countables by size and leaves untyped siblings alone', function () { + expect(sizeFailures(['code' => 'abcde', 'label' => 'ok']))->toBe(['code']) // 'abcde' is not numeric => #[Min(5)] + ->and(sizeFailures(['code' => '12345', 'label' => 'abcd']))->toBe([]) + ->and(sizeFailures(['code' => '12345', 'label' => 'abcde']))->toBe(['label']); +}); + +it('compiles #[Size] to a first-party rule object rather than a polymorphic rule string', function () { + $rules = (new ConstraintScanner)->scan(SizedCodePayload::class); + + $sizeRules = array_values(array_filter($rules['code'], static fn ($rule) => $rule instanceof SizeRule)); + expect($sizeRules)->toHaveCount(1) + ->and($rules['code'])->not->toContain('between:3,8'); + + $size = $sizeRules[0]; + expect($size->min())->toBe(3)->and($size->max())->toBe(8); +}); + +it('emits no rule at all for an unbounded #[Size]', function () { + expect((new Size)->toRules())->toBe([]); +}); diff --git a/packages/validation/tests/Fixtures/Constraint/CustomRulePayload.php b/packages/validation/tests/Fixtures/Constraint/CustomRulePayload.php new file mode 100644 index 0000000..23e6042 --- /dev/null +++ b/packages/validation/tests/Fixtures/Constraint/CustomRulePayload.php @@ -0,0 +1,23 @@ + Class] envelope and rehydrate with its constructor state thrown away. + */ +final class CustomRulePayload +{ + public function __construct( + #[Rules(new StartsWith('ACME-'))] + public readonly string $sku, + #[Rules(new NormalisingRule('urgent'))] + public readonly string $tag, + ) {} +} diff --git a/packages/validation/tests/Fixtures/Constraint/OptionalContactPayload.php b/packages/validation/tests/Fixtures/Constraint/OptionalContactPayload.php new file mode 100644 index 0000000..def91f0 --- /dev/null +++ b/packages/validation/tests/Fixtures/Constraint/OptionalContactPayload.php @@ -0,0 +1,28 @@ + $this->cutoff) { + $fail('The :attribute must not be after the cutoff.'); + } + } +} diff --git a/packages/validation/tests/Fixtures/Rule/NormalisingRule.php b/packages/validation/tests/Fixtures/Rule/NormalisingRule.php new file mode 100644 index 0000000..50d2c1b --- /dev/null +++ b/packages/validation/tests/Fixtures/Rule/NormalisingRule.php @@ -0,0 +1,39 @@ + rehydrate is stable. + */ +final class NormalisingRule implements Compilable, ValidationRule +{ + private readonly string $needle; + + public function __construct(string $needle) + { + $this->needle = mb_strtoupper($needle); + } + + /** + * @return list + */ + public function constructorArguments(): array + { + return [$this->needle]; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! str_contains(mb_strtoupper($value), $this->needle)) { + $fail("The :attribute must contain {$this->needle}."); + } + } +} diff --git a/packages/validation/tests/Fixtures/Rule/OpaqueConstructorRule.php b/packages/validation/tests/Fixtures/Rule/OpaqueConstructorRule.php new file mode 100644 index 0000000..3940496 --- /dev/null +++ b/packages/validation/tests/Fixtures/Rule/OpaqueConstructorRule.php @@ -0,0 +1,31 @@ +needle = $needle; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! str_contains($value, $this->needle)) { + $fail("The :attribute must contain {$this->needle}."); + } + } +} diff --git a/packages/validation/tests/Fixtures/Rule/StartsWith.php b/packages/validation/tests/Fixtures/Rule/StartsWith.php new file mode 100644 index 0000000..eaa161b --- /dev/null +++ b/packages/validation/tests/Fixtures/Rule/StartsWith.php @@ -0,0 +1,34 @@ +caseSensitive + ? str_starts_with($subject, $this->prefix) + : str_starts_with(mb_strtolower($subject), mb_strtolower($this->prefix)); + + if (! $matches) { + $fail("The :attribute must start with {$this->prefix}."); + } + } +} diff --git a/packages/validation/tests/Fixtures/ValidatorHarness.php b/packages/validation/tests/Fixtures/ValidatorHarness.php new file mode 100644 index 0000000..fff631a --- /dev/null +++ b/packages/validation/tests/Fixtures/ValidatorHarness.php @@ -0,0 +1,38 @@ +toArray(array_values($classes)); + + return new BeanValidator( + new IlluminateValidator(new IlluminateFactory(new Translator(new ArrayLoader, 'en'))), + ConstraintManifest::fromArray($rows), + ); + } +} 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 @@ +/.../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/Dispatch/ResponseFactory.php b/packages/web/src/Dispatch/ResponseFactory.php index 90f7a9a..a3b4083 100644 --- a/packages/web/src/Dispatch/ResponseFactory.php +++ b/packages/web/src/Dispatch/ResponseFactory.php @@ -6,21 +6,43 @@ use Firefly\Web\Http\MessageConverterRegistry; use Firefly\Web\Route\RouteDescriptor; +use Firefly\Web\View\ModelAndView; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Contracts\Support\Renderable; use Illuminate\Contracts\Support\Responsable; +use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; /** * Content-negotiates a controller return. An already-built Response (Symfony OR Illuminate — incl. - * JsonResponse) or a Responsable passes through untouched; any other value - * (array/JsonSerializable/Arrayable/scalar) is written by the MessageConverter chosen from the request - * Accept header and wrapped in a Response carrying $status if given, else the descriptor's default status. - * Return type is the Symfony HttpFoundation Response supertype so a JsonResponse passthrough type-checks. + * JsonResponse) or a Responsable passes through untouched; a View / ModelAndView / Htmlable / Renderable is + * rendered as text/html; any other value (array/JsonSerializable/Arrayable/scalar) is written by the + * MessageConverter chosen from the request Accept header and wrapped in a Response carrying $status if given, + * else the descriptor's default status. Return type is the Symfony HttpFoundation Response supertype so a + * JsonResponse passthrough type-checks. + * + * HTML RENDERING. The view branch did not exist before. A Blade View is 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. That made server-rendered HTML impossible in a framework that otherwise + * advertises itself as a home for "any kind of project", and it is why there was no welcome page: there was + * no way to render one. */ final class ResponseFactory { - public function __construct(private readonly MessageConverterRegistry $converters) {} + /** + * @param ViewFactory|null $views the application's view factory, used only to resolve a ModelAndView's + * view NAME. Null when illuminate/view is not installed (a JSON-only + * deployment, or a unit test) — returning a ModelAndView then fails loud + * rather than silently rendering nothing. + */ + public function __construct( + private readonly MessageConverterRegistry $converters, + private readonly ?ViewFactory $views = null, + ) {} /** * @param int|null $status status override for the negotiated body (e.g. a matched #[ExceptionHandler] @@ -36,6 +58,20 @@ public function make(mixed $result, RouteDescriptor $descriptor, Request $reques return $result->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/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 @@ + $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 @@ +` 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 '' + .'' + .''.$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 = ''; + 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..0220c90 --- /dev/null +++ b/packages/web/src/Error/ErrorPageRenderer.php @@ -0,0 +1,126 @@ +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 = '', + private readonly ?ViewFactory $views = null, + ) {} + + /** 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; + } + + // 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->forcesJson($request)) { + return false; + } + + $accept = (string) $request->headers->get('Accept', ''); + + 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); + $status = $exception->httpStatus(); + + $report = ErrorReport::of( + $e, + $request, + $this->settings, + $this->basePath, + $status, + ProblemMapper::statusText($status), + (new DateTimeImmutable)->format(DateTimeInterface::ATOM), + ); + + return new Response( + $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 new file mode 100644 index 0000000..ec5fdf3 --- /dev/null +++ b/packages/web/src/Error/ErrorPageSettings.php @@ -0,0 +1,136 @@ + $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( + enabled: $config->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', + 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/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..603b261 --- /dev/null +++ b/packages/web/src/Error/ProblemMapper.php @@ -0,0 +1,83 @@ + $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( + $disclose && $e->getMessage() !== '' ? $e->getMessage() : self::OPAQUE, + '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/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/Exception/ProblemDetailsRenderer.php b/packages/web/src/Exception/ProblemDetailsRenderer.php index 28e94f5..f295206 100644 --- a/packages/web/src/Exception/ProblemDetailsRenderer.php +++ b/packages/web/src/Exception/ProblemDetailsRenderer.php @@ -6,48 +6,38 @@ 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\ErrorPageSettings; +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 { + /** + * 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 = 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, - ), - }; + // 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, @@ -61,21 +51,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/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/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 6811f54..8ddeb6c 100644 --- a/packages/web/src/WebServiceProvider.php +++ b/packages/web/src/WebServiceProvider.php @@ -4,27 +4,34 @@ namespace Firefly\Web; +use Firefly\Config\Config; 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; 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; 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; 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; @@ -54,12 +61,51 @@ 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))); + } + + 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() : ''; + + // 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); + }); + } + if (! $this->app->bound(MessageConverterRegistry::class)) { $this->app->singleton(MessageConverterRegistry::class, static fn (): MessageConverterRegistry => new MessageConverterRegistry([new JsonMessageConverter])); } 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)) { @@ -71,11 +117,36 @@ 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:: + // 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,10 +170,28 @@ 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)); + }); } } + /** + * 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 { @@ -111,7 +200,13 @@ private function registerProblemDetailsRenderable(): void } $handler->renderable(function (Throwable $e, Request $request) { - if ($e instanceof FireflyException || $request->expectsJson()) { + $page = $this->app->make(ErrorPageRenderer::class); + + if ($page->handles($request)) { + return $page->render($e, $request); + } + + if ($e instanceof FireflyException || $request->expectsJson() || $page->forcesJson($request)) { return $this->app->make(ProblemDetailsRenderer::class)->render($e, $request); } 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/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 new file mode 100644 index 0000000..d81fc9b --- /dev/null +++ b/packages/web/tests/Error/ErrorPageTest.php @@ -0,0 +1,247 @@ + 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>'); +}); + +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(); +}); + +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(); +}); + +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); +}); 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 @@ + $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; + } + + /** + * Illuminate's contract allows both with('k', $v) and with(['k' => $v]). + * + * @param array|string $key + */ + public function with($key, $value = null): self + { + if (is_array($key)) { + $this->data = [...$this->data, ...$key]; + + return $this; + } + + $this->data[$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..e5aa89c --- /dev/null +++ b/packages/web/tests/Fixtures/View/RecordingViewFactory.php @@ -0,0 +1,75 @@ +:", 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; + } + + /** + * @param array $data + * @param array $mergeData + */ + public function file($path, $data = [], $mergeData = []): ViewContract + { + return $this->make($path, $data, $mergeData); + } + + /** + * @param array $data + * @param array $mergeData + */ + public function make($view, $data = [], $mergeData = []): ViewContract + { + /** @var array $data */ + return new RecordingView((string) $view, $data); + } + + /** @param array|string $key */ + public function share($key, $value = null): mixed + { + return $value; + } + + /** + * @param array|string $views + * @return array + */ + public function composer($views, $callback): array + { + return []; + } + + /** + * @param array|string $views + * @return array + */ + public function creator($views, $callback): array + { + return []; + } + + /** @param array|string $hints */ + public function addNamespace($namespace, $hints): self + { + return $this; + } + + /** @param array|string $hints */ + public function replaceNamespace($namespace, $hints): self + { + return $this; + } +} 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 diff --git a/packages/web/tests/Route/ScannedNestedBodyTest.php b/packages/web/tests/Route/ScannedNestedBodyTest.php new file mode 100644 index 0000000..6506213 --- /dev/null +++ b/packages/web/tests/Route/ScannedNestedBodyTest.php @@ -0,0 +1,125 @@ +> */ +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/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']]; + } +} diff --git a/packages/web/tests/View/HtmlRenderingTest.php b/packages/web/tests/View/HtmlRenderingTest.php new file mode 100644 index 0000000..410805a --- /dev/null +++ b/packages/web/tests/View/HtmlRenderingTest.php @@ -0,0 +1,102 @@ +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]. Asserting the class +// hierarchy would be statically trivial; what actually matters is that a REFLECTION lookup for RestController +// finds a #[Controller] attribute — that is the mechanism the scan relies on. +it('makes #[Controller] discoverable by the existing #[RestController] attribute scan', function () { + $probe = new ReflectionClass(HtmlProbeController::class); + + expect($probe->getAttributes(RestController::class, ReflectionAttribute::IS_INSTANCEOF))->toHaveCount(1) + ->and($probe->getAttributes(Controller::class, ReflectionAttribute::IS_INSTANCEOF))->toHaveCount(1); +}); + +#[Controller] +final class HtmlProbeController +{ + public function index(): string + { + return 'probe'; + } +} 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/skeleton/.env.example b/skeleton/.env.example index 2a14d32..bb8288a 100644 --- a/skeleton/.env.example +++ b/skeleton/.env.example @@ -8,6 +8,64 @@ 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 + +# --------------------------------------------------------------------------- +# 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..a80e34b 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 @@ -18,15 +18,50 @@ 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 -- `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. + +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 + +`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 +71,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/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 @@ + */ - #[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/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 @@ +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} + */ + #[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 @@ + $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 @@ +` 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 $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/Http/WelcomeController.php b/skeleton/app/Http/WelcomeController.php new file mode 100644 index 0000000..bbefc0c --- /dev/null +++ b/skeleton/app/Http/WelcomeController.php @@ -0,0 +1,270 @@ +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), + '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. + * + * 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 + { + // 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' => $moved ? null : '/'.$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' => $moved ? null : $admin->url(), + 'label' => 'Dashboard', + 'blurb' => 'Health, beans, the bean graph, 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. + * + * 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/Orders/Address.php b/skeleton/app/Orders/Address.php new file mode 100644 index 0000000..e733aba --- /dev/null +++ b/skeleton/app/Orders/Address.php @@ -0,0 +1,28 @@ + $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); + } + + /** @return array{id: int|null, customer: string, email: string, shipTo: Address, lines: list, total: float} */ + 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/OrderEntity.php b/skeleton/app/Orders/OrderEntity.php new file mode 100644 index 0000000..c81aaca --- /dev/null +++ b/skeleton/app/Orders/OrderEntity.php @@ -0,0 +1,57 @@ + */ + protected $fillable = ['customer', 'email', 'ship_to', 'total']; + + /** @return array */ + protected function casts(): array + { + return [ + 'ship_to' => '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/OrderLine.php b/skeleton/app/Orders/OrderLine.php new file mode 100644 index 0000000..6d07f2e --- /dev/null +++ b/skeleton/app/Orders/OrderLine.php @@ -0,0 +1,31 @@ +quantity * $this->unitPrice, 2); + } +} 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/OrderRepository.php b/skeleton/app/Orders/OrderRepository.php new file mode 100644 index 0000000..2dec2c3 --- /dev/null +++ b/skeleton/app/Orders/OrderRepository.php @@ -0,0 +1,56 @@ + {}` — expressed the way PHP can express it. + * + * 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 + */ +#[Repository] +class OrderRepository extends EloquentRepository +{ + /** @var class-string */ + protected string $model = OrderEntity::class; + + /** + * Every order placed by one address, newest first — parsed from this name, not from a body. + * + * @return list + */ + public function findByEmailOrderByIdDesc(string $email): 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 new file mode 100644 index 0000000..0ec0e51 --- /dev/null +++ b/skeleton/app/Orders/OrderService.php @@ -0,0 +1,208 @@ +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 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, 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. + * + * 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] +class OrderService +{ + public function __construct( + private readonly OrderRepository $orders, + private readonly OrderLineRepository $lines, + ) {} + + /** + * @return array{page: int, size: int, total: int, items: list} + */ + 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' => $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->toDomain($this->row($id)); + } + + #[Transactional] + public function place(Order $order): Order + { + $row = new OrderEntity; + $row->fill($this->toRow($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. 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 + { + $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, 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 : []; + + $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(), + (string) $row->customer, + (string) $row->email, + new Address( + (string) ($shipTo['street'] ?? ''), + (string) ($shipTo['city'] ?? ''), + (string) ($shipTo['postcode'] ?? ''), + (string) ($shipTo['country'] ?? ''), + ), + array_values($lines), + ); + } + + /** + * 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 + */ + private function toRow(Order $order): array + { + 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, + ], + 'total' => $order->total(), + ]; + } +} 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 @@ - [ '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, + + /* + | 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'. + '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'), + // ], + + /* + | 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, + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | 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, + // + // /* + // | 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', + // ], + // ], + // ], + + /* + |-------------------------------------------------------------------------- + | 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, refresh-seconds 10, + | theme 'auto', graph.max-nodes 220, pages.exclude ''. + | + */ + + // 'admin' => [ + // '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', + // ], + // + // /* + // | 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', + // + // /* + // | 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), + // ], + // ], + + /* + |-------------------------------------------------------------------------- + | 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. `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 + // | 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 + |-------------------------------------------------------------------------- + | + | 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), + ], + + /* + | 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/*'], + ], + ], + + /* + |-------------------------------------------------------------------------- + | 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/skeleton/config/logging.php b/skeleton/config/logging.php index b09cb25..f689f3a 100644 --- a/skeleton/config/logging.php +++ b/skeleton/config/logging.php @@ -1,5 +1,7 @@ id(); + $table->string('customer'); + $table->string('email'); + $table->json('ship_to'); + // Derived from the lines by the domain, stored so the column can be sorted, summed and reported + // 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/phpunit.xml b/skeleton/phpunit.xml new file mode 100644 index 0000000..849532e --- /dev/null +++ b/skeleton/phpunit.xml @@ -0,0 +1,21 @@ + + + + + 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. --}} + + + + +
    + +
    + + + Running +

    Hello, {{ $appName }}

    +

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

    +
    + +
    +

    Your routes

    +
    + @forelse ($routes as $route) + @if (str_contains($route['path'], '{')) +
    + {{ $route['method'] }} + {{ $route['path'] }} + {{ class_basename($route['controller']) }} + +
    + @else + + {{ $route['method'] }} + {{ $route['path'] }} + {{ class_basename($route['controller']) }} + + + @endif + @empty +

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

    + @endforelse + +
    +
    + +
    +

    Next steps

    +
    + @foreach ([ + ['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 +
    +
    + +
    +

    What is running

    +
    + @foreach ($tools as $tool) + @if ($tool['href'] === null) + {{-- Moved to the management port: described, never linked, because the link would 404. --}} +
    + {{ $tool['label'] }} + {{ $tool['blurb'] }} + port {{ $managementPort }} +
    + @else + + {{ $tool['label'] }} + {{ $tool['blurb'] }} + {{ $tool['href'] }} + + @endif + @endforeach +
    + + @if ($managementPort !== null) +

    + Management traffic is on port {{ $managementPort }}. The actuator and the dashboard + answer only there, so they are not reachable from this page. Run + php artisan firefly:serve --management alongside your application to reach them + in development. +

    + @endif +
    + +
    +

    Learn

    + +
    + + @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 }} +
    + +
    + + + + 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/OrderTest.php b/skeleton/tests/Feature/OrderTest.php new file mode 100644 index 0000000..e4d5467 --- /dev/null +++ b/skeleton/tests/Feature/OrderTest.php @@ -0,0 +1,279 @@ + $overrides + * @return array + */ + 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')); + + // 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, + ]); + + // 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 + { + $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'); + + // 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]); + + // 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']); + } + + /** + * 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 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, 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); + $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 + { + // 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 new file mode 100644 index 0000000..f6f1023 --- /dev/null +++ b/skeleton/tests/Feature/WelcomeTest.php @@ -0,0 +1,53 @@ +get('/'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + $response->assertSee('Hello,', false); + $response->assertSee('Your routes', false); + $response->assertSee('What is running', false); + } + + public function test_the_sample_rest_controller_returns_json(): void + { + $this->getJson('/greetings/Ada') + ->assertOk() + ->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') + ->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 @@ + $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)); +}); 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 @@ +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 */ +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 */ +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; +} 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; +} 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 @@ + $psr4 */ + $psr4 = is_array($declared) ? $declared : []; + + foreach ($psr4 as $prefix => $relative) { + $root = $package.'/'.rtrim($relative, '/'); + if (! is_dir($root)) { + continue; + } + + /** @var iterable $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([]); +}); 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 = [];