diff --git a/.changelog/fix-optional-callable-event-composition.md b/.changelog/fix-optional-callable-event-composition.md new file mode 100644 index 0000000..2293483 --- /dev/null +++ b/.changelog/fix-optional-callable-event-composition.md @@ -0,0 +1,6 @@ +--- +category: fixed +type: patch +--- + +Skip structural autowiring failure for an unconfigured optional callable reporter so the default major aggregate still delivers PSR-14 events. Preserve configured reporter order and explicit factory failures, and document the container's remaining concrete fallback boundary. diff --git a/.changelog/modernize-error-handler.md b/.changelog/modernize-error-handler.md new file mode 100644 index 0000000..13d7860 --- /dev/null +++ b/.changelog/modernize-error-handler.md @@ -0,0 +1,59 @@ +--- +category: changed +type: major +--- + +Modernize the library for PHP 8.3+ while retaining the package +`coisa/error-handler` and namespace `CoiSA\ErrorHandler`. +- Require PHP `^8.3`, with a tested CI matrix of PHP 8.3–8.5. +- Require FastForward Container `^1.10` and expose the Interop service provider as the + container entrypoint. Remove the standalone Container/ConfigProvider APIs, + their package-specific exceptions and constructor-only factories. Keep explicit + lifecycle/composition factories under `ServiceProvider\Factory`. +- Keep construction and discovery inert. Preserve cached services and provide + explicit fresh handler/middleware factories. Require reverse-order unregistration, + retain one shutdown callback per handler and notify only the active owner. +- Exit 1 after successful registered uncaught-exception reporting. Preserve direct + calls without termination; propagate reporter failures without retry during a + failed registration while allowing independent direct deliveries. +- Separate HTTP reporting from rendering. Middleware requires both contracts, + reports once and then invokes a pure response factory. Use a generic stream by + default, standard reason phrases and integer HTTP error statuses through 599. +- Keep explicit public output capture as an opt-in renderer adapter. Preserve `0`, + use a generic fallback for empty output, clean reachable buffers after success and failure, + and retain renderer-failure precedence over cleanup failures. If no capture + remains above the caller's entry depth, use the generic fallback without reading caller output. +- Retain delivered 2.x handlers and native release tooling. Add distribution checks + and organized PHPUnit Unit/Functional/Process coverage. Cover partial callback + ownership, fail-fast reporter identity and native fatal diagnostic metadata. + Preserve the configurable 80% overall coverage floor and real comparisons; + retire the prototype Fire/HTTP/Python parents and custom release publisher. +- Check all complete cookbook recipes against executable source, retaining + the exact inventory and omitting only each leading license header. +- Ship the canonical `resources/skills/coisa-error-handler` package with + configuration-first examples for the Interop provider and pure + renderer contracts. Preserve its license and optional installer metadata, and + require the complete consumer payload in distribution checks. +- Add a direct native regression for output-handler destruction that replaces its + removed buffer, proving bounded cleanup, primary-failure and caller ownership. +- Preserve the JSON, optional Whoops and explicit reporting adapters published in + 2.5.0. Compose pure renderers after the middleware report; retain trusted debug + configuration, lazy page overrides and fresh middleware scopes without a second + diagnostic delivery. Consolidate response profiles into the request-aware + provider described in the companion negotiation entry. +- Accept matching callables in all three ErrorHandler constructor inputs using + existing adapters. Preserve interface identity and precedence, required reporter, + optional null defaults, inert construction and the existing reporting policies. + Keep explicit provider factories for cached services and fresh scopes. +- Refresh Composer development-tool ranges to the current stable lines, retaining + PHP 8.3 support through PHPUnit 12.5 and Symfony Process 7.4 alternatives. + Require Nyholm 1.8.2 in development for its PHP 8.5 fixes; retain the compatible + PSR ranges and optional Whoops 2.18.7 minimum with its upstream native STDIN source-read fix. + The Whoops compatibility fix preserves its renderer contract; the lifecycle and + HTTP-default changes above apply to this major. +- Describe the accompanying API without temporary PR or publication status, + and clarify that diagnostic reporter output remains outside the PSR-7 body. +- Correct callable-filtering guidance, clarify generic stream and fatal-reporting + contracts, and distinguish checkout recipes from bundled application examples. + Keep installed documentation links usable and release instructions current. +- Keep available representations and diagnostic authorization under application control. diff --git a/.changelog/negotiate-the-html-response-charset.md b/.changelog/negotiate-the-html-response-charset.md new file mode 100644 index 0000000..e006834 --- /dev/null +++ b/.changelog/negotiate-the-html-response-charset.md @@ -0,0 +1,8 @@ +--- +category: fixed +type: patch +--- + +Negotiate the built-in HTML renderer using its actual UTF-8 media type. Honor +charset-specific Accept preferences and exclusions instead of choosing HTML when +its charset is explicitly forbidden or falling back when UTF-8 HTML is accepted. diff --git a/.changelog/request-aware-response-negotiation.md b/.changelog/request-aware-response-negotiation.md new file mode 100644 index 0000000..35f8be1 --- /dev/null +++ b/.changelog/request-aware-response-negotiation.md @@ -0,0 +1,26 @@ +--- +category: changed +type: major +--- + +Negotiate error responses from the current request through a single +`ErrorHandlerServiceProvider`. Replace the JSON and Whoops profile providers with +lazy Problem Details JSON, plain-text and Symfony HTML representations. Accept a required +`ServerRequestInterface` alongside the original throwable in every response +factory and forward it unchanged after one middleware report. + +Keep diagnostic output disabled by default. Explicit debug configuration enables +JSON diagnostics and Symfony's detailed HTML, while production HTML remains a +generic status page. Keep Whoops available only through explicit application +composition, without changing the default based on installed optional packages. +Respect Accept quality and exclusions, preserve existing Vary fields and keep +selection independent across requests that reuse the same cached service. + +Serialize Problem Details with Symfony Serializer, preserving public HTTP status +codes and keeping trace arguments and objects out of debug responses. Support +compatible Symfony component majors without requiring a matching VarDumper major. +Exclude older component patches that emit native PHP 8.4+ deprecations, including +incompatible transitive VarDumper versions, without adding a direct dependency. +Offer an explicit Symfony CLI reporter through the same provider, writing to +STDERR or the application's Console error output without installing native hooks. +Demonstrate PSR-14 reporting with FastForward Event Dispatcher and its provider. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index cc81bfa..35d2e39 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -21,7 +21,7 @@ jobs: issues: read statuses: write - # Official v0 compatibility branch; reviewed against v0.2.0. + # Official v0 compatibility branch; reviewed against v0.3.0. uses: php-fast-forward/changelog/.github/workflows/changelog.yml@v0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 846ea25..f1733b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,65 @@ jobs: - name: Run formatting, PHPCS, PHPUnit and maximum src-plus-tests analysis run: composer --no-plugins check + symfony-compatibility: + name: Symfony ${{ matrix.error-handler }} / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - php: '8.3' + error-handler: '~5.4.0' + serializer: '~5.4.0' + http-foundation: '~6.4.0' + - php: '8.3' + error-handler: '~6.4.0' + serializer: '~6.4.0' + http-foundation: '~6.4.0' + - php: '8.4' + error-handler: '~7.0.0' + serializer: '~7.0.0' + http-foundation: '~7.4.0' + - php: '8.5' + error-handler: '~8.0.0' + serializer: '~8.0.0' + http-foundation: '~8.1.0' + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Isolate Composer state + run: | + echo "COMPOSER_HOME=$RUNNER_TEMP/composer-home" >> "$GITHUB_ENV" + echo "COMPOSER_CACHE_DIR=$RUNNER_TEMP/composer-cache" >> "$GITHUB_ENV" + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, xml, xmlwriter + ini-values: error_reporting=E_ALL + coverage: none + tools: composer:v2 + + - name: Resolve the supported Symfony combination + env: + ERROR_HANDLER: ${{ matrix.error-handler }} + SERIALIZER: ${{ matrix.serializer }} + HTTP_FOUNDATION: ${{ matrix.http-foundation }} + run: | + composer update --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress \ + --with "symfony/error-handler:$ERROR_HANDLER" \ + --with "symfony/serializer:$SERIALIZER" \ + --with "symfony/http-foundation:$HTTP_FOUNDATION" + composer check-platform-reqs + composer show --locked + + - name: Verify the adapters and application composition + run: composer --no-plugins check + production: name: Production / PHP ${{ matrix.php }} / ${{ matrix.dependencies }} runs-on: ubuntu-latest @@ -70,10 +129,10 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + php: ['8.3', '8.4', '8.5'] dependencies: [highest] include: - - php: '7.4' + - php: '8.3' dependencies: lowest - php: '8.5' dependencies: lowest @@ -246,3 +305,4 @@ jobs: with: header: phpunit-coverage path: ${{ steps.coverage-diff.outputs.file }} + diff --git a/.gitignore b/.gitignore index e518196..23547c9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /composer.lock .idea/ vendor/ +/tmp/ tests/html/ tests/clover.xml tests/cobertura.xml diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 334fdd9..e45e760 100755 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -25,8 +25,12 @@ $header = HeaderExtractor::extractFromFile(__FILE__); return PhpCsFixer::create($paths, $header, [ - 'trailing_comma_in_multiline' => [ - 'after_heredoc' => true, - 'elements' => ['arguments', 'array_destructuring', 'arrays'], + // PER3 sections 4/4.4 recommend concise empty bodies (SHOULD); keep multiline bodies + // to converge with complete PSR12 checking, including braced namespaces. + // https://github.com/php-fig/per-coding-style/blob/3.0.0/spec.md#44-methods-and-functions + 'single_line_empty_body' => false, + 'braces_position' => [ + 'allow_single_line_anonymous_functions' => false, + 'allow_single_line_empty_anonymous_classes' => false, ], ]); diff --git a/AGENTS.md b/AGENTS.md index 35d0cc8..21de048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,57 +1,128 @@ -# Error Handler 2.x maintenance +# Error Handler maintenance -Maintain `coisa/error-handler` under `CoiSA\ErrorHandler` as a small PHP library. Code, documentation, examples and agent guidance are English. +## Purpose -## Compatibility and ownership +Maintain a small PHP error-handling library with explicit reporting, process +lifecycle and PSR-15 response boundaries. The current Composer package is +`coisa/error-handler`, under the `CoiSA\ErrorHandler` namespace. -- Preserve the declared PHP `^7.4 || ^8.0` range and existing 2.x public classes, interfaces, factories, ConfigProvider and Laminas discovery metadata. Keep executable example syntax compatible with PHP 7.4. -- Use the actual selected source and Composer version as authority. The proposed major has different container, middleware, rendering and exit contracts; do not present that proposal or a pending release note as a published release. -- Applications own reporters, PSR-14 dispatchers, PSR-17 factories, HTTP emission and when native PHP callbacks are installed. Resolving a service must not install callbacks. -- Preserve the existing container integration. FastForward adoption, removal of legacy classes and API changes require their own explicitly reviewed scope. -- Callbacks are process-wide. Use distinct handlers for nested scopes and restore in reverse order. Direct reporting, registered uncaught exceptions and fatal shutdown reporting are different paths; do not promise recovery or an automatic nonzero exit. -- The existing HTTP stream adapter captures reporter output and falls back to exception text for empty or false-like output. Keep those limitations visible; never describe it as a safe default or pure renderer. -- Treat runtime fixes, formatting, dependency policy, CI, documentation and publication as separate deliveries. Do not widen one to hide another failure. +## Ownership -## Work and verification +The repository owns its core handlers, PSR adapters, interoperability provider, +tests, documentation and consumer skill. Applications own reporters, PSR-14 and +PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed. -Inspect branch/HEAD, tracked/untracked/ignored state, applicable contracts and relevant interfaces before changes. Preserve unrelated work and local ignored Composer state. Do not add a library lockfile. +## Local Contracts -Use Composer 2 dependency installs with `--no-plugins --no-scripts`, then run `composer --no-plugins check`. Development integrations may need a newer PHP than the library minimum: verify minimum runtime separately through a clean no-dev consumer. Report actual PHP/Composer/dependency versions, the tested SHA and existing failures honestly. +- Write code, public documentation, examples and agent guidance in English. +- Identify the public consumer skill as `coisa-error-handler`, matching its + Composer package, and retain the source repository link for copied installs. +- Declare PHP 8.3+ support (`^8.3`); the tested CI matrix is PHP 8.3–8.5. + Document the API and behavior of the accompanying code in its current state. + Do not add temporary PR, branch or unreleased-status disclaimers that require + an edit after merge/release. Keep migration and release history versioned where relevant. +- Keep core handlers independent of FastForward. The service provider uses + `Interop\Container\ServiceProviderInterface` and FastForward's reusable factory + classes. FastForward Container is a required runtime dependency and owns + service resolution, caching and autowiring; do not add a parallel container. +- Constructing or discovering a service must not call `register()`. Native PHP + callbacks are process-wide. Unregister in reverse order and use distinct + handlers for nested scopes. Follow [the lifecycle contract](docs/api.md). +- FastForward caches services. Keep the middleware handler separate from the + process handler and use the provider's factory services for fresh scopes. + Prefer `container($application, ErrorHandlerServiceProvider::class)` for normal + dependency and optional-reporter composition. For required overrides, merge + application definitions after library definitions in one + `AggregateServiceProvider`: separate providers prioritize the first and may + silently fall back on PSR errors. +- Support container integration through the Interop service provider only; + do not restore the removed ConfigProvider or framework-specific adapters. + Keep all provider factories under `ServiceProvider\Factory`. Preserve explicit + factories for reporter composition, optional handlers and fresh middleware + scopes; use autowiring for plain constructor injection. A possible + move to php-fast-forward does not itself change package identity or namespace. +- HTTP middleware requires lifecycle and throwable-reporting contracts on the same + scoped handler. Report once, then pass the original Throwable and current + ServerRequestInterface to a pure response renderer. The single provider selects + request-aware negotiation: JSON Problem Details or Symfony HTML when accepted, generic fallback + otherwise. Trusted debug enables details without forcing a representation. + Whoops is optional and only selected by an explicit application override; + its installation never changes defaults. Keep output capture + an explicit public-renderer adapter; never report diagnostics again during rendering. +- CLI rendering is explicit through SymfonyCliThrowableHandler or the provider's cli flag; + do not infer it from SAPI or add a second Console exception renderer. +- Explicit Log/Filtering/CallablePHP/BestEffort adapters remain opt-in; the default + reporter aggregate stays fail-fast and adds no implicit discovery for them. +- Reporters own diagnostic output; HTTP fallback bodies must not leak exception + details. A shutdown callback reports a fatal error and does not recover it. -Keep source, meaningful regression coverage, affected examples/guides, the source-linked `coisa-error-handler` consumer skill and an independent `.changelog/*.md` fragment aligned. Never install the skill globally as part of maintenance. The Fast Forward Changelog workflow prepares the reviewed version PR; do not consolidate history or consume pending notes in ordinary contributions. +## Work Guidance -Run `php examples/verify.php` with the documented application dependencies after example or documented-contract changes. The verifier owns bounded child processes, including fatal experiments. Do not run fatal fixtures in an agent, application, real server or parent test process. Use synthetic records and disposable consumer dependencies; never application credentials or production logs. +Inspect the current branch and relevant interfaces before changes. Update +source, meaningful regression tests, examples, affected guides and an independent +`.changelog/*.md` fragment together. Keep runtime code independent of release tooling. Keep +the skill aligned with tested public APIs, without copying the full manual or +adding host-specific dependencies. The Fast Forward Changelog workflow prepares +reviewed version PRs; do not consolidate history or consume pending fragments in +ordinary contributions. Composer manages development skill copies through +`skills.json`; keep generated `.agents/` instructions ignored and never install +skills globally as part of repository maintenance. -Check local links, quoted-example parity and the actual CI result for the exact candidate SHA. Runtime `src/` and Composer requirements must remain unchanged in a documentation-only delivery. +Run fatal-error and process-termination experiments only through bounded child +fixtures. Use the PHPUnit Process suite with the existing bounded fixtures and Symfony Process. Never +use application credentials, production logs or a real web server as fixtures. -Merging, releasing, tagging, transferring the repository, publishing Pages or deploying requires the owner's corresponding instruction. Preparing a PR or passing CI does not perform those actions. +Publishing Pages, merging, releasing or transferring the repository requires +the owner's corresponding instruction. Preparing a PR does not perform them. + +## Verification + +Use Composer 2 with `--no-plugins --no-scripts` for dependency installation; +then run `composer --no-plugins check`. This runs formatting, PHPCS, PHPUnit Unit/Functional/Process suites and maximum +PHPStan over source and tests. Run the existing bounded example verifier and static +site validator after documentation/example changes. See [testing](docs/testing.md) +for PHP-version resolution, application dependencies and actual platform evidence. +Run `php examples/fast-forward.php` after provider changes. Verify a production +installation without development dependencies still loads the core and provider. +Check documentation links and inspect the actual CI result for the tested SHA. ## Bundled consumer skill Read [the consumer skill](resources/skills/coisa-error-handler/SKILL.md) when changing application integration examples, consumer guides or the skill. Keep -its supported version contract, references and executable examples aligned with -the public API. The canonical published package is under `resources/skills`; -`.agents/skills` is a consumer discovery destination, not a second source copy. -This source link needs no installed copy or host alias. Repository maintenance -and verification continue to follow this AGENTS.md. +its source-version contract, references and executable examples aligned with +the accompanying public API. The canonical package is under +`resources/skills`; `.agents/skills` is a consumer discovery destination, not a +second source copy. This source link needs no installed copy or host alias. +Repository maintenance and verification continue to follow this AGENTS.md. ## Child DOX Index - [SECURITY.md](SECURITY.md): latest-stable security support and private vulnerability reporting; ordinary bugs remain public issues. -- [README.md](README.md): consumer installation, quick start and guide navigation; distinguishes API family from pending release state. -- [docs/api.md](docs/api.md): reporters, core lifecycle and process behavior; no recovery or exit guarantees. -- [docs/container.md](docs/container.md): legacy container, ConfigProvider, factories and cached scopes; applications supply external implementations. -- [docs/http.md](docs/http.md): existing PSR-15/capture behavior and safe application composition; the library never emits responses. -- [docs/examples.md](docs/examples.md): complete current-2.x recipes, checked against executable source; excludes proposed major APIs and retains existing setup/verification boundaries. -- [docs/index.html](docs/index.html): static 2.x reading surface; source excerpts track executable examples and Pages publishes the owner-configured branch's `/docs` folder. -- [examples/README.md](examples/README.md): runnable recipes for every built-in adapter with explicit application dependencies. -- [examples/verify.md](examples/verify.md): bounded verifier inputs, outputs, child-process effects and recovery. -- [docs/testing.md](docs/testing.md): direct PHPUnit contracts, isolated native process cases, installed-package checks and honest coverage limits; no custom test runner. -- [CONTRIBUTING.md](CONTRIBUTING.md): library dependency resolution and clean production-container checks. -- [docs/releases.md](docs/releases.md): independent fragments, reviewed version PRs, Git-tag version authority and publication boundaries. -- [docs/agent-skill.md](docs/agent-skill.md): optional project/global skill distribution, trust and lifecycle; copying does not establish host activation. -- [resources/skills/coisa-error-handler/SKILL.md](resources/skills/coisa-error-handler/SKILL.md): portable consumer integration procedure, bundled examples and supported API contract; keeps its own package entrypoint and source identity. -- [skills.json](skills.json): Composer-managed development skill sources; installation creates ignored local agent instructions without changing the library's bundled consumer skill. - -No subtree needs an additional AGENTS.md. Guides and the skill retain their own procedures without duplicating repository authority. +- [README.md](README.md): installation and entrypoints for consumers. +- [docs/api.md](docs/api.md): core lifecycle, reporters and PSR composition. +- [docs/fast-forward.md](docs/fast-forward.md): provider definitions, overrides, + cached services and fresh middleware scopes. +- [docs/examples.md](docs/examples.md): all nineteen complete recipes, checked + byte-for-byte against executable source with only the leading license header omitted; + preserve setup boundaries and matching website excerpts. +- [docs/releases.md](docs/releases.md): independent fragments, reviewed version PRs, + immutable release tags and Composer delivery boundaries. +- [docs/testing.md](docs/testing.md): PHPUnit/process tests, commands and evidence limits. +- [CONTRIBUTING.md](CONTRIBUTING.md): dependency resolution and quality entrypoints. +- [examples/README.md](examples/README.md): executable recipe dependencies and verifier. +- [docs/cli.md](docs/cli.md): explicit Symfony terminal reporting and one Console exception owner. +- [docs/http.md](docs/http.md): pure JSON/Whoops rendering, explicit capture, + reporting and diagnostic-output limits. +- [docs/migration.md](docs/migration.md): breaking changes from published 2.x. +- [docs/agent-skill.md](docs/agent-skill.md): optional project/global skill + distribution, trust and lifecycle; copying does not establish host activation. +- [resources/skills/coisa-error-handler/SKILL.md](resources/skills/coisa-error-handler/SKILL.md): + portable consumer integration procedure, bundled examples and source-version + contract; its package procedure stays in its own entrypoint. + +- [skills.json](skills.json): Composer-managed Changelog, Container and Config skill + sources; generated local instructions are ignored and do not replace the bundled consumer skill. + +No subtree needs a separate maintenance contract; the skill's procedure stays +in its SKILL.md entrypoint. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ab38b6..a3640cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,271 +1,59 @@ -# Contributor dependencies +# Contributing -This library intentionally does not track `composer.lock`. Composer constraints -in `composer.json` describe its supported dependency contract. Applications that -consume the library own their application lockfiles. - -Use Composer directly for dependency installation and quality checks. -Resolve development dependencies with the PHP runtime intended for the check: +Use PHP 8.3+ and Composer 2. The runtime constraint is `^8.3`; CI currently tests +8.3–8.5. Resolve tools on the actual selected runtime without bypassing platform +requirements or committing the library's local Composer lockfile: ```sh composer update --no-plugins --no-scripts --no-interaction --prefer-dist - composer validate --strict --no-check-publish composer check-platform-reqs - -composer --no-plugins check -``` - -Composer creates an ignored local lockfile. Keep it to repeat that local -resolution with `composer install --no-plugins --no-scripts`; use `composer update` -when deliberately testing a fresh dependency resolution. Record `php --version`, -`composer --version` and `composer show --locked` with validation evidence. -Do not force-add the local lockfile to a PR. - -A normal development `composer install` also synchronizes the Changelog agent -skill configured in `skills.json` through the `llm/skills` plugin. The isolated -commands above deliberately skip this step; run `composer skills:update` if you -want the local instructions afterward. Generated `.agents/` files stay ignored. -See the [project tooling skill](docs/releases.md#project-tooling-skill) for details. - -Development integrations can have narrower PHP requirements than the library's -runtime `^7.4 || ^8.0`. Resolve them on a compatible runner and verify the supported -minimum runtime through a separate consumer without development dependencies. -Do not bypass platform requirements. CI checks the complete development graph -on PHP 8.3–8.5; the formatter preset requires PHP 8.3, and the Laminas integration -also prevents resolving the complete development graph on PHP 7.4. A production -consumer must resolve only the library's runtime dependencies when checking -that minimum. - -The optional Interop providers are tested with the real -`fast-forward/container:^1.9` development dependency in the normal PHPUnit and -PHPStan checks. FastForward requires PHP 8.3; it is not a core runtime requirement. -The Production jobs create an external consumer from `git archive`, so the -library's `require-dev` is not transitively installed on PHP 7.4. An application -that chooses these providers must install FastForward in its own `require`, not -only `require-dev`, to retain its container in production. - -## Quality checks - -The `coisa/php-cs-fixer:^3.0` preset supplies the formatter policy. A local override -limits multiline trailing commas to arguments, destructuring and arrays; -parameter and `match` commas require PHP 8.0. The preset's automatic PHP migration -target follows the Composer minimum, and its remaining rules are retained. -The opening file header in `.php-cs-fixer.dist.php` is the canonical header -template, read through the preset's `HeaderExtractor::extractFromFile(__FILE__)`. -The preset updates the ending copyright year when formatting. -The superseded PHP-CS-Fixer 2 `.php_cs` configuration is removed. Composer -commands and CI use `.php-cs-fixer.dist.php`; pass that same path when invoking -the formatter explicitly. The old configuration called `Config::create()`, -which the installed PHP-CS-Fixer 3 API does not provide. -Run each check independently when diagnosing a failure: -`composer check` stops at the first failed command. - -```sh -composer --no-plugins cs-check -composer --no-plugins sniff -composer --no-plugins test -composer --no-plugins analyze -``` - -`test` runs all unit and functional cases in isolated PHP processes without -requesting coverage. `test-coverage` runs the same suite and writes the configured -Clover, HTML and text reports; it requires Xdebug or PCOV. A successful ordinary -test run is not coverage evidence. PHPUnit's configuration targets its 9.x -schema. The explicit `phpspec/prophecy-phpunit:^2.1` bridge supplies -`ProphecyTrait`; PHPUnit's deprecated built-in bridge is not used. - -PHPStan remains at maximum level over both `src` and `tests`. -`jangregor/phpstan-prophecy:^1.0` supplies the extension compatible with PHPStan -1.x, loaded from its package-root `extension.neon`. A single callback-signature -exception is bounded by `argument.type`, `src/ErrorHandler.php` and an expected -count of one. The process callback intentionally returns `void`/`null`: PHP -delegates to its native handler only when a callback returns strictly `false`, -as documented by [the PHP manual](https://www.php.net/manual/en/function.set-error-handler.php). -Unmatched exceptions remain errors; do not broaden this rule to other callbacks. - -Review formatter diffs as code changes. The shared preset can make test data -providers static, add compatible iterable return types, make unbound closures -static, order methods and replace late-static assertion calls in final test -classes. Verify that providers do not use instance state and closures do not -depend on binding. Qualifying native functions also requires checking for local -namespace overrides. Preserve the public runtime API and PHP 7.4 syntax. - -Dependency installation in a fresh checkout resolves a local lockfile; subsequent -installs can reuse it. CI must record the actual resolution for its runner, and -cache keys must not require a committed Composer lockfile. - -Add one independent `.changelog/*.md` fragment for observable changes, including -dependencies and workflows. The [release process](docs/releases.md) describes the -format, PR checks and reviewed version PR. Release automation supplies its own -runtime; it adds no PHP or Node dependency to this library. - -## Native development environment - -Use an installed PHP runtime and Composer 2 for the commands above. The removed -Docker development wrappers used PHP 7.2 and Xdebug 2.6, below the library's -declared PHP 7.4 minimum. They wrapped Composer and PHPUnit; they were not a -supported runtime image for consumers. - -For an existing local resolution, install dependencies without enabling plugins -or scripts, then run the native quality chain: - -```sh -composer install --no-plugins --no-scripts --no-interaction --prefer-dist -composer --no-plugins check -``` - -The native workflows described below do not build a repository Docker image. -Record the actual workflow/SHA and local runtime results; an active workflow -name alone does not prove a runtime matrix was executed. - -## Continuous integration - -The [PHP CI workflow](.github/workflows/ci.yml) runs on pull requests, -pushes to `master` and manual dispatches. Each job records the actual PHP, -Composer and resolved package versions. No Composer lockfile or simulated PHP -platform is needed. - -| Jobs | Actual PHP versions | Dependency resolution and checks | -| --- | --- | --- | -| Development quality | 8.3, 8.4, 8.5 | Fresh current development graph; formatting, PHPCS, PHPUnit and maximum PHPStan over `src` and `tests` | -| Production | 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 | Current runtime graph; tracked PHP syntax, distribution contents, public container and alias factories | -| Production lower bounds | 7.4, 8.5 | Fresh `--prefer-lowest --prefer-stable` runtime graph; the same production checks | -| PHPUnit coverage | 8.5 with Xdebug | Fresh development graph; Clover/Cobertura, baseline comparison and Markdown | - -Production jobs install only the library into a separate consumer from its -committed `git archive`, with path-package mirroring instead of a source symlink. -The consumer requires only `coisa/error-handler`, so PSR-11 must be supplied -transitively. Lower-bound jobs exercise the earliest allowed runtime packages; -current-resolution jobs exercise the newest compatible packages available to -each actual PHP version. The recorded graph is the evidence for those bounds, -not a claim that every possible dependency combination was tested. - -Composer 2 installations disable plugins and scripts; quality scripts run only -in their explicit check step. Quality jobs explicitly use `error_reporting=E_ALL` -so deprecations can also reach the configured PHP handlers. Jobs have time limits -and no persisted checkout credential. Collection and validation jobs use read-only -repository permissions; the report job requests PR-comment access. The workflow uses the verified -major action refs documented by [Checkout](https://github.com/actions/checkout) -and [Setup PHP](https://github.com/shivammathur/setup-php). The independent changed -PHP workflow retains the shared preset's `3.0.1` reusable workflow ref. - -Quality and production jobs disable coverage drivers. A green quality job proves -the checks it ran, without claiming coverage or permission to release. - -## PHPUnit coverage and PR notes - -The [PHP CI workflow](.github/workflows/ci.yml) has a read-only `coverage` job -on PHP 8.5/Xdebug and a dependent `coverage-report` job. PHPUnit 9.6 produces -Clover plus Cobertura through its native `--coverage-cobertura` flag. The complete -`src/` filter, runtime PHP constraint and public APIs remain unchanged. -`coverage-report-php` retains both XML files for 14 days. - -The comparison action receives Cobertura to measure lines. Its -[Clover parser](https://github.com/clearlyip/code-coverage-report-action/blob/v7/src/reports/clover/parser/index.ts) -combines statements, methods and conditionals: the initial 12/254 elements -(4.72%) differ from **9/191 lines (4.71%)**. That initial 2.x measurement used -101 tests and 199 assertions on PHP 8.5.11. A new listener regression test proves -the original throwable is returned even when its callback returns a replacement. -Its specific `@covers` scope raises the suite to **12/191 lines (6.28%)** with -102 tests and 201 assertions. Those are historical starting measurements. Direct -unit tests now measure the individual runtime classes; composition tests retain -`@coversNothing`. Genuine native termination runs as PHPUnit Process tests, with -external children outside the parent's measurement. The retired standalone -Fire/HTTP runners are replaced by direct assertions and the few bounded native -fixtures described in [testing](docs/testing.md). Read each current report's -exact SHA and metric rather than treating those historical counts as current. - -The action uses its standard templates and permits coverage decreases against the -target-branch baseline. It fails when reported overall line coverage is below the -repository variable `MINIMUM_COVERAGE_THRESHOLD`, defaulting to **80%** when that -variable is unset. The variable can override the default; this workflow creates -or changes no repository variables. With the default, 80% passes the floor and -79% fails, even if both are decreases. Execution, invalid-report and artifact -failures remain failures independently of this policy. - -The floor applies to the complete measured source, not each class or changed file. -Cobertura's overall line rate weights lines across that source; it is not an -arithmetic mean of file percentages. Changed files, their colors and positive or -negative differences remain visible in the report, without individual drop gates. -See the action's [independent threshold and difference inputs](https://github.com/clearlyip/code-coverage-report-action/blob/v7/action.yml). - -After successful collection, the report job downloads this run's XML artifact -and generates the job summary and `coverage-pr-comment` Markdown artifact -(14-day retention). Pushes and manual runs on `master` store the Cobertura -baseline as `phpunit-coverage-master` for 90 days. Before that baseline exists, -or after it expires, the report shows current coverage without a comparison. -The minimum still applies without a baseline, but no difference can be displayed. -A manual `PHP CI` run on `master` refreshes it. A manual run on another branch -stores a baseline for that actual branch, allowing a stacked PR targeting it to -compare before the workflow reaches `master`. - -The same report job updates one sticky note only for PRs from this repository, -excluding branches starting with `dependabot/`, when its token permits comments. -Fork and Dependabot runs retain useful artifacts but do not receive an automatic -coverage comment through this workflow. There is no privileged fallback workflow. -If a threshold check fails after producing Markdown, the report and eligible PR -note still update while the job remains failed. - -Each report identifies the event's head SHA, the tested commit (which can be the -PR merge commit), and the run URL. A failed or cancelled collection skips the -report job and may leave an older note; read its displayed SHA rather than -assuming it certifies the current head. The workflow does not make an atomic -head check when commenting. Authentic master baselines and eligible sticky-note -updates have been observed; each new result still needs its own SHA/run check. - -## Production container regression - -The public container requires the `psr/container` interface package at runtime; -it does not require another container implementation. Its existing `string $id` -parameters support PSR-11 `^1.1 || ^2.0`. Version 1.0 has untyped parameters and -cannot be implemented by those narrower signatures. - -Run the production smoke test against a clean consumer built from the committed -Composer distribution, without development dependencies or an application -package that could incidentally supply PSR-11: - -```sh -repository="$PWD" -fixture=$(mktemp -d) -mkdir "$fixture/package" "$fixture/consumer" -git archive HEAD | tar -x -C "$fixture/package" -export COMPOSER_HOME="$fixture/composer-home" -export COMPOSER_CACHE_DIR="$fixture/composer-cache" -cd "$fixture/consumer" -composer init --no-interaction --name=coisa/production-fixture -composer config repositories.error-handler path "$fixture/package" -composer require 'coisa/error-handler:*@dev' --no-dev --no-plugins --no-scripts --no-interaction -composer check-platform-reqs --no-dev -php "$repository/tests/Production/container.php" "$fixture/consumer/vendor/autoload.php" composer show --locked +composer --no-plugins check ``` -The smoke test constructs the core handler, resolves the existing container's -aliases and factories, invokes `ConfigProvider`, and checks both PSR-11 exception -interfaces. It does not install PHP callbacks. `git archive` honors the package's -distribution exclusions, so the smoke test is run from the source checkout. - -Repeat in fresh fixture directories on the actual minimum PHP 7.4 runtime and -modern PHP runtimes. For dependency bounds, add an exact `psr/container` -requirement to the consumer, testing `1.1.0`, the latest `1.1`, `2.0.0`, and the -latest `2.0`. Keep at least one consumer requiring only the library to verify -transitive runtime installation. Version `1.0.0` must fail dependency resolution. -Record actual runtime and resolved package versions; a simulated Composer -platform alone does not prove runtime compatibility. - -Before installing an exported package, inspect its committed archive: - -```sh -git archive --format=tar --output=/absolute/temporary/package.tar HEAD -php tests/Production/archive.php /absolute/temporary/package.tar -``` - -This checks archive paths and bytes against the selected source, including every -runtime file. Then run the clean consumer and production container smoke above. - -## Optional development HTML dependency - -The development graph includes `filp/whoops:^2.18.4` to verify the opt-in HTML adapter. Consumer runtime requirements remain unchanged: applications install Whoops only when selecting development HTML, and ordinary no-dev core/container installations need none of it. Record actual lower/current Whoops resolutions and PHP versions when verifying the bridge. The [HTML guide](docs/http.md#opt-in-development-html-with-whoops) describes explicit activation, page configuration and diagnostic disclosure limits. - -The optional Whoops profile is checked with the same real FastForward graph as JSON. It defers page lookup until rendering and FastForward caches the result; applications may prepare their configured page before composition. Middleware scope construction creates no additional page. Check sequential diagnostic replacement and persistent application configuration, not a fresh-page requirement. Verify installed applications both without Whoops (inert profile construction, useful diagnostic through the recommended aggregate, documented separate-provider fallback) and with FastForward/Whoops in the application's runtime requirements under `--no-dev`. Use the existing bounded verifier's `--with-fast-forward` option for the full provider recipes; no separate runner is needed. +A normal development `composer install` synchronizes the Changelog and trusted +FastForward skills configured in `skills.json` through the `llm/skills` plugin. +The isolated commands above disable plugins and deliberately skip synchronization. +Run `composer skills:update` when you want those local instructions afterward; +generated `.agents/` files remain ignored. See the +[project tooling skill](docs/releases.md#project-tooling-skill) for details. + +The CoiSA 3.x formatter preset remains the base. A documented local +`single_line_empty_body: false` exception and conservative anonymous-class brace +option keep empty bodies multiline so the complete PSR12 sniff still checks +namespace braces. PER3 sections 4/4.4 make concise +bodies a SHOULD recommendation; no mandatory rule or PHPCS diagnostic is removed. +The former PHP7.4 trailing-comma restriction is removed: the PHP8.3 floor supports +the preset's parameter and match contexts. Review the formatter's +diff as executable code; the migration target must remain the Composer minimum. +Do not format vendor files or introduce a newer PHP syntax than the runtime floor. +PHPUnit uses its 12.5/13.x attribute API, maximum PHPStan includes `src` and `tests`, +and Prophecy uses the installed bridge/analysis extension. Unit tests target real +behavior; dangerous native termination belongs in the bounded PHPUnit Process +suite. The [testing guide](docs/testing.md) describes commands and evidence limits. + +## Coverage reporting + +The inherited PHP CI policy permits overall decreases while requiring +`MINIMUM_COVERAGE_THRESHOLD`, defaulting to **80% overall** when unset. Baseline +comparisons, file differences/colors and sticky reporting remain in place; +confirm which baseline each run selected. The minimum applies to instrumentable +source lines globally, not to each class or file; 100% line coverage does not prove +every branch or behavior. +Execution, invalid-report and artifact failures remain independent failures. +See [coverage and current evidence](docs/testing.md#coverage-and-current-evidence). + +Source changes need complete English PHPDoc, meaningful regression tests, affected +examples/guides/consumer skill and an independent `.changelog/*.md` fragment. +The [release process](docs/releases.md) covers the note format and reviewed version +PRs; its reusable workflow supplies its own runtime. Preserve the application-owned reporter, callback +registration and HTTP emission boundaries. Do not restore removed Container or +ConfigProvider APIs; use the Interop provider. + +Verify examples with `php examples/verify.php`, documentation source parity with +`php tests/Documentation/site.php`, and an installed no-dev archive consumer with +the [production procedure](tests/Production/README.md). Record actual PHP/package +versions and exact local/remote/PR SHAs. A configured matrix or historical sticky +note is not current proof. See [release maintenance](docs/releases.md); merging, +releasing and publishing remain the owner's decisions. diff --git a/README.md b/README.md index e920992..1668c8e 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,16 @@ PHP callbacks, PSR-3 logging, PSR-14 events or PSR-15 HTTP middleware when neede ## 📦 Installation ```sh -composer require 'coisa/error-handler:^2.4' --no-plugins --no-scripts +composer require coisa/error-handler ``` -Requires PHP `^7.4 || ^8.0` and Composer 2. Applications supply their logger, -event dispatcher, HTTP factories and response emitter as needed. +Use Composer 2 with PHP `^8.3`; CI targets +PHP 8.3–8.5. FastForward Container `^1.10` is a runtime dependency. Applications supply +their logger, dispatcher, HTTP factories and response emitter as needed. -This README describes the 2.x API. Check `composer show coisa/error-handler` -and the [release history](CHANGELOG.md) for your installed version. +Match the documentation to your installed package and its [release history](CHANGELOG.md). +For an upgrade from 2.x, follow the [migration guide](docs/migration.md) and +compare the [2.x API](https://github.com/coisa/error-handler/tree/v2.5.0). ## 🛠️ Quick start @@ -31,22 +33,19 @@ Create `report.php` beside your application's `vendor/` directory: declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandler; -use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; require __DIR__ . '/vendor/autoload.php'; $reports = []; -$reporter = new CallableThrowableHandler( - /** - * Retain the synthetic failure without public diagnostic output. - * - * @param Throwable $error the failure to report - */ - static function (Throwable $error) use (&$reports): void { - $reports[] = $error; - }, -); +/** + * Retain the synthetic failure without public diagnostic output. + * + * @param Throwable $error the failure to report + */ +$reporter = static function (Throwable $error) use (&$reports): void { + $reports[] = $error; +}; $errors = new ErrorHandler($reporter); $failure = new RuntimeException('Example failure'); @@ -61,55 +60,63 @@ installs no PHP callbacks, and direct reporting returns when the reporter returns. Reporter failures propagate. Replace the in-memory reporter with your application's private diagnostic sink. +All three constructor inputs accept the matching handler interface or a callable; +omitted PHP-error and shutdown inputs retain their defaults. Existing interface +objects keep their identity. See the [complete three-callback recipe](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php). + ## 🧰 Choose an integration | Need | Entry point and guide | | --- | --- | | Report a throwable | `ErrorHandler::handleThrowable()` and [reporter composition][link-reporters]. | +| Format terminal diagnostics | [Symfony CLI handler and Console ownership](docs/cli.md). | | Handle warnings or uncaught exceptions | `register()` / `unregister()` and [native callbacks][link-lifecycle]. | | Send diagnostics to a logger or dispatcher | [PSR-3 logging][link-logging] and [PSR-14 events][link-events]. | | Filter or fan out | [Filtering][link-filtering], [aggregates][link-aggregates] and [best effort][link-best-effort]. | -| Use a PSR-11 or Laminas application | [`ErrorHandlerContainer`, `ConfigProvider` and factories](docs/container.md). | +| Use a container | [Interop/FastForward provider, overrides and fresh scopes](docs/fast-forward.md). | | Return an HTTP response | [PSR-15 middleware and PSR-17 composition](docs/http.md). | -| Return JSON with one private report | [Opt-in JSON rendering](docs/http.md#opt-in-json-responses-and-explicit-reporting) and [complete recipe](examples/http-json.php). | -| Use Interop/FastForward providers | [Optional JSON profile](docs/container.md#optional-interop-and-fastforward-providers) and [complete provider recipe](examples/provider-json.php). | -| Render development HTML | [Optional Whoops renderer](docs/http.md#opt-in-development-html-with-whoops) and [manual recipe](examples/http-whoops.php). | -| Configure Whoops services | [Optional Whoops profile](docs/container.md#development-html-profile) and [complete provider recipe](examples/provider-whoops.php). | - -JSON/Whoops response adapters and Interop providers were published in 2.5.0; -select `coisa/error-handler:^2.5` for those routes and verify the installed source. -The bundled skill keeps its common `^2.4` baseline. FastForward examples require -application PHP 8.3+, PSR-17 factories and a private reporter; core and legacy -integrations retain PHP 7.4 support. - -Whoops is an explicit development choice. Applications own trusted debug -activation and format selection. +| Return JSON with one private report | [Opt-in JSON rendering](docs/http.md#opt-in-json-responses-and-explicit-reporting) and [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/http-json.php). | +| Use Interop/FastForward providers | [Request-aware provider](docs/fast-forward.md#request-aware-responses) and [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). | +| Return public or diagnostic HTML | [Symfony HTML](docs/http.md#public-html) and [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/http-html.php). | +| Select optional Whoops diagnostics | [Development HTML configuration](docs/fast-forward.md#development-html-configuration) and [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). | + +One Interop provider supplies lifecycle services and request-aware responses. +It selects JSON Problem Details or Symfony HTML when accepted and a generic fallback otherwise. Trusted +debug enables diagnostic HTML/JSON; optional Whoops requires an explicit override. Debug never forces HTML. +Applications supply PSR-17 implementations and a private reporter through +`container($application, ErrorHandlerServiceProvider::class)`. + +Every response factory receives the original Throwable and current +ServerRequestInterface. Middleware reports once before rendering; do not add the +2.x reporting decorator. Direct JSON rendering uses Symfony Serializer; JSON and Whoops renderers remain available for +explicit application pipelines. ## Lifecycle and HTTP boundaries - PHP callbacks are process-wide. Use distinct handlers for nested scopes and - unregister in reverse order. Shutdown callbacks remain queued after - `unregister()`; repeated cycles are not a bounded worker scope manager. -- Reporting does not recover a fatal error or guarantee a nonzero exit for an - uncaught exception. Applications own their process exit policy. -- The built-in container caches handlers and starts with an empty reporter - aggregate. Configure diagnostics explicitly; a fresh middleware wrapper can - still share its cached handler. Service resolution installs no callbacks. -- The built-in HTTP stream factory captures reporter output. Empty output or - `"0"` falls back to exception text, and the response factory retains valid - exception messages as reason phrases. A silent private logger therefore does - not produce a safe public response. Use the - [explicit application response recipe](docs/http.md#a-safe-application-response) - for a private report and fixed public body. The application emits the response. - -See the [HTTP contract](docs/http.md#existing-response-and-output-capture) for -status-code mapping, reason validation and output-buffer cleanup limits. + unregister in reverse order. Only the active owner receives shutdown reporting; + PHP still retains registered shutdown callbacks until process exit. +- A successfully reported globally uncaught throwable exits with status 1. + Direct `handleThrowable()` calls return normally. Fatal errors remain fatal. +- FastForward caches services and discovery installs no callbacks. Middleware + has a separate handler; invoke the reusable factory for each independent scope. +- Middleware reports once, then passes the current request to its pure renderer. + The default provider negotiates its representation from `Accept`; missing or + unsupported preferences use the generic `Internal Server Error` body. Codes + 400–599 are preserved and reason phrases come from PSR-17. Reporter echo can + still leak outside the response and corrupt emission; keep diagnostics private. +- Output capture is an explicit public-renderer adapter: empty output uses the + generic body and `"0"` remains valid. Reporters must keep diagnostics private. + +See the [HTTP contract](docs/http.md) and [lifecycle guide](docs/api.md) for +failure propagation, callback ownership and output-buffer limits. ## 📚 Guides and examples -- [Complete 2.x cookbook](docs/examples.md), [application setup](examples/README.md) - and [bounded example verification](examples/verify.md). -- [Published documentation][link-docs] and [API reference](docs/api.md). +- [Complete cookbook](docs/examples.md), [application setup](https://github.com/coisa/error-handler/blob/master/examples/README.md) + and [bounded example verification](https://github.com/coisa/error-handler/blob/master/examples/verify.md). +- [Source documentation](https://github.com/coisa/error-handler/blob/master/docs/index.html), [web documentation][link-docs] and + [API reference](docs/api.md). - [Portable `coisa-error-handler` consumer skill](resources/skills/coisa-error-handler/SKILL.md) with complete application examples; [optional installation](docs/agent-skill.md) remains a consumer choice. @@ -121,7 +128,7 @@ status-code mapping, reason validation and output-buffer cleanup limits. For suspected vulnerabilities, follow the [security policy](SECURITY.md) and use the private reporting channel. Ordinary bugs and feature requests belong in issues. -Follow the [contributor guide](CONTRIBUTING.md) for dependency setup and +Follow the [contributor guide](https://github.com/coisa/error-handler/blob/master/CONTRIBUTING.md) for dependency setup and `composer --no-plugins check`. See [testing](docs/testing.md) for process fixtures and platform evidence, and the [release process](docs/releases.md) for independent release notes and reviewed version PRs. Release automation uses the Fast Forward @@ -134,17 +141,17 @@ Changelog reusable workflow without adding a runtime or Node dependency. [ico-php]: https://img.shields.io/packagist/php-v/coisa/error-handler?logo=php&logoColor=white [ico-package]: https://img.shields.io/badge/composer-coisa%2Ferror--handler-F28D1A?logo=composer&logoColor=white [ico-build]: https://img.shields.io/github/actions/workflow/status/coisa/error-handler/ci.yml?branch=master&logo=githubactions&logoColor=white&label=tests -[ico-docs]: https://img.shields.io/badge/docs-2.x-38BDF8?logo=readthedocs&logoColor=white +[ico-docs]: https://img.shields.io/badge/docs-guides-38BDF8?logo=readthedocs&logoColor=white [ico-license]: https://img.shields.io/badge/license-MIT-64748B [link-packagist]: https://packagist.org/packages/coisa/error-handler [link-build]: https://github.com/coisa/error-handler/actions/workflows/ci.yml [link-docs]: https://coisa.github.io/error-handler/ [link-author]: https://github.com/coisa -[link-reporters]: docs/api.md#choose-a-reporter-and-scope -[link-lifecycle]: docs/api.md#native-callbacks +[link-reporters]: docs/api.md#lifecycle-and-diagnostic-reporting +[link-lifecycle]: docs/api.md#lifecycle-and-diagnostic-reporting [link-logging]: docs/api.md#direct-psr-3-logging -[link-events]: docs/api.md#events-and-logging +[link-events]: docs/api.md#callable-reporters-aggregates-and-psr-14 [link-filtering]: docs/api.md#conditional-reporting -[link-aggregates]: docs/api.md#aggregation-and-every-built-in-adapter +[link-aggregates]: docs/api.md#callable-reporters-aggregates-and-psr-14 [link-best-effort]: docs/api.md#best-effort-reporting diff --git a/composer.json b/composer.json index c83595f..7dc3619 100644 --- a/composer.json +++ b/composer.json @@ -14,35 +14,25 @@ "prefer-stable": true, "optimize-autoloader": true, "allow-plugins": { - "llm/skills": true, - "ocramius/package-versions": true + "llm/skills": true } }, - "extra": { - "laminas": { - "config-provider": "CoiSA\\ErrorHandler\\Container\\ConfigProvider" - }, - "skills": { - "source": "resources/skills" - } - }, - "suggest": { - "ext-json": "Required only for debug JSON responses on PHP 7.4; included in PHP 8.", - "fast-forward/container": "^1.9", - "filp/whoops": "Install ^2.18.7 for optional development HTML responses.", - "llm/skills": "Optionally synchronize the bundled agent skill in consumer projects." - }, "require": { - "php": "^7.4 || ^8.0", + "php": "^8.3", "container-interop/service-provider": "^0.4", + "fast-forward/container": "^1.10", "psr/container": "^1.1 || ^2.0", "psr/http-server-middleware": "^1.0", "psr/http-factory": "^1.1", "psr/event-dispatcher": "^1.0", - "psr/log": "^1.1 || ^2.0 || ^3.0" + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/error-handler": "^5.4.46 || ^6.4.47 || ^7.0.10 || ^8.0.15", + "symfony/http-foundation": "^6.4.46 || ^7.4.19 || ^8.1.7", + "symfony/serializer": "^5.4.54 || ^6.4.47 || ^7.0.10 || ^8.0.15" }, "conflict": { - "filp/whoops": "<2.18.7" + "filp/whoops": "<2.18.7", + "symfony/var-dumper": "<5.4.54 || >=6.0 <6.4.48 || >=7.0 <7.0.10 || >=8.0 <8.0.15" }, "autoload": { "psr-4": { @@ -50,31 +40,29 @@ } }, "autoload-dev": { + "exclude-from-classmap": [ + "/tests/Fixtures/compile-fatal.inc" + ], "psr-4": { "CoiSA\\ErrorHandler\\Test\\": "tests/", "CoiSA\\ErrorHandler\\Example\\": "resources/skills/coisa-error-handler/examples/" - }, - "exclude-from-classmap": [ - "/tests/Fixtures/compile-fatal.inc" - ] + } }, "require-dev": { "coisa/php-cs-fixer": "^3.0", - "fast-forward/container": "^1.9", + "fast-forward/event-dispatcher": "^1.0", "filp/whoops": "^2.18.7", - "jangregor/phpstan-prophecy": "^1.0", - "laminas/laminas-servicemanager": "^4.3", + "jangregor/phpstan-prophecy": "^2.3", "llm/skills": "^1.15", - "nyholm/psr7": "^1.8", + "nyholm/psr7": "^1.8.2", "nyholm/psr7-server": "^1.1", - "phly/phly-event-dispatcher": "^1.0", - "phpspec/prophecy": "^1.20", - "phpspec/prophecy-phpunit": "^2.1", - "phpstan/phpstan": "^1.12", - "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "^9.6", - "squizlabs/php_codesniffer": "^3.13 || ^4.0", - "symfony/process": "^5.4 || ^6.4 || ^7.4 || ^8.0" + "phpspec/prophecy": "^1.26", + "phpspec/prophecy-phpunit": "^2.5", + "phpstan/phpstan": "^2.3", + "phpstan/phpstan-strict-rules": "^2.1", + "phpunit/phpunit": "^12.5 || ^13.4", + "squizlabs/php_codesniffer": "^4.0", + "symfony/process": "^7.4 || ^8.1" }, "scripts": { "check": [ @@ -83,8 +71,8 @@ "@test", "@analyze" ], - "analyze": "phpstan analyze --level max ./src ./tests", - "cs-check": "php-cs-fixer fix --dry-run --diff", + "analyze": "phpstan analyze --no-progress", + "cs-check": "php-cs-fixer fix --dry-run --diff --using-cache=no", "cs-fix": [ "php-cs-fixer fix", "phpcbf" @@ -92,5 +80,15 @@ "sniff": "phpcs", "test": "phpunit --no-coverage --colors=always", "test-coverage": "phpunit --colors=always" + }, + "extra": { + "skills": { + "source": "resources/skills" + } + }, + "suggest": { + "llm/skills": "Optionally synchronize the bundled agent skill in consumer projects.", + "filp/whoops": "Install ^2.18.7 for optional development HTML responses.", + "symfony/console": "Optionally route the explicit CLI reporter through the application's Console output." } } diff --git a/docs/agent-skill.md b/docs/agent-skill.md index ce78bdb..aa0fe36 100644 --- a/docs/agent-skill.md +++ b/docs/agent-skill.md @@ -3,10 +3,9 @@ The Composer library ships the complete [`coisa-error-handler` package](../resources/skills/coisa-error-handler/SKILL.md) under `resources/skills/coisa-error-handler`, including its MIT license, -references and executable application examples. It supports the library's -`^2.4` API and PHP `^7.4 || ^8.0`. Earlier API versions are outside that skill -contract. JSON/Whoops/provider routes were published in 2.5.0 and require `^2.5`; -the common skill baseline remains `^2.4`. Inspect `composer show coisa/error-handler` and the actual files; +references and executable application examples. It describes the accompanying +library API and PHP `^8.3`; CI targets PHP 8.3–8.5. Match the skill to the installed +package version and inspect `composer show coisa/error-handler` and its source; do not upgrade a production dependency just to obtain instructions. Shipping a skill does not install it in a discovery directory or activate it in @@ -18,8 +17,8 @@ an explicit consumer choice. ## Optional Composer project synchronization `llm/skills ^1.15` is optional development tooling and requires PHP 8.2+ with -Composer 2. It does not change this library's PHP 7.4 runtime contract. A consumer -on an earlier PHP can use the manual route below. The library declares +Composer 2. The library's PHP 8.3 minimum is stricter; the plugin remains optional, +and the manual route needs no installer. The library declares `extra.skills.source: resources/skills` but does not require or approve the plugin on the consumer's behalf. diff --git a/docs/api.md b/docs/api.md index 1059ea6..c51805a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,166 +1,274 @@ -# Core API and lifecycle in 2.x +# API and composition -These guides describe the 2.x API. Confirm the installed version with `composer show coisa/error-handler` and consult its matching source. The PSR-11 metadata and PHP 7.4 alias corrections are tracked in [#13](https://github.com/coisa/error-handler/issues/13) and [#40](https://github.com/coisa/error-handler/issues/40); container recipes on PHP 7.4 need a checkout or release containing both. +All classes are under `CoiSA\ErrorHandler`. The PHP requirement is `^8.3`; +CI targets PHP 8.3–8.5. Complete runnable integrations are in +the [cookbook](examples.md). -The declared runtime is PHP `^7.4 || ^8.0`. This is a dependency contract, not evidence that every PHP/dependency combination has been tested. Application integrations can impose narrower requirements. - -## Start with one reporting path - -A **reporter** receives a `Throwable` and delivers diagnostics to an application-selected destination. The core `ErrorHandler` connects that reporter to explicit calls or native PHP callbacks. An HTTP response factory has a separate job: creating the response that a client may see. - -1. Follow the [application setup](../examples/README.md#application-dependencies), then run the [complete direct-reporting recipe](examples.md#direct-throwable-reporting). It needs no container, logger implementation or callback registration. Expect `reported: Example failure`, then `continued`. -2. Replace the synthetic output callback with the application's private reporter. Use [direct PSR-3 logging](#direct-psr-3-logging) when the application already has a logger, or [events](#events-and-logging) when its dispatcher owns delivery. -3. Choose [native callbacks](#native-callbacks) only when warnings or uncaught failures should enter that reporting path. Construction and direct reporting do not install them. -4. For a web application, choose its [public HTTP response policy](http.md#a-safe-application-response) before connecting the middleware. Private diagnostics and client-visible content need separate decisions. - -| Responsibility | Library boundary | Application supplies or decides | -| --- | --- | --- | -| Diagnostic delivery | `Handler\ThrowableHandlerInterface` and built-in adapters | Destination, redaction, transport and reporter-failure policy | -| PHP callback lifetime | `ErrorHandlerInterface::register()` / `unregister()` | Bootstrap location, distinct nested scopes and process exit policy | -| Service composition | `Container\ErrorHandlerContainer`, `Container\ConfigProvider`, factories and optional Interop providers | External implementations and required service overrides | -| HTTP response creation | `Http\Message\ThrowableResponseFactoryInterface` | Public content, application status mapping and PSR-17 implementations | -| HTTP delivery | A returned PSR-7 response | Server integration and response emitter | - -The [cookbook](examples.md) contains every complete executable recipe. Read [container integration](container.md) for service lookup, aliases and factories; read [testing](testing.md) for what the repository's checks actually establish. - -## Choose a reporter and scope - -`ThrowableHandlerInterface::handleThrowable(Throwable): void` is the reporting boundary. Applications choose the destination, redaction and delivery policy. `CallableThrowableHandler` adapts a callable; its return value is ignored. For logging through the built-in event adapter, see [events and logging](#events-and-logging). - -`new ErrorHandler($reporter, $phpErrors = null, $shutdown = null)` constructs services without installing PHP callbacks. The defaults are `ThrowErrorExceptionPhpErrorHandler` and `PhpLastErrorShutdownHandler`. Calling `handleThrowable()` directly delegates and returns if the reporter returns. Reporter failures propagate. - -Run [the direct example](https://github.com/coisa/error-handler/blob/master/examples/direct.php) for explicit reporting without global registration. Its synthetic reporter prints a message so the verifier can observe it; use an appropriate private sink in a real application. - -## Direct PSR-3 logging - -`Handler\LogThrowableHandler` is an opt-in 2.x adapter. Use a matching checkout or release containing the class; older installed versions may not provide it. Its constructor accepts an application `LoggerInterface`, a string level defaulting to `LogLevel::ERROR`, and `array` context. - -Each `handleThrowable()` call invokes the logger's `log()` once with exactly `getMessage()`, including an empty message. The original throwable always occupies `context['exception']`, overriding a caller entry without mutating the caller's array. Other context values retain their identity and types; context-key order is not a contract. The level is forwarded unchanged, with no inference from exception codes, PHP severity or HTTP status. The logger decides whether a level is supported. Its identical failure propagates without retry or output from this adapter. - -Construct the reporter and process handler explicitly with the application's logger: +## Lifecycle and diagnostic reporting ```php -$reporter = new CoiSA\ErrorHandler\Handler\LogThrowableHandler($logger); - -$process = new CoiSA\ErrorHandler\ErrorHandler($reporter); - -$process->handleThrowable($failure); +$errors = new ErrorHandler($reporter, $optionalPhpErrorHandler, $optionalShutdownHandler); +$errors->register(); +try { + // Work whose caller owns propagated failures. +} finally { + $errors->unregister(); +} ``` -This does not install callbacks, add a factory or change aggregate discovery. A logger alone in the legacy parent container does not attach this reporter. Read [explicit process composition](container.md#explicit-process-logging) before using container bindings. A private logger is not a public HTTP body renderer; retain the separate [application HTTP response boundary](http.md). - -The [complete logging example](https://github.com/coisa/error-handler/blob/master/examples/logging.php) uses an in-memory recording sink. It verifies the message, level and reserved context policy without a real transport. The existing `LogErrorEventListener` retains its separate event-string behavior below. - -## Conditional reporting - -`new FilteringThrowableHandler($throwableHandler, $predicate)` wraps an explicitly supplied `ThrowableHandlerInterface` reporter. The required `callable(Throwable): bool` predicate runs once per invocation. `true` forwards the exact original object once; `false` skips only that delegate. Repeated calls with the same object remain separate deliveries. The adapter adds no output, callback registration, recursion guard or default filtering policy. - -The predicate must return a boolean: truthy/false-like non-booleans raise `TypeError`. Predicate and delegate failures propagate unchanged, without retry. To invert a condition, supply a predicate that returns its boolean negation. A skipped branch does not recover an operation, change PHP suppression or imply a successful HTTP response; a failure still stops later aggregate reporters. - -The [filtering example](https://github.com/coisa/error-handler/blob/master/examples/filtering.php) routes synthetic PHP deprecations to a selected branch while keeping the general branch. Inspect `ErrorException::getSeverity()` for PHP severity; arbitrary exception codes do not carry that meaning. Use a callable directly for one-off application logic, and the decorator when wrapping an existing reporter or aggregate. - -## Best-effort reporting - -`new Handler\BestEffortThrowableHandlerAggregate($first, $second, ...)` is an explicit alternative to the existing fail-fast `ThrowableHandlerAggregate`. It takes a fixed constructor list of `ThrowableHandlerInterface` reporters. Construction invokes nothing and installs no callbacks. - -Each `handleThrowable($original)` invocation attempts every constructor slot once, in order, with the identical original object. A reporter's `Throwable` failure is retained while later slots run. Duplicate slots remain deliberate separate attempts. Zero reporters and all-success passes return normally. If any reporter fails, including partial success, one `Exception\AggregateReportingException` escapes after all attempts. - -The exception's `getOriginalThrowable()` and inherited `getPrevious()` return the original diagnostic. `getFailures()` returns a nonempty, ordered array of the identical reporter failure objects, including repeated objects. Its constructor requires the original, one failure and optional remaining failures. The summary message is fixed; the stored failures, previous cause and exception string still contain private diagnostics. They are not a safe public HTTP body. - -The [complete synthetic example](https://github.com/coisa/error-handler/blob/master/examples/best-effort.php) catches that exception and records both the original and all failures in memory. An application must choose its own terminal handling and durable sink. This adapter does not retry, deduplicate, add a reentry guard or suppress reporter failures. Application-created recursion remains application-owned. Process termination or an uncatchable engine failure can prevent later attempts; it cannot be collected as an ordinary Throwable. - -The original aggregate, default container composition, `ErrorHandler` constructor and native lifecycle are unchanged. In particular, registered exception/shutdown behavior gains no new delivery or exit guarantee. With the legacy HTTP capture factory, aggregate failure propagates through its existing cleanup path; best-effort reporting does not create a fallback response or redaction policy. See [explicit container composition](container.md) and [HTTP boundaries](http.md). +For process-wide uncaught handling, keep the handler registered through termination, +as in [the CLI example](https://github.com/coisa/error-handler/blob/master/examples/cli.php). PHP executes finally before dispatching +an uncaught exception, so unregistering there returns responsibility to the previous +handler. Direct `handleThrowable()` delivers the original object and returns; the +registered uncaught callback exits 1 when reporting returns normally. -## Native callbacks +Registration installs process-wide PHP callbacks. Unregister in reverse order, +restoring earlier callback identities and severity masks. Previous callbacks are +not automatically called while this handler is active. Re-registering an active +instance is a no-op. PHP retains one shutdown callback per instance; inactive +instances do nothing. Reuse handlers for sequential work and use distinct instances +for nested scopes. These stacks do not provide concurrent-request/fiber isolation. -`ErrorHandlerInterface` defines only `register()` and `unregister()`. `ErrorHandler` also implements throwable, PHP-error and shutdown handling. - -For a whole process, construct one handler, call `register()` at application bootstrap, and leave it registered for uncaught exceptions. For a recoverable operation, catch/report failures in that operation and call `unregister()` in `finally`. PHP executes an unwinding `finally` before invoking the uncaught-exception callback: unregistering there can prevent that handler from seeing the uncaught exception. - -Registration is idempotent while that instance is registered. Unregistering restores the prior native error and exception callbacks. Use distinct objects for nested scopes, unwind in reverse order, and never share a scoped handler across concurrent fibers/requests as if callbacks were local state. - -PHP provides no removal API for registered shutdown functions. Unregistering marks this handler inactive; its queued callback remains. Repeated register/unregister cycles enqueue more callbacks, so do not assume this is a bounded long-running-worker scope manager. - -The [process example](https://github.com/coisa/error-handler/blob/master/examples/process.php) and [bounded verifier](https://github.com/coisa/error-handler/blob/master/examples/verify.md) cover warnings, scoped restoration, uncaught exceptions, custom shutdown and a native fatal error. - -## PHP errors and shutdown - -`ThrowErrorExceptionPhpErrorHandler` checks `error_reporting() & $code`, then throws the library's `Exception\ErrorException`. Disabled/suppressed levels are ignored. `ErrorHandler::handlePhpError()` catches `\ErrorException` and delegates it to the reporter; a reported warning can therefore continue. - -### Callable PHP-error policy - -`Handler\CallablePhpErrorHandler($callback)` is an opt-in additive API: use a checkout/release containing it, not an older installation based only on its 2.x constraint. It invokes the callback once with `(int $code, string $message, string $filename, int $line)` unchanged. It adds no mask check, conversion, output or registration; those choices belong to the callback. Callback failure propagates unchanged. Through `ErrorHandler`, a thrown `\ErrorException` follows the existing reporting boundary; other failures propagate. - -The interface returns `void`, so every callback result, including false/null, is ignored. Returning false here does not delegate to PHP's native error handler. The obsolete fifth native errcontext argument is not part of this four-argument contract. To preserve normal suppression/conversion, explicitly check `error_reporting() & $code` and throw the desired ErrorException in the callback, as the [complete example](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php) does. - -Inject this wrapper as the second argument: `new ErrorHandler($reporter, new CallablePhpErrorHandler($callback))`. The 2.x constructor still requires interface instances; accepting bare callables in its three positions remains [major-only work](https://github.com/coisa/error-handler/issues/70). Null retains the existing defaults. No automatic factory or default container discovery is added. - -`ErrorException::fromPhpError()` preserves message, code, severity, location and the optional previous throwable. `CallableShutdownHandler` adapts an application-owned zero-argument callback. - -`PhpLastErrorShutdownHandler` inspects `error_get_last()` and forwards E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR and E_COMPILE_WARNING to its PHP-error handler. The default outer ErrorHandler reports a converted fatal. This observes a failure; it does not restart or recover PHP. - -The 2.x registered exception callback is the same `handleThrowable()` method used for direct calls. It does not explicitly call `exit(1)`. In the verified CLI fixture, a successfully reported uncaught throwable exits 0, while the native compile fatal exits 255. Applications requiring a specific exit policy must own that policy; do not infer success from reporting or promise recovery after a reporter/shutdown callback itself fails. - -## Aggregation and every built-in adapter - -| Adapter | Existing behavior | +| Contract | Responsibility | | --- | --- | -| `CallableThrowableHandler` | Calls one configured reporter with the throwable. | -| `CallablePhpErrorHandler` | Calls an explicit four-argument PHP-error callback once, ignoring its result; mask/conversion policy belongs to the callback. | -| `LogThrowableHandler` | Explicit PSR-3 adapter: logs the exact message and original exception once; logger failures propagate. | -| `FilteringThrowableHandler` | Evaluates an explicit boolean predicate once; matches forward the same throwable once, nonmatches skip its delegate, failures propagate. | -| `ThrowableHandlerAggregate` | Calls constructor reporters, then attached reporters, in order. An exception stops later deliveries and propagates. There is no best-effort/retry contract. | -| `BestEffortThrowableHandlerAggregate` | Opt-in fixed reporter list: attempts every slot, then throws one AggregateReportingException containing the original and all ordered reporter failures. Zero/all-success passes return normally. | -| `ThrowErrorExceptionPhpErrorHandler` | Converts enabled PHP errors to the library ErrorException. | -| `CallableShutdownHandler` | Calls a configured zero-argument callback. | -| `PhpLastErrorShutdownHandler` | Forwards a matching last fatal/compile/core error; otherwise does nothing. | -| `DispatchThrowableHandler` | Dispatches the original throwable through application-owned PSR-14. | -| `DispatchErrorEventThrowableHandler` | Dispatches a new `ErrorEvent` wrapping the throwable. | +| `ErrorHandlerInterface` | Register and unregister lifecycle callbacks | +| `ThrowableHandlerInterface::handleThrowable(Throwable): void` | Deliver a diagnostic without producing an HTTP representation | +| `PhpErrorHandlerInterface` | Apply policy to PHP severity/message/file/line | +| `ShutdownHandlerInterface` | Handle a shutdown notification | + +The required `$throwableHandler` constructor argument accepts +`ThrowableHandlerInterface|callable`. Optional `$phpErrorHandler` and +`$shutdownHandler` accept their matching interface, a callable or null. The +parameter names and order are unchanged. Interface instances retain their identity +and take precedence over `__invoke()` when an object supports both contracts. +Other callables are wrapped with the existing `CallableThrowableHandler`, +`CallablePhpErrorHandler` or `CallableShutdownHandler`, without being invoked +during construction. Invalid non-callable inputs raise TypeError at that boundary. + +Callbacks receive one Throwable, four PHP-error arguments, or no shutdown +arguments respectively; their return values are ignored. Explicit adapter objects +remain useful when another API requires an interface. Null and omitted options +retain the default policies below, including shutdown reporting through the same +ErrorHandler. The [complete callback recipe](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php) supplies +all three callables and pairs active shutdown notification with cleanup. + +The default `ThrowErrorExceptionPhpErrorHandler` honors `error_reporting()` and +suppression, converts reportable errors and lets the outer handler report them. +Recoverable warnings continue. The default `PhpLastErrorShutdownHandler` inspects +`E_ERROR`, `E_PARSE`, `E_CORE_ERROR` and `E_COMPILE_ERROR`, without recovery. +A fatal inside another shutdown callback aborts the remaining queue even for a +fresh handler; adding generations cannot rescue that engine path. + +Only the active lifecycle handles shutdown, including custom shutdown after an +uncaught throwable. Replacing a callback transfers ownership away. Restore it +before unregistering; otherwise unregister rejects the operation without altering +the native stacks. + +A reporter failure suppresses retries for the current registration. Recursion +through the same handler raises LogicException. Independent unregistered direct +calls retain no failed flag. Unregister/register starts a fresh lifecycle. +No alternative logger or remote retry is installed implicitly. +These failure boundaries handle thrown Throwable objects; an application callback +that calls exit/die or causes a native fatal does not become recoverable. + +## Callable reporters, aggregates and PSR-14 + +`CallableThrowableHandler` adapts `callable(Throwable): mixed` and ignores its +return value. +`ThrowableHandlerAggregate` accepts variadic reporters and `attach()`, delivering +the same object in order and stopping on the first failure. + +`DispatchThrowableHandler` dispatches the original throwable through PSR-14. +`DispatchErrorEventThrowableHandler` dispatches an `ErrorEvent` wrapper. +Under `CoiSA\ErrorHandler\EventDispatcher`, +`ListenerProvider\ThrowableListenerProvider` selects Throwable objects; +`ListenerProvider\ErrorEventListenerProvider` selects the concrete built-in +`Event\ErrorEvent`, not arbitrary implementations of `ErrorEventInterface`. +Both accept variadic callable listeners and `attach()` in order; lookup selects +listeners without invoking them. The application dispatcher performs delivery. + +`Listener\ThrowableCallableListener` and `Listener\ErrorEventCallableListener` +invoke their callbacks, ignore callback results and return the same throwable or +event respectively. Their callback failures propagate. `LogErrorEventListener` +forwards the diagnostic event to an application PSR-3 logger. +`ErrorEvent::getThrowable()` preserves the original object; stringifying the +event includes private diagnostics and must not produce a public HTTP body. +See [the complete event/logger example](examples.md#events). It uses FastForward +EventDispatcher 1.0.0 with this library's own listener-provider instances. +Applications may choose another PSR-14 dispatcher; upstream subscriber, +listener-failure and wrapper behavior has [separately documented limits](testing.md#fastforward-event-dispatcher-integration-limits). -The [adapter example](https://github.com/coisa/error-handler/blob/master/examples/handlers.php) verifies order, fail-fast behavior, error conversion/suppression and callable shutdown. Fatal tests belong only in the bounded verifier. - -## Events and logging - -`ErrorEvent` implements `ErrorEventInterface`; `getThrowable()` returns the original throwable and its string form is that throwable's string form. - -`ThrowableListenerProvider` yields its configured/attached listeners for `Throwable` events. `ErrorEventListenerProvider` filters for the concrete `ErrorEvent` class. Neither is an event dispatcher. - -`ThrowableCallableListener` and `ErrorEventCallableListener` invoke their callback and return the original event. `LogErrorEventListener` calls an application PSR-3 logger with the event string, at ERROR by default or a supplied level. It does not itself provide a logger transport or add an exception context array. - -The [complete event/logging example](https://github.com/coisa/error-handler/blob/master/examples/events.php) uses an application-installed Phly dispatcher, both providers and callable listeners, plus an in-memory PSR-3 logger. Raw and wrapped dispatch are separate deliveries; configure both only when both are intended. - -For explicit public JSON responses, use the separate [pure JSON renderer and reporting decorator](http.md#opt-in-json-responses-and-explicit-reporting). Diagnostic reporters retain their void contract and private-output responsibility; this opt-in composition changes no process lifecycle or existing middleware/default behavior. - -## Extend through the existing contracts - -The core `ErrorHandler`, built-in handlers and HTTP adapters are final. Compose them, adapt callables where supported, or implement the relevant interface in the application. A custom reporter does not need to implement callback registration or HTTP rendering. +## Direct PSR-3 logging -| Extension point | Required operation | Where to supply it | -| --- | --- | --- | -| [ThrowableHandlerInterface](../src/Handler/ThrowableHandlerInterface.php) | `handleThrowable(\Throwable): void` | First `ErrorHandler` constructor argument, an aggregate slot or an explicit reporter decorator | -| [PhpErrorHandlerInterface](../src/Handler/PhpErrorHandlerInterface.php) | `handlePhpError(int, string, string, int): void` | Second constructor argument; owns PHP-error conversion/mask policy | -| [ShutdownHandlerInterface](../src/Handler/ShutdownHandlerInterface.php) | `handleShutdown(): void` | Third constructor argument; owns shutdown observation | -| [ErrorHandlerInterface](../src/ErrorHandlerInterface.php) | `register(): void` and `unregister(): void` | Middleware lifecycle argument; this interface alone has no reporting method | -| [ThrowableStreamFactoryInterface](../src/Http/Message/ThrowableStreamFactoryInterface.php) | `createStreamFromThrowable(\Throwable): StreamInterface` | Built-in response factory's stream dependency | -| [ThrowableResponseFactoryInterface](../src/Http/Message/ThrowableResponseFactoryInterface.php) | `createResponseFromThrowable(\Throwable): ResponseInterface` | Middleware response dependency; owns reporting policy for its caught throwable in 2.x | +`Handler\LogThrowableHandler($logger, $logLevel = LogLevel::ERROR, $context = [])` +retains its compatible 2.x behavior. It calls the application's PSR-3 logger once +with the configured level, the exact throwable message (including an empty string) +and the original object under `context['exception']`. That reserved entry replaces +a caller's exception entry without mutating the caller's array; other context +values retain their identity and types. Context-key order is not a contract. -The HTTP return types above are PSR-7 interfaces. Container factories consume the same contracts; select overrides under the IDs they actually request, as shown in [the alias and factory map](container.md#factory-map-and-scopes). There is no library-wide static accessor or automatic discovery of arbitrary reporter classes. +The level is forwarded unchanged, without inference from PHP severity, exception +code or HTTP status. The logger decides which levels it supports. Logger failure +propagates unchanged; the adapter adds no retry, output or callback registration. +It is not automatically attached when a logger is available in the container. +Construct it explicitly or bind `ThrowableHandlerInterface` using the +[merged-provider override](fast-forward.md#required-overrides). -For events, implement or reuse an application [PSR-14 dispatcher](https://www.php-fig.org/psr/psr-14/) and select its listeners. The library's listener providers return listeners; they do not execute them. For logging, supply a [PSR-3 logger](https://www.php-fig.org/psr/psr-3/) with a configured private destination. +See [the complete logging example](https://github.com/coisa/error-handler/blob/master/examples/logging.php). Unlike this direct +adapter, `LogErrorEventListener` retains its event-string message policy. -## Troubleshoot the reporting path +## Conditional reporting -| Observation | Check next | -| --- | --- | -| Calling `handleThrowable()` reports but the program keeps running | This is the direct-call contract. Put recovery or termination in the application; reporting alone does not select either. | -| A warning is absent from diagnostics | Check `error_reporting()` and suppression, then whether the handler is registered or called explicitly. A custom PHP-error adapter owns its own mask policy. | -| An uncaught exception is absent after a scoped operation | Check whether an unwinding `finally` unregistered the handler before PHP invoked the exception callback. Use the [process recipe](examples.md#process-callbacks-and-scoped-restoration) through its bounded verifier. | -| Later reporters are never reached | The default aggregate stops on the first reporter failure. Inspect that failure or explicitly select [best-effort reporting](#best-effort-reporting) and its terminal policy. | +`Handler\FilteringThrowableHandler($reporter, $predicate)` wraps an explicit +`ThrowableHandlerInterface`. Its required `callable(Throwable): bool` predicate +runs once per invocation: true forwards the same object once, false skips only +that delegate. Repeated calls with the same object remain independent deliveries. +Non-boolean results raise TypeError; predicate and delegate failures propagate +unchanged, without retry. A failure still stops the default fail-fast aggregate. + +No output, default filtering policy, registration or automatic aggregate discovery +is added. A skipped report does not recover the operation, change PHP suppression +or choose the HTTP response. Rendering remains a separate public +boundary regardless of whether a diagnostic reporter forwards the event. +See [the filtering example](https://github.com/coisa/error-handler/blob/master/examples/filtering.php); select PHP severity via +ErrorException::getSeverity(), not an arbitrary exception code. + +## Callable PHP-error policy + +`Handler\CallablePhpErrorHandler($callback)` retains its compatible 2.x contract: +the callback receives `(int $code, string $message, string $filename, int $line)` +once, unchanged. The interface returns void, so every callback result, including +false/null, is ignored. Returning false does not delegate to PHP's native handler. +There is no fifth errcontext argument, automatic mask check, conversion or output. + +The callback owns suppression/conversion policy and its failures propagate +unchanged. Through ErrorHandler, a thrown ErrorException follows the normal +reporting boundary; other failures propagate. The [complete PHP-error example](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php) +explicitly checks `error_reporting() & $code` before converting an enabled error. + +Pass the callable directly as the second ErrorHandler argument, or keep the +explicit wrapper when an interface instance is needed. The provider's explicit +handler factory also accepts either form under `PhpErrorHandlerInterface`; +use FastForward's `ServiceFactory` to bind a callback as a value rather than a +construction factory. Null keeps the existing default. Construction and discovery +never register native callbacks. + +`Exception\ErrorException::fromPhpError($message, $code, $severity, $filename, $line, $previous)` +keeps exception code and PHP severity independent and preserves the original +previous throwable. Omitted or null location values become an empty filename +and line 0; the factory does not invent a source location or previous cause. + +## HTTP: report once, then render explicitly + +`ErrorHandlerMiddleware` accepts an +`ErrorHandlerInterface&ThrowableHandlerInterface` instance and a +`ThrowableResponseFactoryInterface`. The built-in ErrorHandler already implements +both contracts. A custom lifecycle implementation must support reporting too. + +On a downstream Throwable, middleware first calls the scoped handler, then asks +the response factory for a public representation, passing the original Throwable +and current ServerRequestInterface. A reporter failure propagates +after cleanup; its failed-registration guard prevents a second reporter attempt +when a PHP warning caused that failure. Rendering failure also propagates after +cleanup. Successful downstream responses pass through unchanged. + +Diagnostic reporters keep their `void` contract, work in CLI/events/shutdown, +and do not need PSR-7. In HTTP, send diagnostics to private sinks. Reporter echo +is unsolicited output; the default middleware does not treat it as the response +body. Pure renderers return StreamInterface or ResponseInterface directly. -For missing services or unexpected shared instances, use [container troubleshooting](container.md#troubleshoot-service-resolution). For leaked response text or repeated HTTP delivery, use [HTTP troubleshooting](http.md#troubleshoot-http-composition). +```php +$streams = new GenericThrowableStreamFactory($psr17Factory); +$responses = new ThrowableResponseFactory($streams, $psr17Factory); +$middleware = new ErrorHandlerMiddleware(new ErrorHandler($privateReporter), $responses); +$response = $middleware->process($request, $application); +``` -Continue with [container composition](container.md), [HTTP composition](http.md), and the [executable cookbook](https://github.com/coisa/error-handler/blob/master/examples/README.md). +Imports are under `Http\Message` and `Http\Middleware`. The PSR-17 implementation +must provide response and stream factories. The default generic renderer always +returns `Internal Server Error`, without reporter invocation or buffering. +`ThrowableResponseFactory` keeps integer codes 400–599, otherwise uses 500, and +lets PSR-17 supply the standard reason phrase. + +Implement `ThrowableStreamFactoryInterface` for a pure custom body, such as JSON, +or `ThrowableResponseFactoryInterface` when also choosing Content-Type/headers. +See [JSON without echo](examples.md#explicit-json-streams). The application owns emission, +headers, transport and public payload policy; diagnostic details are never a safe +default. The pure `JsonThrowableResponseFactory` and optional +`WhoopsThrowableResponseFactory` retained from 2.5.0 provide built-in alternatives; +see [HTTP composition](http.md). `ReportingThrowableResponseFactory` remains an +explicit utility for other pipelines, but must not wrap the renderer after this +middleware has already reported. + +The response-factory signature is +`createResponseFromThrowable(Throwable $throwable, ServerRequestInterface $request): ResponseInterface`. +Custom factories and direct callers must accept/supply the request even when a +fixed renderer ignores it. The middleware forwards the current object unchanged. +See [negotiation policy](http.md#request-negotiation) and the +[single provider](fast-forward.md#request-aware-responses). + +## Symfony terminal reporting + +`Handler\SymfonyCliThrowableHandler(?OutputInterface $output = null)` formats the +original through Symfony CliErrorRenderer. It writes to stderr by default, or uses +an application's Console output with raw writes and its error channel where available. +Construction is inert; reporting does not choose SAPI or an exit status. Explicitly +select it, or use provider `cli: true`; it is never an HTTP renderer. +Keep one exception owner when integrating Symfony Console. See the +[complete CLI guide](cli.md) and [recipe](examples.md#symfony-terminal-diagnostics). + +## Optional output-capture adapter + +`ThrowableStreamFactory` remains available explicitly for public echo-based +templates. In that adapter only, output from its injected public reporter becomes the +stream; a JSON callback would need to echo JSON. It is not the provider default. + +Pass a separate public renderer, not the diagnostic reporter middleware already +called. Empty captured output becomes `Internal Server Error`; `0` remains valid. +Before reading output, the adapter checks that a buffer remains above the caller's +entry depth. If none remains, it uses the generic body instead of reading a caller buffer. +The adapter cleans reachable removable buffers after success and failure and preserves a +renderer failure over cleanup failure. Cleanup stops at a non-removable buffer +or lack of progress. Renderers must balance their own removable buffers and never +close caller-owned buffers. See [the capture recipe](examples.md#output-capture). + +## Interop provider + +`ServiceProvider\ErrorHandlerServiceProvider` uses FastForward Container's +required runtime resolution, caching and constructor autowiring. It never +registers PHP callbacks during discovery/construction. The explicit lifecycle +factories live under `ServiceProvider\Factory`; no parallel standalone container +or native Laminas ConfigProvider remains. + +Use `container($application, ErrorHandlerServiceProvider::class)` to supply +application dependencies and optional reporters. To override existing library +service IDs, merge library definitions first and the application last in one +AggregateServiceProvider. Bind required reporters/renderers explicitly through +their interfaces. The default +reporter aggregate discovers optional callable/PSR-14 reporters and is silent when +none are configured. The default response alias resolves NegotiatingThrowableResponseFactory. It selects +JSON or Symfony HTML from Accept, uses trusted debug for diagnostic details, +and otherwise uses the generic stream. Optional Whoops requires an explicit override. The concrete response +renderers remain public; all require a ServerRequestInterface argument. +Applications supply PSR-14, PSR-17 and logger implementations. See [provider wiring](fast-forward.md) +and [migration](migration.md). -## Optional development HTML +## Best-effort reporting -For a checkout/release containing `WhoopsThrowableResponseFactory`, use it only as an explicitly selected development renderer, with `ReportingThrowableResponseFactory` supplying private reporting in 2.x. It is not a Throwable reporter, lifecycle handler or Accept negotiator. Whoops remains optional; page configuration and development authorization belong to the application. Read [the HTTP contract](http.md#opt-in-development-html-with-whoops) and [complete recipe](../examples/http-whoops.php). The proposed major uses the pure renderer after its own report; [issue 95](https://github.com/coisa/error-handler/issues/95) tracks that separate adaptation. +`Handler\BestEffortThrowableHandlerAggregate` is an explicit alternative to the +existing fail-fast aggregate. Its fixed constructor list is inert. Each invocation +attempts every slot once, in order, with the identical original Throwable; duplicate +slots are deliberate separate attempts. Zero/all-success passes return normally. +If any reporter throws, including partial success, one +`Exception\AggregateReportingException` escapes after all attempts. + +`getOriginalThrowable()` and `getPrevious()` retain the original; `getFailures()` +returns the nonempty ordered list of identical reporter failures. Its constructor +requires the original, one failure and optional remaining failures. The summary is +fixed, but stored causes/failures and exception strings remain private diagnostics. +Use the [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/best-effort.php) for explicit terminal handling. + +No retry, deduplication, reentry guard or durable transport is added. Native +termination can prevent later attempts. Default provider composition stays +fail-fast; no factory/discovery selects this adapter. In middleware, an aggregate +reporting failure propagates before rendering and does not become a public body. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..b4e4c0b --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,55 @@ +# Terminal diagnostics with Symfony + +`SymfonyCliThrowableHandler(?OutputInterface $output = null)` formats the original +Throwable through Symfony's `CliErrorRenderer`. With no output argument it writes +to stderr, without requiring Symfony Console. It does not register callbacks, +choose a SAPI, terminate a process or render an HTTP response. + +For direct terminal reporting: + +```php +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; + +$errors = new ErrorHandler(new SymfonyCliThrowableHandler()); +$errors->handleThrowable($failure); +``` + +Supply a Console `OutputInterface` when the application already owns one. +A `ConsoleOutputInterface` routes the diagnostic to `getErrorOutput()`; +other outputs receive it directly. Writes use `OUTPUT_RAW`, so exception text +is not parsed as Console markup. The output collaborator requires optional +`symfony/console` in that application. Renderer/output failures propagate unchanged. + +## Provider composition + +The normal provider remains silent when no reporter is supplied. Explicitly opt in +with `new ErrorHandlerServiceProvider(cli: true)`; debug is a separate first +parameter controlling HTTP JSON/HTML diagnostics. The default aggregate appends the +CLI reporter after its optional configured reporters. An application override of +`ThrowableHandlerInterface` remains authoritative and replaces that aggregate. + +Bind `OutputInterface::class` through an application service definition when +needed, then use `container($application, new ErrorHandlerServiceProvider(cli: true))`. +Without that service, diagnostics go to stderr. Service construction installs no +callbacks. There is no automatic SAPI detection and no automatic terminal renderer +in an HTTP response. + +## Symfony Console owns one catch + +Choose either Symfony Console's built-in exception rendering or this adapter. +When the application bootstrap owns the catch, call `setCatchExceptions(false)` +and `setAutoExit(false)` on its Console application. Catch once around +`run()`, report the original once and choose the command's exit status there. +Do not report and then let Console render the same failure again. + +The [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/symfony-cli.php) +has direct, provider and Console modes. It uses `BufferedOutput` only to verify +the synthetic diagnostic without sending it to a terminal. The +[bundled consumer program](../resources/skills/coisa-error-handler/examples/symfony-cli.php) +accepts the application's explicit autoloader. Both succeed after verifying one +diagnostic; a real CLI bootstrap chooses its own failure exit status. + +Diagnostics can include messages, paths, previous exceptions and trace data. +Keep this reporter in terminal/private diagnostic flows. HTTP responses use their +pure [request-aware renderers](http.md). diff --git a/docs/container.md b/docs/container.md index 8108954..2b36da5 100644 --- a/docs/container.md +++ b/docs/container.md @@ -1,252 +1,30 @@ -# Container integration in 2.x - -2.x retains `Container\ErrorHandlerContainer`, `Container\ConfigProvider`, factories under `Container\Factory` and Composer's `extra.laminas.config-provider` metadata. These legacy routes do not require FastForward. The [optional Interop providers](#optional-interop-and-fastforward-providers) below are additive and require an application using PHP 8.3+; they do not adopt the proposed major's middleware or defaults. - -The runtime PSR-11 dependency is the interface package, not another container implementation. The existing typed identifiers support PSR-11 `^1.1 || ^2.0`; 1.0 is incompatible. Published versions predating the [runtime metadata correction](https://github.com/coisa/error-handler/issues/13) need PSR-11 explicitly installed by the application. The [PHP 7.4 alias repair](https://github.com/coisa/error-handler/issues/40) also needs to be present for normal minimum-runtime alias resolution. - -## Decide how to get a handler - -A service ID is the string passed to a container's `get()` or `has()` method, usually an interface or class name. An alias redirects one ID to another. A factory creates a value from services in that container. These are [PSR-11 container](https://www.php-fig.org/psr/psr-11/) integration concepts; they do not install native PHP callbacks. - -| Existing application setup | Choose | Configuration owner | -| --- | --- | --- | -| No dependency injection container | Construct `new ErrorHandler($reporter)` directly | The application supplies its reporter and optional PHP-error/shutdown adapters. | -| An application PSR-11 container | Wrap it with `new ErrorHandlerContainer($application)` | The application parent supplies external implementations and service overrides. | -| A container consuming Laminas-style configuration | Merge `(new ConfigProvider())->getDependencies()` into its definitions | The application's container interprets aliases/factories and controls sharing. | - -The snippets below illustrate these boundaries. For a complete program with imports, application services, all factory resolutions and identity checks, use [the container recipe](examples.md#existing-container-and-configprovider-integration). Follow its [dependency setup](../examples/README.md#application-dependencies) before running it. - -## Choose a composition boundary - -For the built-in container, supply an optional application PSR-11 parent: - -```php -$services = new CoiSA\ErrorHandler\Container\ErrorHandlerContainer($application); - -$errors = $services->get(CoiSA\ErrorHandler\ErrorHandler::class); -``` - -The parent takes priority for IDs it reports as present. Otherwise library aliases and factories resolve services. The built-in container caches values it resolves locally, including alias results and handlers. Parent lookups delegate directly; the parent owns their lifetime. Unknown IDs throw the library's PSR-11 `NotFoundException`; construction errors are wrapped in `ContainerException`. A failed parent service is not a signal to silently substitute a different reporter. - -For source/releases containing the [container-cause repair](https://github.com/coisa/error-handler/issues/90), both `ContainerException::createFromThrowable()` and `NotFoundException::createFromThrowable()` keep the exact original throwable as `getPrevious()`, including its earlier cause chain. Integer codes, including zero and negative values, are copied unchanged. String codes such as PDO SQLSTATE `HY000` or `23000` use `0` on the wrapper; the original string remains on the previous throwable. Numeric-looking strings are not converted to integers. Nested library factory/alias wrappers retain that original diagnostic in their cause chain. Direct parent delegation keeps its existing behavior. - -Alternatively a `ConfigProvider`-aware application can consume its maps: - -```php -$provider = new CoiSA\ErrorHandler\Container\ConfigProvider(); -$configuration = $provider(); -$dependencies = $configuration['dependencies']; -``` - -`getDependencies()` returns aliases and factories; `getAliases()` and `getFactories()` expose each map. Laminas discovery reads this metadata; construction/discovery does not call `register()`. - -The [runnable container example](https://github.com/coisa/error-handler/blob/master/examples/container.php) demonstrates the built-in parent route and native Laminas ServiceManager configuration. ServiceManager is an application dependency here, with a version appropriate for that application's PHP; it is not required by the library at runtime. - -## Reporter composition and external dependencies - -The default `ThrowableHandlerInterface` alias resolves an initially empty `ThrowableHandlerAggregate`. Empty means silent: explicitly configure a reporter before expecting diagnostics. - -`ThrowableHandlerAggregateFactory` attaches an available `CallableThrowableHandler`. When `EventDispatcherInterface` is present, it also attaches the available raw and wrapped dispatch handlers. Each configured route receives its own delivery. Provide the dispatcher as an application service; the PSR-14 interface alone cannot dispatch anything. - -HTTP composition additionally requires application `ResponseFactoryInterface` and `StreamFactoryInterface` implementations. A PSR-17 implementation such as Nyholm's Psr17Factory can satisfy both. Missing required implementations are container construction errors; neither `ConfigProvider` nor PSR interfaces provide implementations automatically. - -## Explicit process logging - -`Handler\LogThrowableHandler` is constructed explicitly; it has no library factory, alias or automatic aggregate membership. Providing `LoggerInterface` alone leaves the existing default aggregate unchanged. With an application logger, construct the process handler directly: - -```php -$reporter = new CoiSA\ErrorHandler\Handler\LogThrowableHandler($logger); - -$process = new CoiSA\ErrorHandler\ErrorHandler($reporter); -``` - -An application may bind that process object under `ErrorHandler::class` in its parent container. Keep any HTTP renderer/lifecycle composition explicit and separate. Binding any reporter globally under `ThrowableHandlerInterface` also selects the legacy captured-output renderer's reporter. A silent reporter, including private logging or a filtering predicate that returns `false`, leaves empty captured output and causes fallback to diagnostic exception text. Select process reporting and public response construction explicitly; neither silence nor filtering establishes a safe public body. See [the logging contract](api.md#direct-psr-3-logging), [conditional reporting](api.md#conditional-reporting) and [HTTP composition](http.md). - -## Factory map and scopes - -`BestEffortThrowableHandlerAggregate` is also explicit: construct it with the desired reporters and inject it into an application-owned `ErrorHandler`. It has no factory, alias or automatic membership in the default fail-fast aggregate. Merely providing a service under its class name does not select it. A global `ThrowableHandlerInterface` override also changes the legacy capture renderer's reporter: any collected failure propagates after all slots, while all-success silent output retains diagnostic fallback. Keep process reporting and public response construction separate; see [best-effort reporting](api.md#best-effort-reporting) and [HTTP composition](http.md). - -`CallablePhpErrorHandler` has no automatic factory/discovery. Construct it explicitly and pass it as the second ErrorHandler argument, or provide the instance under `PhpErrorHandlerInterface` in the application parent. The default PHP-error adapter remains unchanged when no override is supplied. Its callback owns mask/conversion policy and its return value is ignored; see [the API contract](api.md#callable-php-error-policy). - -`FilteringThrowableHandler` requires an application predicate and is not discovered or attached to the default aggregate automatically. Construct it explicitly, then provide it through the application parent's `ThrowableHandlerInterface` service or attach it to an application-composed aggregate. That global service also affects legacy HTTP rendering as explained above; for process-only composition, bind the application `ErrorHandler::class` explicitly. Merely registering a service under `FilteringThrowableHandler::class` does not select it for reporting. There is no library filtering factory or implicit allow-all predicate. - -All factory names below are in `CoiSA\ErrorHandler\Container\Factory`. - -| Service | Factory | -| --- | --- | -| `ErrorHandler` | `ErrorHandlerFactory` | -| `Http\Middleware\ErrorHandlerMiddleware` | `ErrorHandlerMiddlewareFactory` | -| `Handler\ThrowableHandlerAggregate` | `ThrowableHandlerAggregateFactory` | -| `Handler\DispatchThrowableHandler` | `DispatchThrowableHandlerFactory` | -| `Handler\DispatchErrorEventThrowableHandler` | `DispatchErrorEventThrowableHandlerFactory` | -| `Http\Message\ThrowableStreamFactory` | `ThrowableStreamFactoryFactory` | -| `Http\Message\ThrowableResponseFactory` | `ThrowableResponseFactoryFactory` | - -These are the three aliases returned by `ConfigProvider::getAliases()`. Names are relative to `CoiSA\ErrorHandler`: - -| Requested ID | Default target ID | Consequence | -| --- | --- | --- | -| `Handler\ThrowableHandlerInterface` | `Handler\ThrowableHandlerAggregate` | The core and captured-output renderer share the selected reporter service. | -| `Http\Message\ThrowableStreamFactoryInterface` | `Http\Message\ThrowableStreamFactory` | The built-in response factory selects the captured-output adapter. | -| `Http\Message\ThrowableResponseFactoryInterface` | `Http\Message\ThrowableResponseFactory` | The middleware selects the built-in response/capture path unless overridden. | - -They are container service aliases, not PHP `class_alias()` declarations. `AliasFactory($targetId)` calls `get($targetId)` and returns its exact value. It does not construct a new object or establish a global singleton. An application parent can override either a requested interface ID or its target ID. Configure the graph before resolution: changing only a target later does not refresh an already cached local alias. A parent entry for the requested ID still takes priority on each lookup. - -`ErrorHandlerFactory` requires `ThrowableHandlerInterface` and checks the container for optional `PhpErrorHandlerInterface` and `ShutdownHandlerInterface` services. Missing optional services are passed as null, selecting the core defaults. These two optional IDs have no default entries in `ConfigProvider`; provide application instances explicitly when replacing their policy. - -The built-in container reads its fixed maps at construction and has no public setter for replacing factories or aliases. Supply overrides through its application parent. In a ConfigProvider-consuming container, modify the returned dependency maps using that container's supported configuration; its resolution and sharing rules apply. Adding a factory definition is distinct from attaching its reporter to an aggregate. - -Repeated `get(ErrorHandler::class)` returns the cached handler. Invoking a new `ErrorHandlerFactory` with the container constructs another core handler, while its dependencies can remain shared. - -Invoking `ErrorHandlerMiddlewareFactory` constructs a middleware wrapper but asks the container for its handler. That handler may still be cached. For independent nested scopes, explicitly construct a distinct ErrorHandler and middleware; do not treat a fresh wrapper as proof of a fresh handler. The existing middleware factory does not isolate process and request handlers automatically. - -`Http\Message\JsonThrowableResponseFactory` and `ReportingThrowableResponseFactory` are explicit opt-in services. The legacy routes do not select them. Construct them with application-owned PSR-17 factories and a private, non-output reporter, then construct a distinct middleware scope as shown in the [JSON composition](http.md#opt-in-json-responses-and-explicit-reporting), or choose the JSON Interop profile below. An application may supply the composed response factory under `ThrowableResponseFactoryInterface`, but that override does not make the legacy cached lifecycle handler a fresh request scope. Keep process and public rendering composition intentional. - -## Optional Interop and FastForward providers - -The optional `ServiceProvider\ErrorHandlerServiceProvider`, `JsonErrorHandlerServiceProvider` and `WhoopsErrorHandlerServiceProvider` profiles were published in 2.5.0. Select `coisa/error-handler:^2.5` and verify the installed source for the chosen profile. They use `Interop\Container\ServiceProviderInterface` and FastForward's reusable factories. Core PHP 7.4 consumers retain the legacy routes. The examples and bundled skill keep their common `^2.4` baseline. The provider integration needs PHP 8.3+ and FastForward installed as an **application runtime dependency**: - -```sh -composer require 'coisa/error-handler:^2.5' 'fast-forward/container:^1.9' 'nyholm/psr7:^1.8' --no-plugins --no-scripts -``` - -The library requires only the small Interop contract; FastForward is optional for library consumers and present in the library's development graph for its PHPUnit checks. Installing it only as the application's `require-dev` would leave a production `--no-dev` installation without its chosen container. Applications still own PSR-17 factories, their private `ThrowableHandlerInterface` reporter and response emission. The providers do not install an emitter or choose a logging destination. - -With the application map from the [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php), import `ServiceProvider\JsonErrorHandlerServiceProvider`, `ServiceProvider\ErrorHandlerServiceProvider` and `FastForward\Container\container`, then select public JSON: - -```php -$services = container( - JsonErrorHandlerServiceProvider::class, - ErrorHandlerServiceProvider::class, - $application, -); -``` - -Each provider declares only its own services; the JSON profile does not include the base provider. Register JSON **before** the base when passing them separately to `container()`: FastForward selects the first successful definition of `ThrowableResponseFactoryInterface`. Putting the base first selects legacy capture even when JSON is also present. - -The JSON profile declares its pure concrete renderer and selects `ReportingThrowableResponseFactory` under `ThrowableResponseFactoryInterface`. The existing 2.x middleware therefore reports the original privately once before rendering. Generic JSON is the default; it preserves integer HTTP 400–599 codes and returns `{"error":"Request failed"}` without diagnostic text. A reporter that prints still owns that output; use one that writes no public diagnostics. The concrete JSON factory remains pure when resolved directly. - -The base `ErrorHandlerServiceProvider` has no arguments. It defines lifecycle aliases, reusable scope factories and the existing capture defaults, requiring the application's reporter instead of selecting an empty aggregate. It registers neither JSON, Whoops nor an implicit reporter. Merely providing PSR-17 or a logger does not select a profile or compose reporting. - -For explicit development JSON, give the profile a real bool from trusted application configuration **before** building the container: - -```php -$settings = FastForward\Config\config([ - 'error_handler' => ['json' => ['debug' => false]], -]); - -$services = container( - new JsonErrorHandlerServiceProvider($settings->get('error_handler.json.debug', false)), - ErrorHandlerServiceProvider::class, - $application, -); -``` - -Use `declare(strict_types=1)` in the application entrypoint, as the complete recipe does. Config supplies values; it does not hydrate this object or inject constructor scalars automatically. The bool follows PHP's normal caller-dependent typing: weak callers may coerce strings, and `(bool) 'false'` is true. Do not derive diagnostics from public request headers or naïvely cast an environment string. Debug details and `Cache-Control: no-store` follow the [JSON contract](http.md#opt-in-json-responses-and-explicit-reporting). - -### Development HTML profile - -Select `WhoopsErrorHandlerServiceProvider` only after the application's trusted development and HTML-format decision. Install optional `filp/whoops:^2.18.7` where that renderer is used. With the same application PSR-17 and private reporter definitions, the default form is: - -```php -use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use CoiSA\ErrorHandler\ServiceProvider\WhoopsErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; - -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new WhoopsErrorHandlerServiceProvider(), - $application, -)); -``` - -The Whoops profile has no constructor arguments. It declares only `PrettyPageHandler`, its pure `WhoopsThrowableResponseFactory`, the reporting decorator and the selected response alias. It does not instantiate or merge the base provider. The application composes the independent maps in one aggregate, ordered base, Whoops, application; the last definition wins. The profile does not select JSON or install callbacks. Provider discovery, renderer resolution and scope construction create no default page; rendering resolves the page service lazily. Choosing this diagnostic profile is the explicit opt-in, so it has no additional debug boolean or request/Accept detection. - -For page configuration, override `PrettyPageHandler::class` in the application's factory map. The default uses FastForward's `InvokableFactory`. This example reads application Config and prepares the page before composition, then exposes it through `ServiceFactory`. Configuration failures therefore occur outside container lookup: - -```php -use FastForward\Container\Factory\ServiceFactory; -use FastForward\Container\ServiceProvider\ArrayServiceProvider; -use Whoops\Handler\PrettyPageHandler; - -use function FastForward\Config\config; - -$settings = config(['development' => ['page_title' => 'Application diagnostics']]); -$page = new PrettyPageHandler(); - -$page->setPageTitle($settings->get('development.page_title')); -$factories[PrettyPageHandler::class] = new ServiceFactory($page); - -$application = new ArrayServiceProvider($factories); - -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new WhoopsErrorHandlerServiceProvider(), - $application, -)); -``` - -FastForward caches the page service. Sequential renders reuse configuration while Whoops replaces its exception, inspector and generated template data; each render creates a fresh Run, PSR response and stream. The shared page retains its latest diagnostic until the next render. This does not promise concurrent reuse or behavior of arbitrary subclasses. A private reporter runs once before page lookup; reporter failure prevents rendering, and failures escaping the page callback or renderer propagate without another report. The concrete renderer remains pure when resolved directly. Whoops stays optional: loading and constructing the profile without it is inert; selecting the renderer checks the dependency before page lookup and supplies installation guidance if it is absent. - -Container resolution remains FastForward's responsibility. The aggregate fixes definition precedence; it does not disable autowiring fallback after a concrete page factory throws. With FastForward 1.9.1, a failing `PrettyPageHandler` factory can be replaced by an autowired default page, losing that intended configuration. A returned incompatible service produces `TypeError`; a page's `handle()` failure escapes unchanged. Preparing the configured page before composition avoids putting its configuration failure inside lookup; it does not change the semantics of factories that throw during `get()`. - -Separate providers have a different boundary: their first successful resolution wins. With Whoops installed, Whoops before base selects HTML; base first selects legacy capture. Without Whoops, the separate Whoops/base/application route can fall back to a reporting decorator around legacy capture and report the same original twice. Use the aggregate above for this profile: in the real no-Whoops consumer it retains the missing-dependency cause and hint instead of returning that fallback. These observed container paths are not adapter retries. - -The [complete provider recipe](../examples/provider-whoops.php) exercises default and configured services with two fresh middleware scopes and one cached configured page. Default pages expose diagnostic data; hiding selected keys does not redact messages, traces, source excerpts or every secret. Read the [HTML disclosure and failure contract](http.md#opt-in-development-html-with-whoops) before enabling this profile. The following scope rules apply to either renderer profile. Aggregate definitions use the opposite precedence from separate providers. - -### Cached services and fresh HTTP scopes - -`ErrorHandlerInterface` aliases the cached process `ErrorHandler`. Obtaining it or constructing the provider does not register callbacks. The Interop middleware factory lives under `ServiceProvider\Factory`, separately from the unchanged legacy factory: - -```php -$middlewareFactory = $services->get( - CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory::class, -); - -$middleware = $middlewareFactory($services); - -$response = $middleware->process($request, $next); -``` - -Each invocation reuses the existing core `Container\Factory\ErrorHandlerFactory` to create a distinct lifecycle handler and middleware wrapper. The cached process handler is not reused for that HTTP scope. Optional PHP-error/shutdown collaborators retain the existing factory's behavior and may remain shared application services. Resolving `ErrorHandlerMiddleware` directly is also supported, but that service is cached; request fresh scopes deliberately when composing nested or sequential operations. - -The native lifecycle remains 2.x: unregister restores callbacks, but PHP cannot remove queued shutdown functions. Fresh objects are not a bounded worker scheduling mechanism. Keep process ownership and long-running application policy explicit. - -### Required application overrides - -Separate FastForward providers prioritize the first successful resolution. The simple JSON → base → application call works for the required application PSR-17/reporter services because neither library provider defines them. For a required replacement of a service the library already defines, combine the maps in one aggregate in the order **base → JSON → application**; the last definition wins inside this aggregate: - -```php -$services = container( - new FastForward\Container\ServiceProvider\AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider(), - $application, - ), -); -``` - -That order makes the application's renderer override deterministic. Putting a competing definition in a later, separate provider does not override the first one. FastForward can try a later candidate after a PSR container failure; a failing concrete factory may fall through to autowiring with default scalar values. This profile receives `debug` at construction to keep that choice outside service resolution, rather than reading a config service ID during construction of the concrete renderer. Other container errors retain FastForward's semantics. - -These are the optional **2.x** providers: they preserve legacy classes and compose reporting for middleware that does not report first. A future report-before-render major must select the pure renderer directly; do not carry the reporting decorator into that middleware path. - -Applications decide when a resolved process handler calls `register()`. For HTTP registration/restoration, rendering and emission boundaries, see [HTTP](http.md). - -## Optional development HTML composition - -For manual or legacy-container composition, supply application PSR-17 factories and a callable supplying `Whoops\Handler\PrettyPageHandler`, then compose private reporting with `ReportingThrowableResponseFactory` in 2.x. The renderer adds no legacy aliases or ConfigProvider bindings; the separate opt-in [Whoops Interop profile](#development-html-profile) supplies its own map. The renderer can be cached with configuration; do not capture a current request. A manual callback may create a fresh page or reuse one for sequential rendering under the state limits above. Construct it only after the application's trusted development/format decision. Core/container installation does not require Whoops; choosing the adapter without installing `filp/whoops:^2.18.7` raises `LogicException`. See [the HTML guide](http.md#opt-in-development-html-with-whoops) and [manual recipe](../examples/http-whoops.php). Existing container/discovery/middleware defaults remain unchanged. - -## Troubleshoot service resolution - -This table applies to `Container\ErrorHandlerContainer` and its `Container\Factory` integrations. The optional Interop profiles above have their own scope and fallback rules. - -| Observation | Check next | -| --- | --- | -| `has($id)` is true but `get($id)` throws | `has()` recognizes a parent ID, alias or factory; it does not construct or validate its dependencies. Inspect the private exception cause chain for the missing or failing collaborator. | -| The default core receives failures but nothing is reported | Its aggregate starts empty. Supply `CallableThrowableHandler`, the intended event route or an explicit `ThrowableHandlerInterface` override. A logger or arbitrary reporter-class registration alone is insufficient. | -| Middleware construction fails for an HTTP factory ID | Supply application implementations under both PSR-17 `ResponseFactoryInterface` and `StreamFactoryInterface`; installing interface packages alone does not provide them. | -| A custom PHP-error or shutdown adapter is ignored | Bind the interface ID requested by `ErrorHandlerFactory`, or construct the core explicitly with that adapter. Providing only its concrete class ID does not replace an optional interface lookup. | -| Two middleware wrappers use the same core | Their factory resolves `ErrorHandler::class` from the container. Explicitly construct distinct handlers for independent scopes; a new wrapper does not establish a new lifecycle. | -| A service change is absent from an existing handler | Existing objects retain their dependencies and locally resolved services are cached. Configure the intended graph before resolution and construct a new scope when needed. | - -Use [the bounded examples](../examples/verify.md) to verify the selected composition. Keep private diagnostics out of public HTTP output; a successful container lookup does not establish that response boundary. +# Container integration + +Use the [Interop/FastForward service provider](fast-forward.md) for container +integration. Direct handler construction remains available. Applications upgrading +from the 2.x standalone `ErrorHandlerContainer`, its factories/exceptions or +Laminas `ConfigProvider` should follow the [migration map](migration.md). + +FastForward caches services. Resolve and invoke the provider's explicit factory +services when a fresh handler/middleware scope is required. Discovery does not +install native PHP callbacks; applications choose when to register the process +handler. Merge application overrides after library definitions in one aggregate +provider as shown under [required overrides](fast-forward.md#required-overrides). +The [bundled required-reporter program](../resources/skills/coisa-error-handler/examples/container.php) +provides complete setup; the short optional-discovery recipe uses a different +composition. + +The best-effort aggregate is also explicit: construct it with application reporters +and bind the required reporting interface in the merged map. No factory, alias or +automatic membership selects it. The default aggregate remains fail-fast; see +[best-effort reporting](api.md#best-effort-reporting). + +The single [request-aware provider](fast-forward.md#request-aware-responses) +selects a pure renderer from the current request's Accept header. Supply the +application first: `container($application, ErrorHandlerServiceProvider::class)`. +The default HTML implementation is Symfony in both public and debug modes. +Trusted debug enables details but never forces HTML. Optional Whoops requires +an explicit application override. JSON-only +requests do not construct Whoops. Middleware owns the single private report. +Use a merged map only when a required override must not fall through after a +container construction failure. diff --git a/docs/examples.md b/docs/examples.md index 7573bfa..5e0c826 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,458 +1,686 @@ -# Complete 2.x cookbook +# Executable cookbook -These complete recipes use the `coisa/error-handler` 2.x API and PHP 7.4-compatible syntax. They match the executable sources in this checkout. The common baseline is `^2.4`; JSON/Whoops adapters and Interop providers were published in 2.5.0 and need `^2.5`. Verify the selected installation. The optional Interop/FastForward recipes require PHP 8.3+ at runtime. They do not adopt the proposed major's required container, constructor callables or report-before-render middleware. +These complete examples use PHP `^8.3`. The development dependencies include Nyholm PSR-7 and FastForward EventDispatcher; applications own their PSR implementations and Composer lockfiles. FastForward Container is a required runtime dependency. See [example setup](https://github.com/coisa/error-handler/blob/master/examples/README.md). -Start with the [application dependencies and execution setup](https://github.com/coisa/error-handler/blob/master/examples/README.md). The recipes share [bootstrap.php](https://github.com/coisa/error-handler/blob/master/examples/bootstrap.php) for the application's Composer autoloader and [check.php](https://github.com/coisa/error-handler/blob/master/examples/check.php) for their synthetic assertions. When adapting a recipe to an application, use its existing autoloader and tests. +These are source-checkout programs: Composer archives omit the top-level +`examples/` directory. Keep the complete directory, including its bootstrap and +assertion helper, and follow the setup guide above. +For the installed package, use the [bundled application examples](../resources/skills/coisa-error-handler/examples/README.md) +with the application's explicit autoloader. -Run all modes from the source checkout with the [bounded verifier](https://github.com/coisa/error-handler/blob/master/examples/verify.md): +Run `php examples/verify.php` to verify every recipe and documented mode in bounded children. The CLI recipe deliberately exits 1. The [static site](https://coisa.github.io/error-handler/#examples) links these programs and displays checked excerpts. Every complete entry is checked byte-for-byte against source, omitting only its leading license header. Duplicate, changed or missing recipes fail validation; examples do not prove delivery to real transports. -```sh -php examples/verify.php -``` + +## Process bootstrap -Set `ERROR_HANDLER_AUTOLOAD` as described in the setup guide when using a disposable application's dependencies. Process/fatal/HTTP scenarios belong in that bounded execution. The library never starts a server, chooses a logging transport or emits a response to a client. - -The displayed PHP retains imports, declarations, spacing and every executable branch. Only each source file's license header is omitted here; the [MIT license](https://github.com/coisa/error-handler/blob/master/LICENSE) and full headers remain in the repository. The existing documentation validator checks these complete recipes against source in addition to the website excerpts. - -| Recipe | Main responsibility | -| --- | --- | -| [direct.php](#direct-throwable-reporting) | Direct throwable reporting | -| [logging.php](#direct-psr-3-logging) | Direct PSR-3 logging | -| [process.php](#process-callbacks-and-scoped-restoration) | Process callbacks and scoped restoration | -| [handlers.php](#callable-adapters-aggregation-and-php-error-conversion) | Callable adapters, aggregation and PHP error conversion | -| [php-errors.php](#explicit-php-error-callback-policy) | Explicit PHP-error callback policy | -| [filtering.php](#conditional-diagnostic-reporting) | Conditional diagnostic reporting | -| [best-effort.php](#attempt-every-reporter-and-retain-all-failures) | Attempt every reporter and retain all failures | -| [events.php](#psr-14-dispatch-listeners-and-logging) | PSR-14 dispatch, listeners and logging | -| [container.php](#existing-container-and-configprovider-integration) | Existing container and ConfigProvider integration | -| [http.php](#http-responses-output-capture-and-a-safe-application-adapter) | HTTP responses, output capture and a safe application adapter | -| [http-json.php](#pure-json-with-one-private-report) | Pure JSON with one private report | -| [provider-json.php](#optional-interop-json-profile) | Optional Interop JSON profile, PHP 8.3+ | -| [http-whoops.php](#optional-development-html-with-whoops) | Explicit development HTML and private reporting | -| [provider-whoops.php](#optional-interop-whoops-profile) | Optional Interop Whoops profile, PHP 8.3+ | - -## Choose a recipe and its prerequisites - -Start with direct reporting, then add the composition your application needs. The adapters can be combined: a filter can wrap a logger, an aggregate can contain multiple reporters, and the core can use that aggregate. Decide whether one reporter failure should stop later deliveries before choosing the fail-fast or best-effort aggregate. - -| Recipes | Additional application implementation | What success establishes | -| --- | --- | --- | -| Direct, handlers, PHP errors, filtering and best-effort | None beyond the library and Composer autoloader; their reporters use synthetic callbacks | The selected delivery, ordering, conversion or failure contract in memory. | -| Direct logging | A PSR-3 logger; the recipe provides a recording implementation | Exact message, selected level and original throwable in context, without a real log transport. | -| Process callbacks | No additional implementation for the built-in defaults | Native callback behavior and restoration in bounded child processes; fatal observation does not establish recovery. | -| Events | A PSR-14 dispatcher; the recipe uses Phly | Raw and wrapped dispatch, listener selection and recording-log delivery. | -| Container | A parent PSR-11 implementation; the recipe uses Laminas ServiceManager | Overrides, the three aliases/seven factories and cached versus explicitly constructed core identity. Direct core construction needs no container. | -| HTTP | PSR-17 stream/response factories; the recipe uses Nyholm | Returned response tuples, private reporting and callback restoration. No HTTP response is emitted. | - -The library requires `psr/container`, `psr/event-dispatcher`, `psr/http-factory`, `psr/http-server-middleware` and `psr/log` at runtime; Composer also resolves their interface dependencies, including PSR-7 message interfaces and PSR-15 request-handler contracts. These packages define interoperability contracts. Nyholm, Phly and Laminas are implementations used by particular recipes, not mandatory integrations for every application. The installed application's lockfile records the actual dependency versions. - -From the source checkout, run `php examples/direct.php` first. Expected output is: - -```text -reported: Example failure -continued -``` +Report a warning and an uncaught exception, then exit 1. [Source](https://github.com/coisa/error-handler/blob/master/examples/cli.php). + + +```php +getMessage() . PHP_EOL); +}); -## Direct throwable reporting +$handler = new ErrorHandler($reporter); + +$handler->register(); + +trigger_error('Example warning; execution continues.', E_USER_WARNING); + +throw new RuntimeException('Example uncaught exception; exit status is 1.'); +``` -Construct the handler explicitly and report without registering native callbacks. The call returns normally so the application can continue. + +## Interop service provider -Source: [examples/direct.php](https://github.com/coisa/error-handler/blob/master/examples/direct.php). +Resolve real FastForward definitions without installing callbacks during lookup. [Source](https://github.com/coisa/error-handler/blob/master/examples/fast-forward.php). - + ```php application service factories + */ + public function getFactories(): array + { + return [ + CallableThrowableHandler::class => static fn(): CallableThrowableHandler => new CallableThrowableHandler( + /** + * Prints only this CLI recipe's synthetic diagnostic. + * + * @param Throwable $error the converted warning supplied for reporting + */ + static function (Throwable $error): void { + printf("Reported: %s\n", $error->getMessage()); + }, + ), + ]; + } + /** - * Print only the synthetic message used by this executable example. + * Declares no application service extensions. * - * @param Throwable $error the synthetic failure to report + * @return array an empty extension map */ - static function (Throwable $error): void { - echo 'reported: ' . $error->getMessage() . PHP_EOL; + public function getExtensions(): array + { + return []; + } +}; + +$services = container($application, ErrorHandlerServiceProvider::class); + +/** + * @var ErrorHandlerInterface $errors + */ +$errors = $services->get(ErrorHandlerInterface::class); + +$errors->register(); + +try { + trigger_error('FastForward provider warning', E_USER_WARNING); +} finally { + $errors->unregister(); +} +``` + + +## Callable reporters and ordered composition + +Deliver the same throwable in order; the existing aggregate remains fail-fast. [Source](https://github.com/coisa/error-handler/blob/master/examples/reporters.php). + + +```php +getMessage(); + }), +); + +$reporters->attach(new CallableThrowableHandler( + static function (Throwable $error) use (&$deliveries): void { + $deliveries[] = 'second: ' . $error->getMessage(); }, )); -$errors->handleThrowable(new RuntimeException('Example failure')); +$errors = new ErrorHandler($reporters); -echo 'continued' . PHP_EOL; -``` +// Direct delivery needs no registration and does not terminate the process. +$errors->handleThrowable(new RuntimeException('Synthetic failure')); -## Direct PSR-3 logging +echo implode(PHP_EOL, $deliveries) . PHP_EOL; +``` -Inject an application logger, choose a level and preserve the original throwable in context. The recording logger demonstrates exact and empty messages without a real transport. + +## PSR-14 dispatch and PSR-3 event logging -Source: [examples/logging.php](https://github.com/coisa/error-handler/blob/master/examples/logging.php). +Exercise original Throwable and ErrorEvent notification paths with only an +application dispatcher. Expect one raw delivery, one wrapped delivery and +`Private log records: 1`; exit 0 alone does not prove reporting occurred. +See [optional discovery and fallback](fast-forward.md) before relying on the +default reporter. [Source](https://github.com/coisa/error-handler/blob/master/examples/event-dispatcher.php). - + ```php }> Synthetic log records. */ + /** + * @var list synthetic private log records; replace with an application logger + */ public array $records = []; /** - * Record arguments without transport or output, using signatures compatible with PSR-3 1.x–3.x. + * Records diagnostics without emitting them into an HTTP response. * - * @param mixed $level the level chosen by the application - * @param object|string $message the logger's message argument - * @param array $context structured context retained without coercion + * Untyped parameters also support applications using psr/log 1.x. + * + * @param mixed $level the PSR-3 log level + * @param mixed $message the diagnostic message + * @param array $context the application log context */ public function log($level, $message, array $context = []): void { - $this->records[] = ['level' => $level, 'message' => $message, 'context' => $context]; + $this->records[] = $level . ': ' . (string) $message; } }; -$context = ['component' => 'worker', 'exception' => new LogicException('Caller context')]; -$failure = new RuntimeException('Synthetic worker failure'); -$reporter = new LogThrowableHandler($logger, LogLevel::CRITICAL, $context); +$throwables = new ThrowableListenerProvider( + new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void { + $deliveries[] = 'throwable: ' . $error->getMessage(); + }), +); +$events = new ErrorEventListenerProvider( + new ErrorEventCallableListener(static function (ErrorEventInterface $event) use (&$deliveries): void { + $deliveries[] = 'event: ' . $event->getThrowable()->getMessage(); + }), +); +$events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); -$process = new ErrorHandler($reporter); +$configuration = new ArrayConfig([ + ListenerProviderInterface::class => [$throwables, $events], +]); -$process->handleThrowable($failure); +$services = container($configuration, EventDispatcherServiceProvider::class, ErrorHandlerServiceProvider::class); -$empty = new RuntimeException(''); -$process->handleThrowable($empty); +$errors = $services->get(ErrorHandlerInterface::class); -exampleCheck(2 === count($logger->records), 'Each adapter invocation must make one logger call.'); -exampleCheck(LogLevel::CRITICAL === $logger->records[0]['level'], 'The configured level changed.'); -exampleCheck($failure->getMessage() === $logger->records[0]['message'], 'The exact message changed.'); -exampleCheck($failure === $logger->records[0]['context']['exception'], 'The original throwable must win its key.'); -exampleCheck('worker' === $logger->records[0]['context']['component'], 'Caller context changed.'); -exampleCheck($context['exception'] instanceof LogicException, 'Caller context was mutated.'); +$errors->register(); -exampleCheck('' === $logger->records[1]['message'], 'An empty message must stay empty.'); -exampleCheck($empty === $logger->records[1]['context']['exception'], 'The second original throwable changed.'); +try { + trigger_error('Synthetic warning', E_USER_WARNING); +} finally { + $errors->unregister(); +} -echo 'direct PSR-3 logging, exact messages and original context passed' . PHP_EOL; +echo implode(PHP_EOL, $deliveries) . PHP_EOL; +printf("Private log records: %d\n", count($logger->records)); ``` -## Process callbacks and scoped restoration + +## PSR-15 middleware with the provider -The existing modes cover warnings, scoped callback restoration, uncaught exceptions and a custom shutdown callback. Run these and the verifier-only fatal mode through the bounded verifier; the current 2.x uncaught path does not promise a nonzero exit. +Report once, render a generic response and restore the process handler. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-middleware.php). -Source: [examples/process.php](https://github.com/coisa/error-handler/blob/master/examples/process.php). - - + ```php getMessage() . PHP_EOL; - }, -); -$shutdown = 'shutdown' === $mode - ? new CallableShutdownHandler( - /** Demonstrate an application-owned shutdown callback. */ - static function (): void { - echo 'shutdown callback' . PHP_EOL; - }, - ) - : null; -$errors = new ErrorHandler($reporter, null, $shutdown); +$reported = []; + +$psr17 = new Psr17Factory(); + +$reporter = new CallableThrowableHandler(static function (Throwable $error) use (&$reported): void { + $reported[] = $error; // Replace with a private logger; do not echo diagnostics. +}); + +$application = new ArrayServiceProvider([ + CallableThrowableHandler::class => static fn(): CallableThrowableHandler => $reporter, + ResponseFactoryInterface::class => static fn(): ResponseFactoryInterface => $psr17, + StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $psr17, +]); + +$services = container($application, ErrorHandlerServiceProvider::class); -if ('scope' === $mode) { - /** Represent an application's already installed PHP error callback. */ - $previousError = static fn(): bool => true; +$process = $services->get(ErrorHandlerInterface::class); +$middleware = $services->get(ErrorHandlerMiddleware::class); +$downstream = new class implements RequestHandlerInterface { /** - * Represent an application's already installed exception callback. + * Simulates an application failure with an HTTP error status. * - * @param Throwable $error an unexpected failure escaping the fixture + * @param ServerRequestInterface $request the incoming request * - * @throws LogicException if the surrounding callback is unexpectedly invoked + * @return ResponseInterface a response supplied by the error middleware */ - $previousException = static function (Throwable $error): void { - throw new LogicException('The surrounding exception callback must remain unused.', 0, $error); - }; + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Private database diagnostic', 503); + } +}; - set_error_handler($previousError); - set_exception_handler($previousException); +$process->register(); - $errors->register(); +try { + $response = $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream); - try { - trigger_error('Scoped warning', E_USER_WARNING); - } finally { - $errors->unregister(); - } + // The process handler is active again after the middleware scope ends. + trigger_error('Synthetic post-request warning', E_USER_WARNING); +} finally { + $process->unregister(); +} - $actualError = set_error_handler($previousError); - restore_error_handler(); - $actualException = set_exception_handler($previousException); - restore_exception_handler(); +printf("HTTP %d | %s\n", $response->getStatusCode(), (string) $response->getBody()); +printf("Reported: %d\n", count($reported)); - restore_error_handler(); - restore_exception_handler(); +// Your HTTP framework/emitter sends the response; this example only inspects it. +``` - exampleCheck( - $actualError === $previousError && $actualException === $previousException, - 'Callbacks were not restored.', - ); + +## Explicit JSON streams - echo 'callbacks restored' . PHP_EOL; +Return application-owned JSON without echo or buffering. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-rendering.php). - return; -} + +```php +register(); +declare(strict_types=1); -if ('uncaught' === $mode) { - throw new RuntimeException('Uncaught fixture'); -} +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; +use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Server\RequestHandlerInterface; -if ('fatal' === $mode) { - exampleCheck( - '1' === getenv('ERROR_HANDLER_BOUNDED_FIXTURE'), - 'Run the fatal fixture only through examples/verify.php.', - ); +require __DIR__ . '/bootstrap.php'; - eval('function exampleFatalDuplicate() {} function exampleFatalDuplicate() {}'); -} +$privateDiagnostics = []; +$reporter = new CallableThrowableHandler( + static function (Throwable $error) use (&$privateDiagnostics): void { + $privateDiagnostics[] = $error; // Replace with a private logger; emit no response body. + }, +); + +$psr17 = new Psr17Factory(); +$streams = new class ($psr17) implements ThrowableStreamFactoryInterface { + /** + * Stores the application's PSR-17 factory. + * + * @param StreamFactoryInterface $streams creates public response body streams + */ + public function __construct(private StreamFactoryInterface $streams) + { + } -if ('warning' === $mode) { - trigger_error('Process warning', E_USER_WARNING); + /** + * Returns controlled JSON directly, independently of diagnostic reporting. + * + * @param Throwable $throwable the private failure whose details stay undisclosed + * + * @return StreamInterface a public JSON body without echo or output buffering + */ + public function createStreamFromThrowable(Throwable $throwable): StreamInterface + { + return $this->streams->createStream( + json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR), + ); + } +}; - echo 'continued' . PHP_EOL; +$responses = new ThrowableResponseFactory($streams, $psr17); +$middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); - return; -} +$downstream = new class implements RequestHandlerInterface { + /** + * Simulates a private application failure with an HTTP error code. + * + * @param ServerRequestInterface $request the incoming request + * + * @return ResponseInterface the error response produced by middleware + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Private credentials', 503); + } +}; -exampleCheck('shutdown' === $mode, 'Unknown process example mode.'); +$response = $middleware->process($psr17->createServerRequest('GET', '/'), $downstream) + ->withHeader('Content-Type', 'application/json') +; -echo 'leaving main' . PHP_EOL; +printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type')); +echo (string) $response->getBody() . PHP_EOL; +printf("Private reports: %d\n", count($privateDiagnostics)); ``` -## Callable adapters, aggregation and PHP error conversion + +## Application PHP-error and shutdown handlers -Compose callable throwable/shutdown adapters and the fail-fast aggregate. Exercise attach order, original-object delivery, conversion, reporting-mask suppression and the shutdown adapter's nonfatal path. +Supply explicit interface implementations and a shutdown callback. [Source](https://github.com/coisa/error-handler/blob/master/examples/custom-handlers.php). -Source: [examples/handlers.php](https://github.com/coisa/error-handler/blob/master/examples/handlers.php). - - + ```php reportable PHP messages received by this application handler */ - static function (Throwable $error) use (&$calls): void { - $calls[] = 'first'; - }, -); -$second = new CallableThrowableHandler( + public array $messages = []; + /** - * Record an attached delivery after the constructor's reporters. + * Records reportable PHP errors without converting them to exceptions. * - * @param Throwable $error the synthetic aggregate input + * @param int $code the PHP severity + * @param string $message the PHP message + * @param string $filename the source filename + * @param int $line the source line */ - static function (Throwable $error) use (&$calls): void { - $calls[] = 'second'; - }, -); -$aggregate = new ThrowableHandlerAggregate($first); -$aggregate->attach($second); -$aggregate->handleThrowable(new RuntimeException('Aggregate fixture')); + public function handlePhpError(int $code, string $message, string $filename, int $line): void + { + if (($code & error_reporting()) !== 0) { + $this->messages[] = $message; + } + } +}; -exampleCheck(['first', 'second'] === $calls, 'Aggregate ordering changed.'); +$shutdowns = 0; -$failure = new RuntimeException('Reporter failure'); -$stopped = false; -$failFast = new ThrowableHandlerAggregate( - new CallableThrowableHandler( - /** - * Fail the first reporter to demonstrate propagation. - * - * @param Throwable $error the synthetic aggregate input - * - * @throws RuntimeException the exact configured reporter failure - */ - static function (Throwable $error) use ($failure): void { - throw $failure; - }, - ), - new CallableThrowableHandler( +/** Records the explicit notification on the active lifecycle scope. */ +$shutdown = static function () use (&$shutdowns): void { + ++$shutdowns; +}; + +$application = new ArrayServiceProvider([ + CallableThrowableHandler::class => static fn(): CallableThrowableHandler => new CallableThrowableHandler( /** - * Record an unexpected delivery after the first reporter fails. + * Routes diagnostics to the application's private sink. * - * @param Throwable $error the synthetic aggregate input + * @param Throwable $error the original diagnostic */ - static function (Throwable $error) use (&$stopped): void { - $stopped = true; + static function (Throwable $error): void { + error_log($error->getMessage()); }, ), -); + PhpErrorHandlerInterface::class => static fn(): PhpErrorHandlerInterface => $phpErrors, + ShutdownHandlerInterface::class => new ServiceFactory($shutdown), +]); -try { - $failFast->handleThrowable($failure); +$services = container($application, ErrorHandlerServiceProvider::class); - throw new LogicException('Reporter failure was swallowed.'); -} catch (RuntimeException $error) { - exampleCheck($error === $failure && !$stopped, 'Aggregate failure behavior changed.'); -} +$errors = $services->get(ErrorHandlerInterface::class); -$phpErrors = new ThrowErrorExceptionPhpErrorHandler(); -$level = error_reporting(E_ALL); +$errors->register(); try { - try { - $phpErrors->handlePhpError(E_USER_WARNING, 'Converted warning', __FILE__, __LINE__); - - throw new LogicException('An enabled warning was not converted.'); - } catch (ErrorException $error) { - exampleCheck(E_USER_WARNING === $error->getSeverity(), 'PHP severity was lost.'); - } - - error_reporting(0); - $phpErrors->handlePhpError(E_USER_WARNING, 'Suppressed warning', __FILE__, __LINE__); + trigger_error('Application-owned warning', E_USER_WARNING); + $errors->handleShutdown(); // Simulate a notification without terminating the process. } finally { - error_reporting($level); + $errors->unregister(); } -$shutdownCalls = 0; -$shutdown = new CallableShutdownHandler( - /** Demonstrate direct invocation of a custom shutdown adapter. */ - static function () use (&$shutdownCalls): void { - ++$shutdownCalls; - }, -); -$shutdown->handleShutdown(); - -exampleCheck(1 === $shutdownCalls, 'Custom shutdown callback was not invoked.'); - -// No fatal error exists in this fresh process, so the default adapter is a no-op. -(new PhpLastErrorShutdownHandler($phpErrors))->handleShutdown(); - -echo 'callable, aggregate, conversion, suppression and shutdown adapters passed' . PHP_EOL; +printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messages), $shutdowns); ``` -## Explicit PHP-error callback policy - -Supply the four-argument CallablePhpErrorHandler explicitly. The callback owns mask/conversion policy; its return value is ignored by the void interface, and failures propagate. + +## Fresh handlers and nested middleware -Source: [examples/php-errors.php](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php). +Use factory services for a new scope; ordinary lookups remain cached. [Source](https://github.com/coisa/error-handler/blob/master/examples/scopes.php). - + ```php static fn(): CallableThrowableHandler => $reporter, + ResponseFactoryInterface::class => static fn(): ResponseFactoryInterface => $psr17, + StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $psr17, +]); + +$services = container($application, ErrorHandlerServiceProvider::class); + +$makeHandler = $services->get(ErrorHandlerFactory::class); +$first = $makeHandler($services); +$second = $makeHandler($services); + +$makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class); +$outer = $makeMiddleware($services); +$inner = $makeMiddleware($services); + +$downstream = new class implements RequestHandlerInterface { + /** + * Throws a synthetic downstream failure for the inner scope. + * + * @param ServerRequestInterface $request the synthetic request + * + * @return ResponseInterface the response produced by the inner middleware */ - static function (int $code, string $message, string $filename, int $line) use (&$calls): void { - $calls[] = [$code, $message, $filename, $line]; - if (error_reporting() & $code) { - throw ErrorException::fromPhpError($message, $code, $code, $filename, $line); - } - }, -); -$errors = new ErrorHandler(new CallableThrowableHandler( + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Nested failure'); + } +}; + +$next = new class ($inner, $downstream) implements RequestHandlerInterface { /** - * Record a converted synthetic failure without output or a real transport. + * Stores the independently constructed inner middleware and application handler. * - * @param Throwable $error the callback's converted error + * @param MiddlewareInterface $inner the inner error middleware + * @param RequestHandlerInterface $downstream the application request handler */ - static function (Throwable $error) use (&$reports): void { - $reports[] = $error; - }, -), $phpErrors); + public function __construct(private MiddlewareInterface $inner, private RequestHandlerInterface $downstream) + { + } -exampleCheck([] === $calls && [] === $reports, 'Construction invoked a callback.'); -$previous = error_reporting(E_ALL); + /** + * Enters the inner scope while the outer scope remains registered. + * + * @param ServerRequestInterface $request the incoming request + * + * @return ResponseInterface the inner response + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->inner->process($request, $this->downstream); + } +}; + +$process = $services->get(ErrorHandlerInterface::class); + +$process->register(); try { - $errors->handlePhpError(E_USER_WARNING, 'Synthetic enabled input', __FILE__, 42); - error_reporting(0); - $errors->handlePhpError(E_USER_WARNING, 'Synthetic disabled input', __FILE__, 43); + $response = $outer->process($psr17->createServerRequest('GET', 'https://example.test'), $next); + trigger_error('Process handler restored', E_USER_WARNING); } finally { - error_reporting($previous); + $process->unregister(); } -exampleCheck([ - [E_USER_WARNING, 'Synthetic enabled input', __FILE__, 42], - [E_USER_WARNING, 'Synthetic disabled input', __FILE__, 43], -] === $calls, 'The callback did not receive the four original arguments.'); -exampleCheck(1 === count($reports) && $reports[0] instanceof ErrorException, 'Explicit mask/conversion policy changed.'); -exampleCheck('Synthetic enabled input' === $reports[0]->getMessage(), 'Converted diagnostic metadata was lost.'); +printf( + "Fresh handlers: %s | Fresh middleware: %s\n", + $first !== $second ? 'yes' : 'no', + $outer !== $inner ? 'yes' : 'no', +); +printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported); +``` -echo 'callable PHP errors: four arguments, explicit mask policy and conversion passed' . PHP_EOL; + +## Explicit public output capture + +Capture a separate public renderer, preserving the string zero. [Source](https://github.com/coisa/error-handler/blob/master/examples/output-capture.php). + + +```php +createStreamFromThrowable(new RuntimeException('Private diagnostics')); + +printf("Captured body: %s\n", (string) $body); ``` -## Conditional diagnostic reporting + +## Direct PSR-3 logging + +Log exact messages with the original exception context. [Source](https://github.com/coisa/error-handler/blob/master/examples/logging.php). + + +```php +}> Synthetic log records. */ + public array $records = []; + + /** + * Record arguments without transport or output, using signatures compatible with PSR-3 1.x–3.x. + * + * @param mixed $level the level chosen by the application + * @param object|string $message the logger's message argument + * @param array $context structured context retained without coercion + */ + public function log($level, $message, array $context = []): void + { + $this->records[] = ['level' => $level, 'message' => $message, 'context' => $context]; + } +}; + +$context = ['component' => 'worker', 'exception' => new LogicException('Caller context')]; +$failure = new RuntimeException('Synthetic worker failure'); +$reporter = new LogThrowableHandler($logger, LogLevel::CRITICAL, $context); + +$process = new ErrorHandler($reporter); + +$process->handleThrowable($failure); + +$empty = new RuntimeException(''); +$process->handleThrowable($empty); + +exampleCheck(2 === count($logger->records), 'Each adapter invocation must make one logger call.'); +exampleCheck(LogLevel::CRITICAL === $logger->records[0]['level'], 'The configured level changed.'); +exampleCheck($failure->getMessage() === $logger->records[0]['message'], 'The exact message changed.'); +exampleCheck($failure === $logger->records[0]['context']['exception'], 'The original throwable must win its key.'); +exampleCheck('worker' === $logger->records[0]['context']['component'], 'Caller context changed.'); +exampleCheck($context['exception'] instanceof LogicException, 'Caller context was mutated.'); + +exampleCheck('' === $logger->records[1]['message'], 'An empty message must stay empty.'); +exampleCheck($empty === $logger->records[1]['context']['exception'], 'The second original throwable changed.'); -Filter one explicit branch by PHP error severity while retaining a general diagnostic branch. A nonmatch suppresses only its delegate; it does not redact an HTTP fallback or select a response. +echo 'direct PSR-3 logging, exact messages and original context passed' . PHP_EOL; +``` -Source: [examples/filtering.php](https://github.com/coisa/error-handler/blob/master/examples/filtering.php). + +## Filtering + +Select per invocation without changing the event or defaults. [Source](https://github.com/coisa/error-handler/blob/master/examples/filtering.php). ```php @@ -498,7 +726,8 @@ $filtered = new FilteringThrowableHandler( * * @return bool true forwards this event to the deprecation branch */ - static fn(Throwable $error): bool => $error instanceof ErrorException && E_USER_DEPRECATED === $error->getSeverity(), + static fn(Throwable $error): bool => $error instanceof ErrorException + && E_USER_DEPRECATED === $error->getSeverity(), ); $routes = new ThrowableHandlerAggregate($allReports, $filtered); $deprecation = new ErrorException('Synthetic deprecation', 0, E_USER_DEPRECATED); @@ -513,11 +742,97 @@ exampleCheck([$deprecation] === $deprecations, 'Exception codes were mistaken fo echo 'explicit filtering, severity selection and original-object delivery passed' . PHP_EOL; ``` -## Attempt every reporter and retain all failures + +## Callable PHP-error conversion + +Pass three callbacks directly to ErrorHandler, retaining four original PHP-error arguments, +application-owned mask policy and explicit active shutdown notification. [Source](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php). -Opt into ordered attempts through partial success, then handle the aggregate failure explicitly. The original throwable and every reporter failure retain their identity; the default aggregate remains fail-fast. + +```php +handlePhpError(E_USER_WARNING, 'Synthetic enabled input', __FILE__, 42); + error_reporting(0); + $errors->handlePhpError(E_USER_WARNING, 'Synthetic disabled input', __FILE__, 43); +} finally { + error_reporting($previous); +} + +exampleCheck([ + [E_USER_WARNING, 'Synthetic enabled input', __FILE__, 42], + [E_USER_WARNING, 'Synthetic disabled input', __FILE__, 43], +] === $calls, 'The callback did not receive the four original arguments.'); +exampleCheck( + 1 === count($reports) && $reports[0] instanceof ErrorException, + 'Explicit mask/conversion policy changed.', +); +exampleCheck('Synthetic enabled input' === $reports[0]->getMessage(), 'Converted diagnostic metadata was lost.'); + +$errors->handleShutdown(); +exampleCheck(0 === $shutdowns, 'An inactive handler invoked the shutdown policy.'); +$errors->register(); + +try { + $errors->handleShutdown(); +} finally { + $errors->unregister(); +} + +exampleCheck(1 === $shutdowns, 'The active shutdown callback was not invoked once.'); + +echo 'callable PHP errors: four arguments, explicit mask policy and conversion passed' . PHP_EOL; +``` -Source: [examples/best-effort.php](https://github.com/coisa/error-handler/blob/master/examples/best-effort.php). + +## Best-effort reporting + +Attempt every fixed slot in order, then retain the original and all reporter failures. [Source](https://github.com/coisa/error-handler/blob/master/examples/best-effort.php). ```php @@ -603,639 +918,70 @@ exampleCheck([ echo 'best-effort order, original cause and all reporter failures passed' . PHP_EOL; ``` -## PSR-14 dispatch, listeners and logging - -Use application-owned dispatchers for raw throwables and wrapped ErrorEvent values. The complete recipe includes both callable listeners/providers and LogErrorEventListener; configuring both routes creates separate deliveries. +## Built-in JSON responses -Source: [examples/events.php](https://github.com/coisa/error-handler/blob/master/examples/events.php). +Public and explicitly enabled debug JSON preserve HTTP error codes. Middleware reports privately once, then uses the pure factory. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-json.php). - + ```php Synthetic in-memory log records. */ - public array $records = []; +$messages = new Psr17Factory(); +$mode = $argv[1] ?? 'public'; +exampleCheck(in_array($mode, ['public', 'debug'], true), 'Choose the public or debug example mode.'); - /** - * Record a synthetic log entry using signatures compatible with PSR-3 1.x–3.x. - * - * @param mixed $level the selected log level - * @param object|string $message the string or stringable event message - * @param array $context application log context - */ - public function log($level, $message, array $context = []): void - { - if (is_object($message) && method_exists($message, '__toString')) { - $message = $message->__toString(); - } +// This explicit CLI fixture choice stands in for trusted application configuration. +$debug = 'debug' === $mode; - exampleCheck(is_string($message), 'A PSR-3 message must be a string or stringable object.'); +/** @return RuntimeException a synthetic downstream failure with a native call frame */ +$createFailure = static fn(): RuntimeException => new RuntimeException('Private synthetic upstream details', 503); - $this->records[] = ['level' => $level, 'message' => $message]; - } -}; -$rawListeners = new ThrowableListenerProvider(new ThrowableCallableListener( - /** - * Verify that raw dispatch retains the original throwable. - * - * @param Throwable $error the raw PSR-14 event - */ - static function (Throwable $error) use ($failure, &$rawCalls): void { - exampleCheck($error === $failure, 'Raw event identity changed.'); +$original = $createFailure(); +$reports = []; - ++$rawCalls; - }, -)); -$wrappedListeners = new ErrorEventListenerProvider(new ErrorEventCallableListener( +$reporter = new CallableThrowableHandler( /** - * Verify that the wrapper exposes its original throwable and string representation. + * Record the original privately without printing a public diagnostic. * - * @param ErrorEventInterface $event the wrapped PSR-14 event + * @param Throwable $error the original downstream failure */ - static function (ErrorEventInterface $event) use ($failure, &$wrappedCalls): void { - exampleCheck($event->getThrowable() === $failure, 'Wrapped event identity changed.'); - exampleCheck((string) $event === (string) $failure, 'Wrapped event text changed.'); - - ++$wrappedCalls; + static function (Throwable $error) use (&$reports): void { + $reports[] = $error; // Replace this recording sink with the application's private transport. }, -)); -$wrappedListeners->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); - -$providers = new ListenerProviderAggregate(); -$providers->attach($rawListeners); -$providers->attach($wrappedListeners); - -$dispatcher = new EventDispatcher($providers); - -(new DispatchThrowableHandler($dispatcher))->handleThrowable($failure); -(new DispatchErrorEventThrowableHandler($dispatcher))->handleThrowable($failure); - -exampleCheck(1 === $rawCalls && 1 === $wrappedCalls && 1 === count($logger->records), 'Event routing changed.'); -exampleCheck(LogLevel::CRITICAL === $logger->records[0]['level'], 'The configured log level was ignored.'); - -echo 'raw throwable, ErrorEvent, listener providers and PSR-3 logging passed' . PHP_EOL; -``` - -## Existing container and ConfigProvider integration - -Provide application services to the current ErrorHandlerContainer and ConfigProvider/Laminas maps. Service discovery stays inert; cached resolution and explicit fresh factory invocation have different lifetimes. - -Source: [examples/container.php](https://github.com/coisa/error-handler/blob/master/examples/container.php). - - -```php -getDependencies()); - -$messages = new Psr17Factory(); - -$dispatcher = new EventDispatcher(new ListenerProviderAggregate()); - -foreach ([$application, $native] as $services) { - $services->setService(CallableThrowableHandler::class, $reporter); - $services->setService(EventDispatcherInterface::class, $dispatcher); - $services->setService(ResponseFactoryInterface::class, $messages); - $services->setService(StreamFactoryInterface::class, $messages); -} - -$legacy = new ErrorHandlerContainer($application); - -$process = $legacy->get(ErrorHandler::class); - -exampleCheck($process instanceof ErrorHandler, 'The container returned the wrong core service.'); - -$process->handleThrowable(new RuntimeException('Legacy container fixture')); - -exampleCheck($legacy->get(ErrorHandler::class) === $process, 'The legacy container must cache its handler.'); - -$fresh = (new ErrorHandlerFactory())($legacy); - -exampleCheck($fresh !== $process, 'Direct factory invocation must construct a new handler.'); - -$fresh->handleThrowable(new RuntimeException('Fresh factory fixture')); - -// A ConfigProvider-aware application can consume the same maps directly. -exampleCheck($configuration['dependencies'] === $provider->getDependencies(), 'ConfigProvider maps changed.'); - -$native->get(ErrorHandler::class)->handleThrowable(new RuntimeException('ConfigProvider fixture')); - -foreach (array_keys($provider->getFactories()) as $service) { - exampleCheck($legacy->get($service) instanceof $service, 'A legacy provider factory did not resolve its service.'); - exampleCheck($native->get($service) instanceof $service, 'A native provider factory did not resolve its service.'); -} - -exampleCheck(3 === $reports, 'One of the composition paths lost its reporter.'); - -echo 'legacy container, cached services, fresh factory and ConfigProvider passed' . PHP_EOL; -``` - -## HTTP responses, output capture and a safe application adapter - -Exercise normal and failed PSR-15 processing, captured output, the legacy empty/zero diagnostic fallback and an explicit application response factory. The current middleware does not report independently before rendering; do not add a second diagnostic call around the built-in capture chain. - -The example's single-line reason remains unchanged. In a checkout/release containing the [reason-phrase repair](https://github.com/coisa/error-handler/issues/89), diagnostics with forbidden status-line controls use the PSR-17 default reason instead. This does not redact the original throwable or the legacy body fallback; see [the HTTP contract](http.md#existing-response-and-output-capture). - -Source: [examples/http.php](https://github.com/coisa/error-handler/blob/master/examples/http.php). - - -```php -messages = $messages; - $this->reporter = $reporter; - } - - /** - * Report once, then build a fixed public response without exception details. - * - * @param Throwable $error the downstream failure to report privately - * - * @return ResponseInterface the fixed public error response - */ - public function createResponseFromThrowable(Throwable $error): ResponseInterface - { - $this->reporter->handleThrowable($error); - - return $this->messages->createResponse(500)->withBody( - $this->messages->createStream('Internal Server Error'), - ); - } - }; -} elseif ('renderer-failure' === $mode) { - $responses = new class implements ThrowableResponseFactoryInterface { - /** - * Demonstrate cleanup when an application renderer fails. - * - * @param Throwable $error the downstream failure - * - * @return ResponseInterface no response is returned by this failing fixture - * - * @throws RuntimeException the synthetic renderer failure - */ - public function createResponseFromThrowable(Throwable $error): ResponseInterface - { - throw new RuntimeException('Renderer fixture failed'); - } - }; -} - -$next = new class ($messages, $mode) implements RequestHandlerInterface { - /** @var Psr17Factory the application-owned HTTP factories */ - private Psr17Factory $messages; - - /** @var string the selected synthetic downstream route */ - private string $mode; - - /** - * Store the synthetic request route. - * - * @param Psr17Factory $messages the application HTTP factories - * @param string $mode the selected example route - */ - public function __construct(Psr17Factory $messages, string $mode) - { - $this->messages = $messages; - $this->mode = $mode; - } - - /** - * Return a normal PSR response or throw a synthetic downstream failure. - * - * @param ServerRequestInterface $request the synthetic request - * - * @return ResponseInterface the successful route's response - * - * @throws RuntimeException for an error-response fixture - */ - public function handle(ServerRequestInterface $request): ResponseInterface - { - if ('success' === $this->mode) { - return $this->messages->createResponse(200)->withBody($this->messages->createStream('OK')); - } - - throw new RuntimeException('Public example failure', 404); - } -}; - -/** Represent a surrounding application's PHP error callback. */ -$previousError = static fn(): bool => true; - -/** - * Represent a surrounding application's exception callback. - * - * @param Throwable $error an unexpected failure escaping middleware - * - * @throws LogicException if the surrounding callback is unexpectedly invoked - */ -$previousException = static function (Throwable $error): void { - throw new LogicException('The surrounding exception callback must remain unused.', 0, $error); -}; - -set_error_handler($previousError); -set_exception_handler($previousException); - -$middleware = new ErrorHandlerMiddleware($errors, $responses); -$bufferLevel = ob_get_level(); -$response = null; -$bufferDelta = 0; - -try { - $response = $middleware->process($messages->createServerRequest('GET', '/example'), $next); -} catch (RuntimeException $error) { - exampleCheck(in_array($mode, ['renderer-failure', 'reporter-failure'], true), 'Unexpected HTTP fixture failure.'); - exampleCheck(false !== strpos($error->getMessage(), 'fixture failed'), 'Failure identity changed.'); - - // Observe the library's exceptional cleanup before the fixture's final safety cleanup. - $bufferDelta = ob_get_level() - $bufferLevel; -} finally { - while (ob_get_level() > $bufferLevel) { - ob_end_clean(); - } - - $actualError = set_error_handler($previousError); - restore_error_handler(); - $actualException = set_exception_handler($previousException); - restore_exception_handler(); - - restore_error_handler(); - restore_exception_handler(); - - exampleCheck( - $actualError === $previousError && $actualException === $previousException, - 'Middleware lost surrounding callbacks.', - ); -} - -if ('renderer-failure' === $mode || 'reporter-failure' === $mode) { - exampleCheck(null === $response, 'A failed renderer unexpectedly returned a response.'); - exampleCheck(0 === $bufferDelta, 'A failed renderer left an output buffer behind.'); -} else { - exampleCheck($response instanceof ResponseInterface, 'Middleware did not return a PSR response.'); - - $body = (string) $response->getBody(); - - if ('capture' === $mode) { - exampleCheck( - 'Public example response' === $body && 404 === $response->getStatusCode(), - 'Captured response changed.', - ); - exampleCheck( - 'Public example failure' === $response->getReasonPhrase(), - 'The existing reason phrase policy changed.', - ); - } elseif ('safe' === $mode) { - exampleCheck( - 'Internal Server Error' === $body && 500 === $response->getStatusCode(), - 'Safe application response changed.', - ); - } elseif ('success' === $mode) { - exampleCheck('OK' === $body && 200 === $response->getStatusCode(), 'Normal response changed.'); - } else { - exampleCheck(in_array($mode, ['empty', 'zero'], true), 'Unknown HTTP example mode.'); - exampleCheck( - false !== strpos($body, 'Public example failure') && false !== strpos($body, 'RuntimeException'), - 'The existing fallback changed.', - ); - } -} - -exampleCheck( - ('success' === $mode || 'renderer-failure' === $mode ? 0 : 1) === $reports, - 'Unexpected reporting count.', -); - -echo 'HTTP ' . $mode . ': response/reporting contract and callback restoration passed; buffer delta ' - . $bufferDelta . PHP_EOL; -``` - -## Pure JSON with one private report - -Use explicit JSON and reporting adapters without changing the legacy middleware or default renderer. Both modes preserve integer 503 and report the original privately once. Public output contains only the controlled error; explicitly selected development output adds metadata-only trace and no-store. The CLI choice represents trusted configuration. No output buffer, diagnostic echo or HTTP emission is required. - -Source: [examples/http-json.php](https://github.com/coisa/error-handler/blob/master/examples/http-json.php). - - -```php - new RuntimeException('Private synthetic upstream details', 503); - -$original = $createFailure(); -$reports = []; - -$reporter = new CallableThrowableHandler( - /** - * Record the original privately without printing a public diagnostic. - * - * @param Throwable $error the original downstream failure - */ - static function (Throwable $error) use (&$reports): void { - $reports[] = $error; // Replace this recording sink with the application's private transport. - }, -); - -$errors = new ErrorHandler($reporter); - -$json = new JsonThrowableResponseFactory($messages, $messages, $debug); - -$responses = new ReportingThrowableResponseFactory($reporter, $json); - -$middleware = new ErrorHandlerMiddleware($errors, $responses); - -$next = new class ($original) implements RequestHandlerInterface { - /** @var Throwable the synthetic downstream failure */ - private Throwable $original; - - /** - * Store the original event for the synthetic application route. - * - * @param Throwable $original the event the middleware will catch - */ - public function __construct(Throwable $original) - { - $this->original = $original; - } - - /** - * Fail the route without printing diagnostics or emitting a response. - * - * @param ServerRequestInterface $request the synthetic request - * - * @return ResponseInterface no response is returned by this failing route - * - * @throws Throwable the original synthetic failure - */ - public function handle(ServerRequestInterface $request): ResponseInterface - { - throw $this->original; - } -}; - -$bufferLevel = ob_get_level(); -$response = $middleware->process($messages->createServerRequest('GET', '/example'), $next); - -exampleCheck([$original] === $reports, 'Expected exactly one private report of the original event.'); -exampleCheck(503 === $response->getStatusCode(), 'The integer HTTP error code was not preserved.'); -exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected a JSON representation.'); -if ($debug) { - $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - exampleCheck('Request failed' === $payload['error'], 'The public error label changed.'); - exampleCheck( - $original->getMessage() === $payload['exception']['message'] && [] !== $payload['exception']['trace'], - 'Explicit development metadata is missing.', - ); - foreach ($payload['exception']['trace'] as $frame) { - exampleCheck( - !array_key_exists('args', $frame) && !array_key_exists('object', $frame), - 'Private trace arguments or objects must not be serialized.', - ); - } - exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Debug details must not be cached.'); -} else { - exampleCheck( - '{"error":"Request failed"}' === (string) $response->getBody(), - 'The generic JSON body must not contain the private diagnostic.', - ); -} -exampleCheck( - $original->getMessage() !== $response->getReasonPhrase(), - 'The diagnostic message must not become an HTTP reason phrase.', -); -exampleCheck($bufferLevel === ob_get_level(), 'JSON composition unexpectedly changed the buffer stack.'); - -// The application owns response emission; this CLI recipe prints only its verification summary. -echo 'HTTP JSON ' . $mode . ': explicit payload, status preservation and one private report passed' . PHP_EOL; -``` - -## Optional Interop JSON profile - -Use the additive JSON profile with application-owned PSR-17 and a private reporter. Each provider declares only its own services: select JSON before the separate base provider so its response alias wins over legacy capture. The base supplies lifecycle/defaults and scope factories; JSON supplies reporting plus its pure renderer. This integration requires PHP 8.3+ and FastForward installed in the application's runtime dependencies. Config supplies an explicit bool before container construction. Each scope-factory invocation creates a distinct handler and middleware; existing 2.x shutdown-queue limits remain. Select these two modes with the existing verifier's `--with-fast-forward` option. - -Source: [examples/provider-json.php](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). - - -```php -= 80300 && function_exists('FastForward\Container\container'), - 'Install fast-forward/container:^1.9 in the application and use PHP 8.3 or newer.', -); -$mode = $argv[1] ?? 'public'; -exampleCheck(in_array($mode, ['public', 'debug'], true), 'Choose the public or debug example mode.'); - -// This CLI choice stands in for trusted application configuration, never request headers. -$settings = config(['error_handler' => ['json' => ['debug' => 'debug' === $mode]]]); -$messages = new Psr17Factory(); -$original = new RuntimeException('Private synthetic provider failure', 503); -$reports = []; -$reporter = new CallableThrowableHandler( - /** - * Record one original event without writing diagnostics into a public response. - * - * @param Throwable $event the original downstream failure - */ - static function (Throwable $event) use (&$reports): void { - $reports[] = $event; // Replace this recording sink with the application's private transport. - }, -); - -$application = new ArrayServiceProvider([ - ResponseFactoryInterface::class => new ServiceFactory($messages), - StreamFactoryInterface::class => AliasFactory::get(ResponseFactoryInterface::class), - ThrowableHandlerInterface::class => new ServiceFactory($reporter), -]); - -// Separate providers are first-wins: the JSON selection must precede the base capture alias. -if ('debug' === $mode) { - $services = container( - new JsonErrorHandlerServiceProvider($settings->get('error_handler.json.debug', false)), - ErrorHandlerServiceProvider::class, - $application, - ); -} else { - $services = container( - JsonErrorHandlerServiceProvider::class, - ErrorHandlerServiceProvider::class, - $application, - ); -} +); -// Cache the factory, then invoke it for a distinct handler and wrapper for each HTTP scope. -$middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); +$errors = new ErrorHandler($reporter); -$middleware = $middlewareFactory($services); +$serializer = new Serializer([new ProblemNormalizer(true)], [new JsonEncoder()]); + +$json = new JsonThrowableResponseFactory($messages, $messages, $serializer, $debug); + +$middleware = new ErrorHandlerMiddleware($errors, $json); $next = new class ($original) implements RequestHandlerInterface { /** @var Throwable the synthetic downstream failure */ private Throwable $original; /** - * Retain original identity for the private reporter. + * Store the original event for the synthetic application route. * - * @param Throwable $original the event that the middleware will catch + * @param Throwable $original the event the middleware will catch */ public function __construct(Throwable $original) { @@ -1243,13 +989,13 @@ $next = new class ($original) implements RequestHandlerInterface { } /** - * Fail a synthetic route without emission or registration of its own callbacks. + * Fail the route without printing diagnostics or emitting a response. * - * @param ServerRequestInterface $request the synthetic HTTP request + * @param ServerRequestInterface $request the synthetic request * * @return ResponseInterface no response is returned by this failing route * - * @throws Throwable the original downstream failure + * @throws Throwable the original synthetic failure */ public function handle(ServerRequestInterface $request): ResponseInterface { @@ -1257,34 +1003,110 @@ $next = new class ($original) implements RequestHandlerInterface { } }; -$response = $middleware->process($messages->createServerRequest('GET', '/provider-json'), $next); +$bufferLevel = ob_get_level(); +$response = $middleware->process($messages->createServerRequest('GET', '/example'), $next); -exampleCheck([$original] === $reports, 'The profile must report the exact original once.'); -exampleCheck(503 === $response->getStatusCode(), 'Expected the native HTTP error code.'); -exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected JSON.'); +exampleCheck([$original] === $reports, 'Expected exactly one private report of the original event.'); +exampleCheck(503 === $response->getStatusCode(), 'The integer HTTP error code was not preserved.'); +exampleCheck( + 'application/problem+json' === $response->getHeaderLine('Content-Type'), + 'Expected a JSON Problem Details representation.', +); +$publicBody = '{"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Request failed"}'; -if ('debug' === $mode) { +if ($debug) { $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); exampleCheck( - $original->getMessage() === $payload['exception']['message'], - 'Trusted debug configuration did not reach the renderer.', + $original->getMessage() === $payload['detail'] && [] !== $payload['trace'], + 'Explicit development metadata is missing.', ); - exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.'); + foreach ($payload['trace'] as $frame) { + exampleCheck( + [] === ($frame['args'] ?? []) && !array_key_exists('object', $frame), + 'Private trace arguments or objects must not be serialized.', + ); + } + exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Debug details must not be cached.'); } else { - exampleCheck('{"error":"Request failed"}' === (string) $response->getBody(), 'Public JSON leaked diagnostics.'); + exampleCheck( + $publicBody === (string) $response->getBody(), + 'The generic JSON body must not contain the private diagnostic.', + ); } +exampleCheck( + $original->getMessage() !== $response->getReasonPhrase(), + 'The diagnostic message must not become an HTTP reason phrase.', +); +exampleCheck($bufferLevel === ob_get_level(), 'JSON composition unexpectedly changed the buffer stack.'); -// Applications emit the response; this CLI fixture prints only its verification summary. -echo 'Interop JSON ' . $mode . ': configured profile, fresh scope and one private report passed' . PHP_EOL; +// The application owns response emission; this CLI recipe prints only its verification summary. +echo 'HTTP JSON ' . $mode . ': explicit payload, status preservation and one private report passed' . PHP_EOL; ``` -Continue with the [core API](api.md), [container integration](container.md), [HTTP composition](http.md) and [testing guide](testing.md). Those guides explain lifecycle ownership, reporting failures and the current output-capture limits. + +## Public and diagnostic Symfony HTML + +Report explicitly once, then render with the required current request. Public mode hides diagnostic details; trusted debug enables Symfony diagnostics. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-html.php). + + +```php +createServerRequest('GET', 'http://example.test/html'); +$renderer = new HtmlThrowableResponseFactory($messages, $messages, $debug); + +// An explicit pipeline owns reporting; the renderer only builds a response. +$errors->handleThrowable($failure); +$response = $renderer->createResponseFromThrowable($failure, $request); + +if ([$failure] !== $reports || 503 !== $response->getStatusCode()) { + throw new RuntimeException('Expected one original report and the HTTP error status.'); +} +if ( + 'text/html; charset=UTF-8' !== $response->getHeaderLine('Content-Type') + || ($debug && 'no-store' !== $response->getHeaderLine('Cache-Control')) +) { + throw new RuntimeException('Expected HTML and a no-store policy for debug.'); +} +if ($debug !== str_contains((string) $response->getBody(), $failure->getMessage())) { + throw new RuntimeException('Only explicit debug may disclose the diagnostic.'); +} +if ($failure->getMessage() === $response->getReasonPhrase()) { + throw new RuntimeException('Diagnostic text must not become the reason phrase.'); +} -## Optional development HTML with Whoops +// Applications own emission; this fixture prints no diagnostic HTML. +echo 'HTTP HTML ' . $mode . ': Symfony rendering and one original report passed' . PHP_EOL; +``` -Install the optional application dependency described in the [HTTP guide](http.md#opt-in-development-html-with-whoops). Explicit trusted development configuration selects a fresh configured page; public mode selects generic JSON without constructing Whoops. No request negotiation or response emission is performed. Selected hidden keys are not comprehensive redaction. +## Explicit development HTML -Source: [examples/http-whoops.php](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). +Trusted configuration selects public Symfony HTML or explicit optional Whoops HTML. The application owns diagnostic policy; no request enables it. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). ```php @@ -1294,8 +1116,7 @@ declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ReportingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; @@ -1324,7 +1145,7 @@ $reporter = new CallableThrowableHandler( }, ); -$renderer = new JsonThrowableResponseFactory($messages, $messages); +$renderer = new HtmlThrowableResponseFactory($messages, $messages); if ($debug) { $renderer = new WhoopsThrowableResponseFactory( @@ -1346,11 +1167,9 @@ if ($debug) { exampleCheck(0 === $pageCalls, 'Construction must not create a page.'); -$responses = new ReportingThrowableResponseFactory($reporter, $renderer); - $errors = new ErrorHandler($reporter); -$middleware = new ErrorHandlerMiddleware($errors, $responses); +$middleware = new ErrorHandlerMiddleware($errors, $renderer); $next = new class ($original) implements RequestHandlerInterface { /** @var Throwable the synthetic application failure */ @@ -1388,25 +1207,150 @@ if ($debug) { exampleCheck('text/html; charset=UTF-8' === $response->getHeaderLine('Content-Type'), 'Expected HTML.'); exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.'); exampleCheck( - false !== strpos((string) $response->getBody(), '<synthetic-development-detail>'), + str_contains((string) $response->getBody(), '<synthetic-development-detail>'), 'Expected an escaped diagnostic in the development page.', ); } else { exampleCheck(0 === $pageCalls, 'Public mode must not construct a Whoops page.'); - exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected public JSON.'); - exampleCheck('{"error":"Request failed"}' === (string) $response->getBody(), 'Expected the generic public body.'); + exampleCheck('text/html; charset=UTF-8' === $response->getHeaderLine('Content-Type'), 'Expected public HTML.'); + exampleCheck( + !str_contains((string) $response->getBody(), 'synthetic-development-detail'), + 'Public HTML must not disclose the diagnostic.', + ); } // No response is emitted. Hiding selected keys is not complete redaction or production authorization. echo 'HTTP Whoops ' . $mode . ': explicit renderer selection and one private report passed' . PHP_EOL; ``` +## JSON negotiation with the single provider + +Compose base, JSON and application maps explicitly, with a real bool for debug and a fresh middleware scope. [Source](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). + + +```php += 80300 && function_exists('FastForward\Container\container'), + 'Load the FastForward runtime dependency of this source on PHP 8.3 or newer.', +); +$mode = $argv[1] ?? 'public'; +exampleCheck(in_array($mode, ['public', 'debug'], true), 'Choose the public or debug example mode.'); + +// This CLI choice stands in for trusted application configuration, never request headers. +$settings = config(['error_handler' => ['debug' => 'debug' === $mode]]); +$messages = new Psr17Factory(); +$original = new RuntimeException('Private synthetic provider failure', 503); +$reports = []; +$reporter = new CallableThrowableHandler( + /** + * Record one original event without writing diagnostics into a public response. + * + * @param Throwable $event the original downstream failure + */ + static function (Throwable $event) use (&$reports): void { + $reports[] = $event; // Replace this recording sink with the application's private transport. + }, +); + +$application = new ArrayServiceProvider([ + ResponseFactoryInterface::class => new ServiceFactory($messages), + StreamFactoryInterface::class => AliasFactory::get(ResponseFactoryInterface::class), + ThrowableHandlerInterface::class => new ServiceFactory($reporter), +]); + +// The application is searched first; one library provider supplies negotiation and lifecycle. +$services = container( + $application, + new ErrorHandlerServiceProvider(debug: $settings->get('error_handler.debug', false)), +); + +// Cache the factory, then invoke it for a distinct handler and wrapper for each HTTP scope. +$middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); + +$middleware = $middlewareFactory($services); + +$next = new class ($original) implements RequestHandlerInterface { + /** @var Throwable the synthetic downstream failure */ + private Throwable $original; + + /** + * Retain original identity for the private reporter. + * + * @param Throwable $original the event that the middleware will catch + */ + public function __construct(Throwable $original) + { + $this->original = $original; + } + + /** + * Fail a synthetic route without emission or registration of its own callbacks. + * + * @param ServerRequestInterface $request the synthetic HTTP request + * + * @return ResponseInterface no response is returned by this failing route + * + * @throws Throwable the original downstream failure + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw $this->original; + } +}; + +$request = $messages->createServerRequest('GET', '/provider-json') + ->withHeader('Accept', 'application/json') +; + +$response = $middleware->process($request, $next); + +exampleCheck([$original] === $reports, 'Middleware must report the exact original once.'); +exampleCheck(503 === $response->getStatusCode(), 'Expected the native HTTP error code.'); +exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected JSON.'); + +$publicBody = '{"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Request failed"}'; + +if ('debug' === $mode) { + $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + exampleCheck( + $original->getMessage() === $payload['detail'], + 'Trusted debug configuration did not reach the renderer.', + ); + exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.'); +} else { + exampleCheck($publicBody === (string) $response->getBody(), 'Public JSON leaked diagnostics.'); +} -## Optional Interop Whoops profile +// Applications emit the response; this CLI fixture prints only its verification summary. +echo 'Interop JSON ' . $mode . ': configured provider, fresh scope and one private report passed' . PHP_EOL; +``` -With PHP 8.3+, install FastForward as an application runtime dependency and optional Whoops ^2.18.7 as described in the [provider guide](container.md#development-html-profile). Trusted application configuration selects this diagnostic HTML profile. Compose the independent providers in one aggregate ordered base, Whoops, application. The provider has no constructor arguments; its default page service is lazy and cached. The configured mode reads Config and binds a prepared `PrettyPageHandler` through `ServiceFactory`. Both use private reporting and two distinct middleware scopes, without negotiation or emission. Sequential renders retain page configuration while replacing the current diagnostic. The guide records container fallback and missing-package limits. +## Public HTML and explicit Whoops negotiation -Source: [examples/provider-whoops.php](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). +Compose independent maps in one aggregate, configure the page service and reuse it sequentially while creating distinct request scopes. [Source](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). ```php @@ -1416,14 +1360,19 @@ declare(strict_types=1); use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use CoiSA\ErrorHandler\ServiceProvider\WhoopsErrorHandlerServiceProvider; use FastForward\Container\Factory\AliasFactory; +use FastForward\Container\Factory\CallableFactory; use FastForward\Container\Factory\ServiceFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -1438,13 +1387,13 @@ require __DIR__ . '/bootstrap.php'; exampleCheck( PHP_VERSION_ID >= 80300 && function_exists('FastForward\Container\container'), - 'Install fast-forward/container:^1.9 in the application and use PHP 8.3 or newer.', + 'Load the FastForward runtime dependency of this source on PHP 8.3 or newer.', ); -// FastForward integration requires PHP 8.3+ and an application runtime dependency. -// Selecting this profile deliberately chooses diagnostic HTML; no request enables it. +// FastForward is a required runtime dependency of this PHP 8.3+ major. +// Trusted debug permits HTML; the current request must still accept it. $mode = $argv[1] ?? 'default'; -exampleCheck(in_array($mode, ['default', 'configured'], true), 'Choose default or configured mode.'); +exampleCheck(in_array($mode, ['public', 'default', 'configured'], true), 'Choose public, default or configured mode.'); $messages = new Psr17Factory(); $reports = []; @@ -1464,6 +1413,10 @@ $factories = [ ]; if ('configured' === $mode) { + if (!class_exists(PrettyPageHandler::class)) { + throw new RuntimeException('The configured development page needs filp/whoops:^2.18.7.'); + } + $settings = config([ 'development' => [ 'page_title' => 'Synthetic application diagnostics', @@ -1482,14 +1435,34 @@ if ('configured' === $mode) { $factories[PrettyPageHandler::class] = new ServiceFactory($page); } +if ('public' !== $mode) { + // Whoops is an explicit application choice; package presence never selects it. + $factories[ThrowableResponseFactoryInterface::class] = new CallableFactory( + /** + * Retain request negotiation while selecting a diagnostic HTML integration. + * + * @param ContainerInterface $services the composed application services + * + * @return NegotiatingThrowableResponseFactory the explicit application response policy + */ + static function (ContainerInterface $services): NegotiatingThrowableResponseFactory { + /** @var ThrowableResponseFactoryInterface $fallback the generic error response */ + $fallback = $services->get(ThrowableResponseFactory::class); + + return new NegotiatingThrowableResponseFactory($fallback, [ + 'text/plain' => static fn() => $fallback, + 'application/json' => static fn() => $services->get(JsonThrowableResponseFactory::class), + 'application/problem+json' => static fn() => $services->get(JsonThrowableResponseFactory::class), + 'text/html; charset=UTF-8' => static fn() => $services->get(WhoopsThrowableResponseFactory::class), + ]); + }, + ); +} + $application = new ArrayServiceProvider($factories); -// One aggregate selects HTML explicitly and applies the application's page override. -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new WhoopsErrorHandlerServiceProvider(), - $application, -)); +// Application definitions win; development modes explicitly select the Whoops HTML candidate. +$services = container($application, new ErrorHandlerServiceProvider(debug: 'public' !== $mode)); $middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); exampleCheck($middlewareFactory instanceof ErrorHandlerMiddlewareFactory, 'Expected the fresh-scope factory.'); @@ -1515,8 +1488,7 @@ $failures = [ * * @return RequestHandlerInterface the failing application route */ -function exampleWhoopsRoute(Throwable $failure): RequestHandlerInterface -{ +$route = static function (Throwable $failure): RequestHandlerInterface { return new class ($failure) implements RequestHandlerInterface { /** @var Throwable the unchanged synthetic route failure */ private Throwable $failure; @@ -1539,40 +1511,156 @@ function exampleWhoopsRoute(Throwable $failure): RequestHandlerInterface throw $this->failure; } }; -} +}; $first = $firstScope->process( - $messages->createServerRequest('GET', '/first'), - exampleWhoopsRoute($failures[0]), + $messages->createServerRequest('GET', '/first')->withHeader('Accept', 'text/html'), + $route($failures[0]), ); $second = $secondScope->process( - $messages->createServerRequest('GET', '/second'), - exampleWhoopsRoute($failures[1]), + $messages->createServerRequest('GET', '/second')->withHeader('Accept', 'text/html'), + $route($failures[1]), ); exampleCheck($failures === $reports, 'Each original must reach the private reporter once.'); exampleCheck(503 === $first->getStatusCode() && 404 === $second->getStatusCode(), 'Expected original HTTP codes.'); exampleCheck('text/html; charset=UTF-8' === $first->getHeaderLine('Content-Type'), 'Expected HTML.'); -exampleCheck('no-store' === $first->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.'); exampleCheck( - false === strpos((string) $second->getBody(), $failures[0]->getMessage()), + 'public' === $mode || 'no-store' === $first->getHeaderLine('Cache-Control'), + 'Diagnostics must not be cached.', +); +exampleCheck( + !str_contains((string) $second->getBody(), $failures[0]->getMessage()), 'The next page must not retain the first diagnostic.', ); +if ('public' === $mode) { + exampleCheck(0 === $pageCalls, 'Public HTML must not resolve a Whoops page.'); + foreach ($failures as $failure) { + exampleCheck( + !str_contains((string) $first->getBody(), $failure->getMessage()) + && !str_contains((string) $second->getBody(), $failure->getMessage()), + 'Public HTML must not expose diagnostics.', + ); + } +} + if ('configured' === $mode) { exampleCheck(1 === $pageCalls, 'Expected one cached configured page for sequential renders.'); exampleCheck( - false !== strpos((string) $first->getBody(), 'Synthetic application diagnostics'), + str_contains((string) $first->getBody(), 'Synthetic application diagnostics'), 'Expected the application-configured title.', ); exampleCheck( - false !== strpos((string) $second->getBody(), 'Synthetic application diagnostics'), + str_contains((string) $second->getBody(), 'Synthetic application diagnostics'), 'The cached page must retain application configuration.', ); } // No HTTP emission. Hiding selected keys is not comprehensive redaction. -echo 'Whoops provider ' . $mode . ': lazy pages, fresh scopes and private reporting passed' . PHP_EOL; +echo 'public' === $mode + ? 'HTML provider public: Symfony renderer, fresh scopes and private reporting passed' . PHP_EOL + : 'Whoops provider ' . $mode . ': lazy pages, fresh scopes and private reporting passed' . PHP_EOL; +``` + + +## Symfony terminal diagnostics + +Choose explicit terminal reporting, an opt-in provider or one outer Console exception catch. +The BufferedOutput is a synthetic destination; omit it for stderr in an application. +[Source](https://github.com/coisa/error-handler/blob/master/examples/symfony-cli.php). + + +```php + new ServiceFactory($output), + ]); + $services = container($application, new ErrorHandlerServiceProvider(cli: true)); + $errors = $services->get(ErrorHandler::class); +} + +if ('console' === $mode) { + $application = new Application('Synthetic command'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->addCommands([new class ($failure) extends Command { + /** @param Throwable $failure the unchanged synthetic command failure */ + public function __construct(private readonly Throwable $failure) + { + parent::__construct('fail'); + } + + /** + * Fail without letting Console become a second exception renderer. + * + * @param InputInterface $input the synthetic command input + * @param OutputInterface $output the application-owned command output + * + * @return int this synthetic command never returns normally + * + * @throws Throwable the unchanged command failure + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + throw $this->failure; + } + }]); + + // The bootstrap owns this catch and reports once; Console's exception catch is disabled. + try { + $application->run(new ArrayInput(['command' => 'fail']), $output); + } catch (Throwable $caught) { + exampleCheck($failure === $caught, 'The original command failure must reach the bootstrap.'); + $errors->handleThrowable($caught); + } +} else { + $errors->handleThrowable($failure); +} + +$diagnostic = $output->fetch(); +exampleCheck(1 === substr_count($diagnostic, $failure->getMessage()), 'Expected one terminal diagnostic.'); +exampleCheck(str_contains($diagnostic, RuntimeException::class), 'Expected Symfony exception formatting.'); + +// Reporting does not set an exit status; this verification program succeeds intentionally. +echo 'Symfony CLI ' . $mode . ': one terminal diagnostic and explicit capture ownership passed' . PHP_EOL; ``` +## Failure and lifetime boundaries + +Middleware requires lifecycle and reporting on one scoped object. It reports once before a separate pure renderer; reporter failure propagates without rendering. Generic output ignores reporter bytes, so diagnostics must use private sinks. + +Unregister in reverse order and use distinct nested instances. Failed-reporter suppression applies within a registered lifecycle; independent direct calls remain usable. Arbitrary exit/die, engine fatals, callback side effects and concurrent native callback scopes are not recovered. See [provider](fast-forward.md), [API](api.md), [migration](migration.md) and [testing](testing.md). + +The explicit logging, filtering, callable PHP-error and best-effort adapters are retained from delivered 2.x APIs. The default aggregate remains fail-fast; no implicit adapter discovery is added. Concrete JSON/Whoops adapters remain available. The single provider negotiates JSON or public/debug Symfony HTML from each current request; optional Whoops requires an explicit application override. All response factories require the current request, and middleware reports before rendering. ErrorHandler accepts matching callables directly while preserving interface precedence, default policies and explicit lifecycle ownership. diff --git a/docs/fast-forward.md b/docs/fast-forward.md new file mode 100644 index 0000000..94d8b1d --- /dev/null +++ b/docs/fast-forward.md @@ -0,0 +1,411 @@ +# FastForward service provider + +`coisa/error-handler` uses FastForward Container's Interop service-provider +contract under the `CoiSA\ErrorHandler` namespace. It requires PHP `^8.3`; +CI targets PHP 8.3–8.5. + +## Installation and bootstrap + +The library requires `fast-forward/container:^1.10` and the +`container-interop/service-provider:^0.4` interface package at runtime. +Composer installs them for consumers. The provider reuses FastForward's +`AliasFactory::get()`, `InvokableFactory` and `CallableFactory`; it does not ship a second container. +To install the container explicitly in an application: + +```sh +composer require fast-forward/container:^1.10 +``` + +FastForward 1.10 requires Config `^1.6`; these lines no longer use PHP-DI or +DotAccessData. Record the actual PHP and locked versions rather than treating a +successful Composer resolution as a complete runtime check. See +[test evidence](testing.md#coverage-and-current-evidence). + +The development checkout includes it in `require`. After installing +the checkout dependencies, run the complete [example](https://github.com/coisa/error-handler/blob/master/examples/fast-forward.php): + +```sh +php examples/fast-forward.php +``` + +It reports one warning and restores the prior PHP callbacks. Pass the library +provider class and application provider directly to `container()`. This example +adds a callable reporter for the library's optional reporter discovery. Its +`bootstrap.php` selects the checkout or application autoloader as described in +[example setup](https://github.com/coisa/error-handler/blob/master/examples/README.md): + +```php + application service factories + */ + public function getFactories(): array + { + return [ + CallableThrowableHandler::class => static fn(): CallableThrowableHandler => new CallableThrowableHandler( + /** + * Prints only this CLI recipe's synthetic diagnostic. + * + * @param Throwable $error the converted warning supplied for reporting + */ + static function (Throwable $error): void { + printf("Reported: %s\n", $error->getMessage()); + }, + ), + ]; + } + + /** + * Declares no application service extensions. + * + * @return array an empty extension map + */ + public function getExtensions(): array + { + return []; + } +}; + +$services = container($application, ErrorHandlerServiceProvider::class); + +/** + * @var ErrorHandlerInterface $errors + */ +$errors = $services->get(ErrorHandlerInterface::class); + +$errors->register(); + +try { + trigger_error('FastForward provider warning', E_USER_WARNING); +} finally { + $errors->unregister(); +} +``` + +For a process-wide handler, keep it registered through termination. For a scoped +operation, use `try/finally` and unregister; the caller then owns any propagated +exception. See [lifecycle](api.md#lifecycle-and-diagnostic-reporting) before combining these patterns. + +`getFactories()` declares lazy services; `getExtensions()` returns an empty +array. Neither method, service construction nor `has()` installs PHP callbacks. +There is no provider boot hook. The application decides when to register. + +## Definitions and overrides + +| Service | Provider behavior | +| --- | --- | +| `ErrorHandlerInterface` / `ErrorHandler` | The same cached process handler; initially unregistered | +| `ThrowableHandlerInterface` | Default aggregate of available callable and PSR-14 reporters | +| `DispatchThrowableHandler` / `DispatchErrorEventThrowableHandler` | Existing PSR-14 adapters; require an application dispatcher | +| `ThrowableStreamFactoryInterface` | Pure generic fallback stream; requires a PSR-17 stream factory | +| `ThrowableResponseFactoryInterface` | Request-aware negotiation among generic, JSON and Symfony HTML responses | +| `HtmlThrowableResponseFactory` / `JsonThrowableResponseFactory` / `WhoopsThrowableResponseFactory` | Pure concrete renderers; only Whoops requires its optional package | +| `PrettyPageHandler` | Lazy cached page, customizable through an application service | +| `ErrorHandlerMiddleware` | Cached middleware with a handler separate from the process handler | +| `ServiceProvider\Factory\ErrorHandlerFactory` | Reusable callable that creates a fresh handler on each invocation | +| `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` | Reusable callable that creates fresh middleware and a fresh handler | + +The single provider declares lifecycle, response negotiation and renderer services. +Dispatch handlers and simple generic stream/response adapters use FastForward +constructor autowiring. Interface aliases remain explicit, and application definitions +can override either an interface or its concrete implementation when their +maps are merged as shown under [required overrides](#required-overrides). Explicit library factories encode optional-handler defaults, reporter composition, +fresh scopes and lazy renderer selection; autowiring alone does not replace those +contracts. The factory-object services use FastForward's `InvokableFactory`. + +Bind a required custom renderer through `ThrowableStreamFactoryInterface` or +`ThrowableResponseFactoryInterface`. FastForward can discard a concrete +class definition's PSR-11 construction failure and fall back to autowiring that +class. The same failure on an explicitly bound interface prevents construction, +because the interface has no autowire fallback. Successful concrete overrides +work, but they do not provide the same failure contract. The integration suite +checks both successful concrete overrides and required-interface failures. + +Names in the table are relative to `CoiSA\ErrorHandler`; reporter classes are +under `Handler`, HTTP adapters under `Http\Message`, and middleware under +`Http\Middleware`. + +Bind `ThrowableHandlerInterface` in the application provider for an explicit +reporter. Alternatively provide `CallableThrowableHandler` or +`Psr\EventDispatcher\EventDispatcherInterface` to populate the aggregate. The +PSR-14 path dispatches both the original throwable and an `ErrorEvent` wrapper; +subscribe intentionally to avoid duplicate application reporting. An aggregate +with no configured reporters is silent. No logger is silently installed. + +The compatible 2.x LogThrowableHandler and FilteringThrowableHandler remain +explicit application composition. Construct the chosen reporter and bind it under +ThrowableHandlerInterface using the merged-provider pattern below. Neither adapter +is added to the optional discovery list. A logger binding alone does not opt into +direct logging, and a filter does not change the separate public HTTP renderer. + +For an explicit PHP-error policy, bind an interface instance or callable under +PhpErrorHandlerInterface. ErrorHandlerFactory passes the configured value into +process and fresh middleware handlers, preserving four arguments, ignored callback +results and the callback's own mask/conversion policy. The same choice is available +under ThrowableHandlerInterface and ShutdownHandlerInterface. Bind callbacks as +values with `ServiceFactory($callback)`; a raw callable in a factory map describes +service construction instead. Required overrides use the aggregate below. + +The explicit ErrorHandlerFactory remains responsible for optional defaults and +fresh scopes; this does not require a container to autowire arbitrary union types. +Interface instances take precedence over their callable capability. No callback +is executed or registered during construction. See [adapter contracts](api.md#callable-php-error-policy) +and the [logging](https://github.com/coisa/error-handler/blob/master/examples/logging.php), [filtering](https://github.com/coisa/error-handler/blob/master/examples/filtering.php) +and [PHP-error](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php) examples. + +Some autowiring containers advertise `CallableThrowableHandler` through `has()` +even when its required callable cannot be constructed. Optional discovery skips +a PSR-11 not-found result or FastForward's structural `AutowiringException` only +for that callable lookup. This lets an application dispatcher receive both event +routes without a dummy callable. Other callable-construction failures and all +dispatch-adapter failures propagate from the aggregate factory. + +FastForward 1.10.0 wraps an executing provider factory's failure in +`ContainerException`, retaining its cause; this is not skipped by the optional +lookup. The final container can nevertheless recover from failed concrete +aggregate construction by autowiring an empty `ThrowableHandlerAggregate` and +caching it. Merging maps does not disable that native fallback. Use an explicit +`ThrowableHandlerInterface` binding for a required reporter and the merged-provider +composition below; verify delivery and failures in the actual application graph. + +## Required overrides + +The short bootstrap above adds optional reporters and external dependencies. +FastForward 1.10 searches separate providers in the supplied order; +they do not merge their factory maps. The normal +`container($application, ErrorHandlerServiceProvider::class)` bootstrap gives +application definitions priority. If an application factory throws a PSR-11 +construction failure, resolution can still fall through to the silent library +aggregate. This distinction matters only when failure must prevent construction. + +When replacing an existing service ID, merge the definitions explicitly, with +the application last: + +```php +use FastForward\Container\ServiceProvider\AggregateServiceProvider; + +$services = container( + new AggregateServiceProvider( + new ErrorHandlerServiceProvider(), + $application + ) +); +``` + +Here `$application` binds the required reporter or renderer interface. +`AggregateServiceProvider` uses its last definition for each service ID. This +replaces the library default and makes required-reporter failures fail +construction of both process handler and middleware. FastForward normalizes +construction failures into PSR-11 exceptions; its aggregate retains the first +construction failure when no source succeeds. The supported contract is failed +construction after reaching the required factory, without silent fallback to the +library's default reporter. Do not depend on a particular wrapper type or message; +inspect the available cause chain when diagnosing a failed resolution. +Container resolution may attempt a failing construction more than once while +trying its delegates and autowiring. Application factories must tolerate those +attempts; this differs from the handler's no-retry contract after reporting fails. + +Optional `PhpErrorHandlerInterface` and `ShutdownHandlerInterface` bindings are +passed to fresh handlers too. The provider reuses the package's PSR-11 factories +and introduces no separate configuration format or automatic event subscriptions. +The Interop service provider is the only supported container integration. +`ConfigProvider`, its Laminas discovery metadata, `ErrorHandlerContainer` and the +constructor-only factories have been removed. Provider factories all live under +`ServiceProvider\Factory`; see [migration](migration.md). Provider consumers must +support constructor autowiring, as FastForward's `container()` does. + +## Runnable integration recipes + +The [usage cookbook](examples.md) exposes complete examples for every built-in adapter: +PSR-14 dispatch and PSR-3 logging, private HTTP reporting, controlled JSON rendering, +custom PHP/shutdown handlers, direct reporter composition and nested scopes. +The cookbook includes each complete program. The [documentation site](https://coisa.github.io/error-handler/#examples) +links the same sources and displays checked excerpts. + +## HTTP scopes and fresh instances + +Supply `Psr\Http\Message\ResponseFactoryInterface` and +`Psr\Http\Message\StreamFactoryInterface`, for example using an application's +Nyholm PSR-7 factory. The normal middleware service can process sequential +requests and restores an already registered process handler after each one. + +The middleware factory deliberately does not resolve the cached `ErrorHandler` +process service. Its `finally` must unregister only its own handler; sharing the +process instance would deactivate the application's bootstrap registration. +`ErrorHandlerFactory` also applies optional PHP/shutdown bindings consistently. +Autowiring selects constructor dependencies, but does not establish a fresh +lifecycle or make a cached instance independent. + +FastForward caches `get()` results. For nested scopes, resolve a factory object +and invoke it for each middleware instance: + +```php +use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; + +$makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class); +$outer = $makeMiddleware($services); +$inner = $makeMiddleware($services); +``` + +Likewise invoke the `ServiceProvider\Factory\ErrorHandlerFactory` service to create +fresh direct handlers. Avoid sharing the process handler with middleware and +do not pre-register the middleware's own handler. The reusable factory pattern +does not provide concurrent-request isolation: PHP callback stacks remain +process-wide. Reuse instances for sequential work when practical because PHP +retains registered shutdown callbacks until process exit. + +## Verification and migration readiness + +The integration tests use the real released FastForward container, including +provider-class bootstrap, explicit and optional reporters, custom PHP/shutdown +handlers, event dispatch, fresh factories, nested HTTP scopes and restoration +after renderer failure. The full [test command](testing.md) retains the existing +bounded fatal-error fixtures under PHPUnit Process tests. CI also installs without development dependencies +and resolves a process handler through the provider, proving that FastForward's +factory classes and autowiring are available in production installations. + +The full FastForward framework is not required. The Composer package is +`coisa/error-handler`, under the `CoiSA\ErrorHandler` namespace. + +Sources: [FastForward Container 1.10.0](https://github.com/php-fast-forward/container/tree/v1.10.0), +[provider contract](https://github.com/container-interop/service-provider/tree/v0.4.1), +[cached provider services](https://github.com/php-fast-forward/container/blob/v1.10.0/src/ServiceProviderContainer.php). + +If the built-in handler's reporter fails during a request warning, middleware +propagates that failure and restores callbacks without rendering it through the +reporter again. Ordinary downstream failures still use the configured response +factory. See [failure boundaries](examples.md#failure-and-lifetime-boundaries). + +## Reporting and rendering + +Middleware requires one scoped instance implementing both `ErrorHandlerInterface` +and `ThrowableHandlerInterface`. It reports downstream failures before invoking +the response factory with the original Throwable and current request. The fallback +`ThrowableStreamFactoryInterface` alias +selects `Http\Message\GenericThrowableStreamFactory`, which uses PSR-17 directly +and produces a controlled body without invoking reporters or output buffering. +A lifecycle-only custom implementation must add reporting to use the middleware. + +Reporters keep their void contract and must write HTTP diagnostics privately. +Use the request-aware responses below or implement a custom +stream/response factory; see the complete +[renderer example](examples.md#explicit-json-streams). The retained `ThrowableStreamFactory` +captures a separate public renderer only when explicitly selected. Binding the +old concrete capture class no longer changes the default alias. Bind the +interface for required custom rendering, and do not inject the diagnostic reporter +into a capture adapter after middleware has already reported it. + +## Explicit terminal reporting + +`new ErrorHandlerServiceProvider(cli: true)` appends `SymfonyCliThrowableHandler` +to the default reporter aggregate after its optional reporters. With the flag false, +terminal formatting is not selected automatically. A configured +`ThrowableHandlerInterface` override replaces the aggregate as usual. +The optional `OutputInterface` application service selects a Console destination; +without one the handler writes to stderr. The CLI flag does not enable HTTP debug +or install callbacks. See [terminal integration and Console ownership](cli.md). + +## Request-aware responses + +`ErrorHandlerServiceProvider(bool $debug = false, bool $cli = false)` is the only library provider. +Its response alias selects `NegotiatingThrowableResponseFactory`. The current +request's Accept header chooses from generic `text/plain`, `application/json` or `application/problem+json` +and Symfony `text/html`. Trusted debug enables diagnostics in the same Symfony +renderer. Negotiated responses include Accept in Vary and preserve existing Vary fields. Installed Whoops never changes this default. +Missing Accept or no supported positive match uses the generic fallback. +Debug never forces HTML; JSON-only requests do not resolve Whoops. +See [quality values and fallbacks](http.md#request-negotiation). + +The application supplies its own PSR-17 implementations and private reporter: + +```php +$services = container($application, ErrorHandlerServiceProvider::class); +``` + +For trusted development configuration, read a real boolean before bootstrap: + +```php +$settings = config(['error_handler' => ['debug' => true]]); + +$services = container( + $application, + new ErrorHandlerServiceProvider(debug: $settings->get('error_handler.debug', false)), +); +``` + +The Config key belongs to this application example; the library reads no +environment variable or configuration key automatically. Avoid casting the +string `"false"` to bool, which enables PHP truthiness. The +[JSON provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php) +shows public/debug modes and `Accept: application/json` with one private report. +The provider also exposes the concrete JSON renderer for explicit application +composition; selecting a concrete renderer directly bypasses negotiation. JSON uses +`SerializerInterface`, supplied by the provider as a Symfony Serializer with +ProblemNormalizer and JsonEncoder. Both accepted JSON media types use Problem +Details; direct JSON rendering returns `application/problem+json`. Override the +serializer service explicitly only with a collaborator supporting that same contract. + +The negotiator is cached, while the request is supplied for each rendering call. +Its lazy renderer map resolves only the selected response service. Use the +middleware factory for fresh scopes; repeated container lookups remain cached. +Reporter and renderer failures retain the [HTTP boundaries](http.md). + +## Development HTML configuration + +Symfony HTML is the default in public and debug modes. To select Whoops instead, +install optional `filp/whoops:^2.18.7` in the application. The [public/default/configured recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php) +uses the same provider with `Accept: text/html` and an explicit application +override of the negotiating HTML candidate in its development modes. Public mode retains Symfony HTML. The default provider never +auto-selects Whoops based on package availability. + +The provider binds `PrettyPageHandler` through FastForward's `InvokableFactory`. +The HTML renderer's lazy callback resolves that service only during selected HTML +rendering. Discovery, JSON-only requests and middleware construction do not create +a page or register native callbacks. The concrete Whoops renderer remains usable +directly, where the application owns development authorization. + +Customize a page before container lookup, apply trusted Config values and bind it +with `ServiceFactory` in the application map. Application-first composition gives +that page priority. The container caches it; sequential renders replace diagnostic +data while retaining configuration. Every render owns a fresh unregistered Whoops +run, response and stream. This is not concurrent-request isolation. + +FastForward can recover a failing concrete page factory by autowiring a default +page. A merged provider fixes map precedence but does not disable that concrete +fallback. Prepare required page configuration before lookup when configuration +failures must be observed immediately. Wrong-type pages and rendering failures +propagate through their actual runtime boundaries; there is no second resolver, +page cloning or warning suppression. + +The manual renderer's callable remains available for explicit composition. +The provider never selects `ReportingThrowableResponseFactory`: middleware already +reported the original. Debug output can expose source, globals and traces; selected +key hiding is not complete redaction. See [diagnostic HTML](http.md#opt-in-development-html-with-whoops). + +## Troubleshooting composition + +| Symptom | Check | +| --- | --- | +| A required reporter is silent | Bind `ThrowableHandlerInterface` and merge library then application maps in one aggregate. A separate application provider can be shadowed or skipped after a construction failure. | +| A request deactivates the process handler | Give middleware a distinct handler. Resolve and invoke its factory for each independent scope; do not pre-register that handler. | +| A page override appears to be ignored | Configure the page before lookup and bind the instance with `ServiceFactory`. A failed concrete factory can fall back to an autowired default page. | +| Whoops classes are unavailable | Install optional `filp/whoops:^2.18.7` in the consuming application and confirm that the selected autoloader includes it. | + +For reporter echo, duplicate reports and diagnostic exposure, follow +[HTTP boundaries](http.md). Container failure normalization does +not make an invalid required binding optional. diff --git a/docs/http.md b/docs/http.md index 36a8495..0d15e61 100644 --- a/docs/http.md +++ b/docs/http.md @@ -1,186 +1,180 @@ -# HTTP composition in 2.x - -The library implements PSR-15 middleware and consumes application-owned PSR-17 factories. It returns a PSR-7 response; the application/server owns request creation, routing and response emission. Composer's PSR interfaces are not concrete HTTP factories or an emitter. - -Run the [HTTP example](https://github.com/coisa/error-handler/blob/master/examples/http.php) through the [bounded verifier](https://github.com/coisa/error-handler/blob/master/examples/verify.md). It supplies Nyholm's Psr17Factory, a synthetic downstream RequestHandlerInterface and application callbacks. No real server is needed. - -## Prepare the application boundary - -Read [the setup guide](../examples/README.md#application-dependencies) for a compatible application dependency graph. [PSR-15](https://www.php-fig.org/psr/psr-15/) defines middleware/request-handler contracts; [PSR-17](https://www.php-fig.org/psr/psr-17/) defines HTTP message factories. Their interface packages do not create a concrete request, response body or network server for the application. - -For the two legacy compositions demonstrated here, identify: - -- A private reporter implementing `ThrowableHandlerInterface` and a distinct lifecycle handler for this scope. -- Application PSR-17 `ResponseFactoryInterface` and `StreamFactoryInterface` implementations; the recipe uses one Nyholm object for both. -- A downstream PSR-15 `RequestHandlerInterface` and the application's existing response emitter/server integration. -- The public status/body/reason policy and the action to take if reporting or response construction fails. - -A custom `ThrowableResponseFactoryInterface` implementation may create its PSR-7 `ResponseInterface` differently. PSR-17 factories are dependencies of the demonstrated implementations, rather than a requirement of that library interface. - -| Intended response policy | Composition to choose | Reporting owner | -| --- | --- | --- | -| Fixed public error response with private diagnostics | Application `ThrowableResponseFactoryInterface`, as in the complete recipe's `safe` mode | The application response factory reports once, then creates the fixed response. | -| Deliberate use of existing captured reporter output | Built-in `ThrowableResponseFactory` with `ThrowableStreamFactory` | The stream factory invokes its reporter while capturing output; empty/false-like output exposes diagnostic fallback. | - -Select this policy explicitly before using the [default container aliases](container.md#factory-map-and-scopes). A private reporter alone does not make the captured-output composition suitable for public responses. - -## Current middleware contract - -`new ErrorHandlerMiddleware($errors, $responses)` takes `ErrorHandlerInterface` and `ThrowableResponseFactoryInterface`. `ErrorHandlerInterface` is a lifecycle contract only; no intersection with `ThrowableHandlerInterface` is required by the 2.x constructor. - -Processing registers the handler, invokes the downstream request handler, catches Throwable and asks the response factory for a response, then unregisters in `finally`. A normal response passes through. Response-factory failure propagates after unregistering restores surrounding callbacks. - -The middleware does not independently call `handleThrowable()` in that catch. With the built-in response/stream factories, reporting happens during stream rendering. Adding a separate diagnostic call before that renderer can produce duplicate delivery. Custom response factories own their reporting policy. - -Use a distinct core handler for each nested scope and retain the process handler separately. The default container can cache/reuse a handler for both; factory discovery does not provide independent request lifetimes. Native callbacks remain process-wide, including under fibers or concurrent workers. - -## Existing response and output capture - -The default composition is: - -```php -$streams = new CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory($messages, $reporter); - -$responses = new CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory($streams, $messages); - -$middleware = new CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware($errors, $responses); -``` - -Here `$messages` implements both PSR-17 stream/response factory contracts. - -`ThrowableResponseFactory` passes the throwable message unchanged as the HTTP reason phrase when it contains only legal status-line bytes: horizontal tabs, spaces, visible ASCII or opaque bytes from `0x80` through `0xFF`. If it contains CR, LF, NUL, DEL or another forbidden control byte, the factory omits the reason argument so the PSR-17 implementation can select its default instead. An empty message is still passed explicitly, preserving the existing implementation-specific behavior (Nyholm retains an empty reason). Verify that the selected checkout/release contains the [reason-phrase repair](https://github.com/coisa/error-handler/issues/89); this is not guaranteed by every 2.x installation. - -The factory uses integer codes from 400 through 500 inclusive as status, and 500 otherwise. String codes, including PDO SQLSTATE and numeric-looking strings, fall back to 500. This existing policy is not a general application error-to-HTTP mapping; choose application-owned mapping for domain/database errors. Reason validation changes neither the original throwable sent to the stream/reporting path nor its body policy. It prevents an invalid status line; it does not redact valid diagnostic text from the response. - -`ThrowableStreamFactory` starts an output buffer, invokes its configured reporter, then uses the captured text. If that text is empty or false-like (including the string `"0"`), it falls back to the throwable's string representation, including diagnostic details and stack information. A private logger that prints nothing therefore does not produce a safe generic body. - -If the reporter closes capture and returns normally, the factory uses that same diagnostic fallback. It does not read or close a caller-owned buffer at or below the entry depth, or invoke that buffer's output callback. With no caller buffer, the missing capture also reaches fallback without calling `ob_get_clean()`. This ownership boundary cannot undo a reporter's own writes, flushes or buffer changes; reporters must still balance their buffers. - -The executable modes verify these actual paths: - -| Mode | Observed result | +# HTTP reporting and response boundaries + +Middleware requires one scoped object implementing `ErrorHandlerInterface` and +`ThrowableHandlerInterface`. It registers, calls downstream, reports a caught +Throwable once, passes that same Throwable and the current ServerRequestInterface +to a separate response renderer, and unregisters in `finally`. +A reporter failure propagates without rendering. Successful downstream responses +retain their exact identity; PHP warnings do not replace them automatically. +Applications supply PSR-17 factories and own HTTP emission and format selection. + +All response factories implement +`createResponseFromThrowable(Throwable $throwable, ServerRequestInterface $request): ResponseInterface`. +The request parameter is required, including for renderers that ignore it. +Direct application calls must supply the current request too. + +JSON and Whoops adapters were introduced in 2.5.0. Middleware owns reporting; +pass the pure renderer directly. Do not wrap it in +`ReportingThrowableResponseFactory` after this middleware has already reported. +See [migration](migration.md#separate-http-reporting-from-rendering). + +## Request negotiation + +`ErrorHandlerServiceProvider(bool $debug = false, bool $cli = false)` supplies one default +`NegotiatingThrowableResponseFactory` for all requests. A cached middleware can +therefore serve an HTML browser request and a JSON API request using each current +request's `Accept` header; no request is cached in a service. + +| Available representation | Selection | | --- | --- | -| `capture` | Synthetic public output becomes the body; the example code/message become status/reason. | -| `empty` | No reporter output: body falls back to exception text. | -| `zero` | Reporter output `"0"` also falls back to exception text. | -| `success` | Normal downstream response, no throwable report. | -| `renderer-failure` | Application renderer throws; callbacks are restored. | -| `reporter-failure` | Built-in capture reporter throws; its removable capture buffer is discarded and callbacks are restored. | - -On reporter or capture failure, the stream factory attempts to discard removable buffers above its entry level. If cleanup returns, it rethrows the same primary failure. It does not attempt to close buffers at or below the entry level. A throwing output callback does not stop cleanup when PHP has already removed its buffer; cleanup then continues through the remaining reachable removable scopes. It stops at a non-removable buffer, a failed close or no decrease in buffer level. Buffers can remain in those cases. Callback side effects cannot be undone: an output handler that calls `exit()`/`die()` or triggers an engine fatal can terminate execution before the caller receives the primary failure. This best-effort cleanup cannot restore control in those cases. Reporters must still balance their own buffers, including on success: successful unbalanced nested capture retains its existing 2.x behavior. - -## A safe application response - -An explicitly configured `BestEffortThrowableHandlerAggregate` attempts all its reporter slots, then throws if any failed. The existing capture factory propagates that aggregate failure through the cleanup boundary above; it does not produce a successful fallback response. Its fixed summary does not make its original cause, failures or string representation public-safe. See [the terminal policy](api.md#best-effort-reporting) before composing it into an HTTP reporter. - -The `safe` mode uses an application implementation of `ThrowableResponseFactoryInterface`. It calls a private, non-output diagnostic reporter once, then creates a fixed 500 response with `Internal Server Error` through the supplied PSR-17 implementation. It does not copy exception messages, stack traces or database codes into the public body or reason phrase. - -Read the [complete HTTP recipe](examples.md#http-responses-output-capture-and-a-safe-application-adapter) for its imports, synthetic request handler, application response adapter and assertions. Through the bounded verifier, the safe case checks one private report, the fixed 500 status/body and restored callbacks. A reporter failure propagates before the fixed response is created; PSR-17 failures propagate too. The application still needs a terminal failure policy for its chosen server/runtime. - -This is an explicit application adapter. The default renderer remains the capture/fallback adapter described above. The opt-in JSON factory below was published in 2.5.0; select `coisa/error-handler:^2.5` and verify the installed source. Neither JSON nor content negotiation becomes a default. - -An explicit `FilteringThrowableHandler` may select the private diagnostic branch in this application adapter. Both match and nonmatch still lead to its fixed public response; filtering does not select status, body or reason. Predicate/delegate failure propagates after middleware restores callbacks. Do not use a silent nonmatch as redaction inside the built-in capture adapter: no output still falls back to the original diagnostic text. Filtering starts after PHP-error conversion and preserves the existing reporting mask/suppression rules. +| Generic body | `text/plain`, or fallback when Accept is absent or no supported positive match exists | +| JSON Problem Details | `application/json` or `application/problem+json`, with generic details unless trusted debug is enabled | +| Public HTML | `text/html`, without exception details | +| Diagnostic HTML | The same Symfony renderer with trusted debug; optional Whoops requires an explicit override | + +Accepted quality values determine selection. Zero-quality representations are +excluded. Equal wildcard choices prefer generic, then JSON, then HTML. Debug +enables diagnostic HTML/JSON; it never overrides the client's JSON preference. +A JSON-only request never resolves Whoops. Whoops is never selected by default, +whether installed or absent. Applications can explicitly replace the HTML +candidate with Whoops while preserving negotiation. + +The HTML candidate is `text/html; charset=UTF-8`, matching the emitted response. +Bare `text/html` accepts it; a charset-specific preference or zero-quality +exclusion applies to that actual representation. Custom maps must likewise +describe their renderers' media parameters. + +The fallback is an error response for the original failure, not a new 406. +Negotiated responses add Accept to Vary while preserving existing fields. +Reporting still occurs once before negotiation. Renderers receive the exact +current request and original Throwable; selecting a representation reports nothing. + +For explicit composition, construct +`NegotiatingThrowableResponseFactory($fallback, $renderers)`. The map keys are +media types and values are lazy `callable(): ThrowableResponseFactoryInterface` +factories. The selected factory alone is invoked. Configure known response +services from trusted application code; client input selects only among that map. +Concrete Symfony HTML, JSON, Whoops and generic factories can still be used directly. + +## Generic default and explicit output capture + +The single provider selects `NegotiatingThrowableResponseFactory`; see +[request negotiation](#request-negotiation). Its fallback uses +`GenericThrowableStreamFactory`, which always returns `Internal Server Error` +without inspecting reporter output. Diagnostic reporters +must use private sinks: echoed diagnostics can still escape the response object +and corrupt application emission. `ThrowableResponseFactory` preserves integer +codes 400–599 and otherwise uses 500, including SQLSTATE strings. It leaves reason +phrases to PSR-17 instead of copying exception messages. + +Implement a pure stream/response factory for an application-specific representation; +the [custom JSON-stream recipe](https://github.com/coisa/error-handler/blob/master/examples/http-rendering.php) returns a stream +directly. `ThrowableStreamFactory` remains an explicit adapter for a separate +public echo-based renderer. Its output becomes the response stream: `0` is +preserved, and empty output uses the generic body. A private reporter is not that +public renderer and must not be invoked again during rendering. + +If rendering returns with no buffer above the caller's entry depth, capture uses +the generic body instead of reading the caller's existing output. The factory +does not read or close a caller-owned buffer or invoke its output callback. +Missing capture at native depth zero also returns the generic body without a +missing-buffer warning. This cannot undo the renderer's own writes, flushes or +buffer changes. +Capture cleans reachable removable renderer-owned buffers after success and +failure. It stops at protected buffers or no progress and retains the first +cleanup failure; the original renderer failure takes precedence. Renderers must +not close caller-owned buffers. Callback exit/die/fatal effects remain native +limitations. See the [capture recipe](https://github.com/coisa/error-handler/blob/master/examples/output-capture.php). ## Opt-in JSON responses and explicit reporting -JSON, its reporting decorator and the optional Interop profiles are available from published 2.5.0. For an authorized application installation selecting these routes, use: - -```sh -composer require 'coisa/error-handler:^2.5' --no-plugins --no-scripts -``` - -The common reporting and legacy integration baseline remains `^2.4`. - -`Http\Message\JsonThrowableResponseFactory` accepts a PSR-17 response factory and stream factory. It returns a fresh response/body with `Content-Type: application/json` and the exact public body `{"error":"Request failed"}`. Integer throwable codes from 400 through 599 are preserved; strings, including numeric-looking codes and SQLSTATE, and all other integers use 500. The PSR-17 implementation supplies its default reason phrase; the throwable message is never passed as that reason. - -This renderer returns data directly. It does not invoke a diagnostic reporter, print, capture output, register callbacks, emit the response or select a representation from request globals. Its status range belongs to this new opt-in class; the legacy factory's 400–500 mapping, throwable reason phrase and output-capture defaults remain unchanged. - -The 2.x middleware does not report a caught downstream Throwable before rendering. Compose `Http\Message\ReportingThrowableResponseFactory` with a private, non-output reporter and the pure JSON renderer: - -```php -$errors = new CoiSA\ErrorHandler\ErrorHandler($reporter); - -$json = new CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory($messages, $messages); - -$responses = new CoiSA\ErrorHandler\Http\Message\ReportingThrowableResponseFactory($reporter, $json); - -$middleware = new CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware($errors, $responses); -``` - -The decorator calls its reporter once with the original Throwable, then calls its renderer once with the same object and returns that exact response. A reporter failure prevents rendering; reporter and renderer failures propagate unchanged, with middleware unregistering in `finally`. The decorator does not silence a reporter that prints. Use a private sink; diagnostic output remains that collaborator's responsibility. - -### Explicit development details - -The optional third JSON constructor argument is `bool $debug = false`. Enable it only from trusted application configuration, for example `new JsonThrowableResponseFactory($messages, $messages, true)` in a development-only composition. The library does not inspect environment names, request headers, remote addresses or globals to enable diagnostics. Omitting the argument keeps the generic public body. - -Debug adds `exception` containing `class`, `message`, `file`, `line` and `trace`, and sets `Cache-Control: no-store`. Trace frames retain only the native `file`, `line`, `class`, `type` and `function` keys that are present. Arguments, objects, previous-exception chains and arbitrary properties are excluded; the Throwable is not serialized. Debug intentionally exposes the chosen message and paths, which can contain sensitive information. It is not a complete redaction policy. - -Debug encoding replaces invalid UTF-8 bytes in messages, paths and trace metadata with U+FFFD. It does not retry or report again. PHP 7.4 debug use needs the optional JSON extension and an available `json_encode`; the constructor rejects a missing encoder when debug is selected, before a Throwable is being handled. PHP 8 includes JSON. Generic rendering needs no encoder, and the core package does not acquire an extension requirement. - -Using JSON directly in the 2.x middleware omits explicit reporting of that caught Throwable unless the application supplies another reporting composition. Wrapping the legacy capture renderer would instead add a second report because capture already invokes its reporter. Use the decorator with a pure renderer. In the proposed major's report-before-render middleware, inject the pure JSON factory directly and omit this decorator to avoid duplicate delivery; that major is not the published 2.x contract. - -The [complete JSON example](https://github.com/coisa/error-handler/blob/master/examples/http-json.php) preserves a synthetic 503 and records the original privately once in both `public` and explicitly selected `debug` modes. It uses application-provided PSR-17 factories without application-side buffers or an emitter. The CLI mode represents trusted application configuration, not request input. Run both through the existing bounded verifier. - -### JSON through an Interop service provider - -For an application using PHP 8.3+ and FastForward Container `^1.9` as a runtime dependency, register `ServiceProvider\JsonErrorHandlerServiceProvider` before the separate base provider, followed by the application's PSR-17 and private reporter definitions: - -```php -$services = FastForward\Container\container( - CoiSA\ErrorHandler\ServiceProvider\JsonErrorHandlerServiceProvider::class, - CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider::class, - $application, -); - -$middlewareFactory = $services->get( - CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory::class, -); - -$middleware = $middlewareFactory($services); -``` - -Each provider declares only its own services. The JSON profile selects the reporting decorator for this explicit 2.x composition; the base supplies lifecycle and fresh-scope factories. JSON must come first in this separate-provider call so its response alias wins over legacy capture. The pure concrete remains independently available. Invoking the middleware factory creates a new lifecycle handler rather than reusing the cached process handler. Discovery is inert, public JSON is the default, and a real bool constructor argument enables debug only by trusted application choice. The core and legacy container still support PHP 7.4. - -Read the [provider dependencies, Config values and override precedence](container.md#optional-interop-and-fastforward-providers) and the [complete executable provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). Run the optional provider scenarios with `php examples/verify.php --with-fast-forward` on PHP 8.3+; the existing verifier keeps their callback installation in bounded children. Applications continue to own emission and private reporter output. +`JsonThrowableResponseFactory($responses, $streams, $serializer, bool $debug = false)` +is a pure PSR-17 renderer using Symfony's `SerializerInterface`. The provider supplies +`Serializer` with `ProblemNormalizer(true)` and `JsonEncoder`; direct composition +supplies the same collaborators explicitly. Its public Problem Details body contains +`type: about:blank`, the PSR-17 reason as `title`, the HTTP `status` and +`detail: Request failed`. A direct call returns `application/problem+json`. +Negotiation also supports `application/json` and retains the requested supported +media type; both have the same Problem Details shape. Integer exception codes 400–599 are +preserved; other integers and string codes use 500. Reason phrases come from +PSR-17. No report, output capture or emission occurs inside this factory. + +The [complete JSON recipe](https://github.com/coisa/error-handler/blob/master/examples/http-json.php) constructs an `ErrorHandler` +with a private reporter, then passes the JSON factory directly to middleware. +Middleware delivers the identical original once before rendering. A reporter +failure prevents rendering; renderer and PSR-17 failures propagate after callback +restoration without a second report. + +The fourth parameter is an explicit application-controlled boolean. When true, +`detail` contains the exception message and JSON adds `class` and a Symfony trace. +Frame argument values and objects are excluded; an empty `args` array is harmless. +It sets `Cache-Control: no-store` and substitutes invalid UTF-8. Serialization +failures propagate; JSON rendering never falls back to HTML. Messages, paths and other metadata can still contain secrets. No environment +name or request header enables diagnostics. Use trusted configuration and strict +typing; casting the string `"false"` yields true in PHP. + +`ReportingThrowableResponseFactory($reporter, $renderer)` remains a public, +explicit utility for pipelines that have not already reported. It reports once +and then delegates. Reporter failures prevent rendering; collaborator failures +propagate unchanged. Selecting it after this middleware would report +twice. The provider never selects it automatically. + +## Public HTML + +`HtmlThrowableResponseFactory($responses, $streams, bool $debug = false)` uses +Symfony's `HtmlErrorRenderer` to create a production error page. With debug false, +the page omits the exception message, source and trace. It returns `text/html; charset=UTF-8`, +uses the same 400–599 status policy and does not report or emit. +The default provider always offers Symfony HTML. Trusted debug enables Symfony's +diagnostic page with a document shell and the renderer's stylesheet; +public mode omits diagnostic details. Whoops presence does not +change either default. +The [complete public/debug HTML recipe](https://github.com/coisa/error-handler/blob/master/examples/http-html.php) +shows explicit reporting followed by a direct request-aware render. The +[manual middleware recipe](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php) +shows public HTML or explicitly selected diagnostic Whoops. ## Opt-in development HTML with Whoops -`Http\Message\WhoopsThrowableResponseFactory` was published in 2.5.0. Applications selecting this development HTML route need `coisa/error-handler:^2.5`. Install optional `filp/whoops:^2.18.7` in that application's development dependencies. The library suggests Whoops and uses it for development tests; core/container installations do not require it. Loading the adapter class without Whoops works; constructing it without the dependency raises `LogicException` with installation guidance. - -The minimum Whoops 2.18.7 includes the upstream PHP 8.5 deprecation fix and native virtual-source inspection repair. Whoops preserves frames created through STDIN (`Standard input code`) and `php -r` (`Command line code`) without trying to read those names as files. Composer rejects older Whoops versions through an optional conflict constraint; it does not install Whoops unless the application requests it. This requirement neither adds Whoops to core runtime dependencies nor suppresses legitimate application warnings. - -The constructor takes `ResponseFactoryInterface`, `StreamFactoryInterface` and a required `callable(): Whoops\Handler\PrettyPageHandler`. The callable supplies the configured page for each render and is not invoked during construction. It may create a new page or return a configured page reused for sequential rendering. Whoops replaces the page's current exception, inspector and generated template data; configuration stays in place. A reused page retains its latest diagnostic until the next render. This is not a concurrency or arbitrary-subclass guarantee. The adapter creates its own fresh Run, sets `handleUnconditionally(true)`, `allowQuit(false)`, `writeToOutput(false)` and `sendHttpCode(false)`, and never calls `register()`. - -HTML comes from Whoops' public string-returning path. Its internal buffering belongs to Whoops; CoiSA adds no reporter-output capture. The adapter returns a new PSR body with `text/html; charset=UTF-8` and `Cache-Control: no-store`. It preserves integer codes 400–599, otherwise uses 500, and omits the reason argument so PSR-17 supplies its default. The original diagnostic code shown by Whoops is not a separate HTTP status promise; the Throwable is neither wrapped nor changed. - -Reuse `ReportingThrowableResponseFactory($reporter, $html)` in the 2.x middleware. The private reporter runs once before rendering; its failure prevents page lookup. Failures escaping the manual page callback, Whoops or HTTP collaborators propagate without adapter retry, fallback or another report. Returning an incompatible page produces `TypeError`. These controls do not suppress arbitrary output, callbacks or process termination deliberately performed by application callbacks/subclasses. - -The renderer leaves the original Throwable and its trace unchanged. The 2.x reporting decorator still reports the original once before rendering, and enabled application PHP warnings still reach their configured reporter. No warning suppression, fabricated filename or trace rewriting is supplied. The [native source regression](testing.md#optional-whoops-html-adapter) checks STDIN, `php -r` and real files separately. - -The [complete HTML recipe](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php) starts with generic JSON. Its trusted `development` mode selects Whoops in an explicitly chosen HTML pipeline; `public` mode never constructs a page and can run without Whoops. The mode represents application configuration, not a request parameter. There is no additional debug flag on the HTML class: choosing this diagnostic renderer is the explicit opt-in. - -`hideSuperglobalKey()` hides selected fields, not every occurrence of a secret. Default Whoops pages expose diagnostic data and ambient superglobals; messages, traces, source excerpts, causes and other tables may remain sensitive. The recipe's three hidden keys are examples, not a universal policy. HTML does not have the JSON adapter's metadata-only trace guarantee. Keep development access and broader data policy with the application; do not use this as a redacted production page. - -This factory receives only Throwable. It does not negotiate Accept, infer a browser, inspect request globals to enable debug or retain a current request. Existing application format selection may choose it after an independent trusted debug decision. No `Vary: Accept` is added; an application that negotiates must set its own Vary/cache policy. Reusable negotiation remains separate [issue 8](https://github.com/coisa/error-handler/issues/8). The proposed major receives the pure renderer directly after reporting, without the 2.x decorator; [issue 95](https://github.com/coisa/error-handler/issues/95) tracks that adaptation. - -### Whoops through an Interop service provider - -With application PHP 8.3+, `fast-forward/container:^1.9` installed at runtime and optional `filp/whoops:^2.18.7`, choose Whoops after the trusted development/HTML decision. Compose the independent providers in one `AggregateServiceProvider`, ordered `ErrorHandlerServiceProvider`, `WhoopsErrorHandlerServiceProvider`, application. Its last definition wins, retaining HTML and application page overrides. Supply the same application PSR-17 factories and private reporter used by the JSON profile. The Whoops map selects the 2.x reporting decorator; the base supplies `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` for a fresh HTTP scope. - -The profile has no constructor arguments. It defines `PrettyPageHandler::class` through FastForward's `InvokableFactory` and resolves that cached page lazily when rendering starts. Neither provider discovery, renderer resolution nor middleware scope construction creates the default page. The [complete provider recipe](../examples/provider-whoops.php) also shows application Config preparing a customized page before composition, bound through `ServiceFactory`. Sequential renders reuse page configuration while replacing the current diagnostic, with a new Run, response and stream each time. There is no request negotiation or automatic development detection. Selected key hiding remains a limited diagnostic policy, not comprehensive redaction. - -Use the documented aggregate for Whoops. Separate providers can fall back to reporting around legacy capture when Whoops is absent, reporting the same original twice instead of preserving the missing-dependency hint. The aggregate preserves that hint in the verified no-Whoops graph, but does not disable FastForward's autowiring fallback after a concrete page factory throws. The renderer propagates errors that actually escape page lookup; it does not enforce container resolution policy. See the [provider configuration and failure boundaries](container.md#development-html-profile). - -Keep emission after response creation in the application's existing PSR-7 emitter/server integration. This library does not call header(), echo a response to a client or select an emitter package. Output captured by the diagnostic adapter is an input to a response body, not HTTP emission. - -## Troubleshoot HTTP composition - -| Observation | Check next | -| --- | --- | -| A stack trace or exception message appears in the body despite private logging | The built-in capture adapter falls back to diagnostic text for empty output or `"0"`. Select the explicit application response adapter and assert the exact public body. | -| One downstream failure reaches a reporter twice | Inspect raw/wrapped event routes, manual reporting and the response factory's policy. The 2.x middleware delegates to the response factory; adding another diagnostic call before the built-in capture renderer duplicates reporting. | -| A PDO or domain error produces an unexpected status/reason | The legacy mapping uses integer 400–500 only and can preserve a valid diagnostic message as reason. Provide application response mapping and test status, reason and body together. | -| No response reaches the client after `process()` returns | Pass the returned PSR-7 response to the application's emitter. Creating a response object does not send headers or a body. | -| Response creation throws instead of returning a fallback | Reporter and renderer failures propagate after callback restoration. Inspect the original private failure and apply the application's terminal policy. Best-effort reporting also throws when any reporter slot fails. | - -See [core callback behavior](api.md) and [container dependencies](container.md) before integrating the middleware into a process that already has handlers. +Install `filp/whoops:^2.18.7` in the application wherever the optional Whoops +renderer is explicitly selected. This minimum includes upstream PHP 8.5 deprecation and native STDIN / +`php -r` source-inspection fixes. Composer rejects older Whoops versions when +selected without making that optional package mandatory. +`WhoopsThrowableResponseFactory($responses, $streams, $pageFactory)` +takes a callable returning `Whoops\Handler\PrettyPageHandler`, invoked lazily for +each render. Its constructor signature is unchanged from 2.5.0. Pass this pure renderer +directly to middleware; see [manual selection](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). + +Each render owns a fresh, unregistered `Whoops\Run`, response and body. Whoops +returns HTML without writing output, sending headers or terminating the process. +The response uses `text/html; charset=UTF-8`, `no-store`, the same 400–599 status +policy and PSR-17 reason phrases. The application may return a configured page +service for sequential renders: current diagnostic data is replaced while page +configuration remains. That page retains its latest diagnostic and does not +provide concurrent-request isolation. + +The [Whoops provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php) +uses `container($application, new ErrorHandlerServiceProvider(debug: true))`. +The same provider supplies JSON, generic responses and Symfony HTML; the +recipe explicitly overrides its negotiating HTML candidate with optional Whoops. +Override the `PrettyPageHandler` service through an application `ServiceFactory`. +The page is lazy and cached; middleware scopes and runs are fresh. See +[provider configuration and failure limits](fast-forward.md#development-html-configuration). + +Choose diagnostic HTML only through trusted development configuration. Default +Whoops pages expose globals, source and traces; selected key hiding is not complete +redaction. The concrete Whoops renderer does not negotiate; the +application can explicitly select it as the negotiating factory's HTML candidate. +The default provider uses Symfony HTML in both public and debug modes. +No request value activates debug. + +## Failure and lifetime boundaries + +Best-effort reporting attempts all its slots and throws if any failed. Middleware +propagates that aggregate failure before invoking its renderer; it does not create +a fallback response. The aggregate's original and failures are not a redaction +policy. Applications own terminal handling and private sinks. + +Verify exact body, status, one original private report, report-before-render +ordering and callback restoration for success and failures. See [API](api.md), +[provider composition](fast-forward.md), [cookbook](examples.md) and [tests](testing.md). diff --git a/docs/index.html b/docs/index.html index 707533a..779e9ab 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,8 +3,8 @@ - - CoiSA Error Handler — 2.x documentation + + CoiSA Error Handler documentation \n\n" + . "\n%s\n\n\n", + $title, + $renderer->getStylesheet(), + $body, + ); + } +} diff --git a/src/Http/Message/JsonThrowableResponseFactory.php b/src/Http/Message/JsonThrowableResponseFactory.php index 13956a4..a916ad5 100644 --- a/src/Http/Message/JsonThrowableResponseFactory.php +++ b/src/Http/Message/JsonThrowableResponseFactory.php @@ -18,10 +18,13 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\Serializer\SerializerInterface; /** - * Builds JSON responses; applications own diagnostic reporting and response emission. + * Builds JSON Problem Details; applications own reporting and response emission. * * Integer HTTP error codes 400–599 are preserved; other codes use 500. * Debug details require an explicit application choice; generic output is the default. @@ -35,29 +38,29 @@ final class JsonThrowableResponseFactory implements ThrowableResponseFactoryInte /** @var StreamFactoryInterface the application-owned body factory */ private StreamFactoryInterface $streamFactory; + /** @var SerializerInterface the application serializer configured with a problem normalizer and JSON encoder */ + private SerializerInterface $serializer; + /** @var bool whether trusted application configuration explicitly enables diagnostics */ private bool $debug; /** - * Configures pure JSON rendering without changing the library's default renderer. + * Configures pure Problem Details rendering with an explicit serializer. * * @param ResponseFactoryInterface $responseFactory the factory providing HTTP status reason phrases * @param StreamFactoryInterface $streamFactory the factory providing a new body per response + * @param SerializerInterface $serializer the serializer using ProblemNormalizer and JsonEncoder * @param bool $debug whether to expose exception metadata for development - * - * @throws \LogicException when debug rendering is selected without an available JSON encoder */ public function __construct( ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory, - bool $debug = false + SerializerInterface $serializer, + bool $debug = false, ) { - if ($debug && !\function_exists('json_encode')) { - throw new \LogicException('Debug JSON responses require the JSON extension and json_encode().'); - } - $this->responseFactory = $responseFactory; $this->streamFactory = $streamFactory; + $this->serializer = $serializer; $this->debug = $debug; } @@ -67,19 +70,33 @@ public function __construct( * The throwable message is never used as the response's reason phrase. * This method does not report, capture output, install callbacks or emit a response. * - * @param \Throwable $throwable the original failure providing status and optional debug metadata + * @param \Throwable $throwable the original failure providing status and optional debug metadata + * @param ServerRequestInterface $request the request; diagnostics use trusted configuration only * - * @return ResponseInterface the application response with an application/json body + * @return ResponseInterface the application response with a JSON Problem Details body * - * @throws \Throwable the identical failure raised by an HTTP collaborator - * @throws \JsonException if controlled debug metadata cannot be encoded + * @throws \Throwable the identical failure raised by an HTTP or serializer collaborator */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { $code = $throwable->getCode(); $status = (\is_int($code) && $code >= 400 && $code <= 599) ? $code : 500; - $response = $this->responseFactory->createResponse($status)->withHeader('Content-Type', 'application/json'); - $stream = $this->streamFactory->createStream($this->createBody($throwable)); + $response = $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/problem+json') + ; + $stream = $this->streamFactory->createStream($this->serializer->serialize( + $this->createProblem($throwable, $status), + 'json', + [ + 'debug' => $this->debug, + 'type' => 'about:blank', + 'title' => $response->getReasonPhrase(), + 'status' => $status, + 'json_encode_options' => JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR, + ], + )); if ($this->debug) { $response = $response->withHeader('Cache-Control', 'no-store'); @@ -89,30 +106,26 @@ public function createResponseFromThrowable(\Throwable $throwable): ResponseInte } /** - * Encodes only selected native metadata, replacing invalid UTF-8 without serializing objects. + * Selects native metadata without traversing exception objects or trace arguments. * * @param \Throwable $throwable the original failure whose metadata is explicitly selected + * @param int $status the library's selected HTTP error status * - * @return string the complete JSON document without captured reporter output - * - * @throws \JsonException if controlled debug metadata cannot be encoded + * @return FlattenException a detached problem value without a previous exception chain */ - private function createBody(\Throwable $throwable): string + private function createProblem(\Throwable $throwable, int $status): FlattenException { - if (!$this->debug) { - return '{"error":"Request failed"}'; - } - - return json_encode([ - 'error' => 'Request failed', - 'exception' => [ - 'class' => \get_class($throwable), - 'message' => $throwable->getMessage(), - 'file' => $throwable->getFile(), - 'line' => $throwable->getLine(), - 'trace' => $this->createTraceMetadata($throwable), - ], - ], JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + return (new FlattenException()) + ->setStatusCode($status) + ->setStatusText('Request failed') + ->setMessage($this->debug ? $throwable->getMessage() : 'Request failed') + ->setClass($this->debug ? $throwable::class : '') + ->setTrace( + $this->debug ? $this->createTraceMetadata($throwable) : [], + $this->debug ? $throwable->getFile() : '', + $this->debug ? $throwable->getLine() : 0, + ) + ; } /** diff --git a/src/Http/Message/NegotiatingThrowableResponseFactory.php b/src/Http/Message/NegotiatingThrowableResponseFactory.php new file mode 100644 index 0000000..086abda --- /dev/null +++ b/src/Http/Message/NegotiatingThrowableResponseFactory.php @@ -0,0 +1,144 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Http\Message; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\HttpFoundation\AcceptHeader; +use Symfony\Component\HttpFoundation\AcceptHeaderItem; + +/** + * Selects an application-authorized renderer for the current request's Accept header. + * + * The application supplies the available representations in server-preference order. + * Only the winning renderer callback runs. Diagnostic authorization belongs to + * that configuration; a client header cannot enable an unavailable representation. + * This factory does not report, register callbacks, retain requests or emit output. + */ +final class NegotiatingThrowableResponseFactory implements ThrowableResponseFactoryInterface +{ + /** @var ThrowableResponseFactoryInterface the generic response for absent or unsupported preferences */ + private readonly ThrowableResponseFactoryInterface $fallback; + + /** @var array lazy representations in tie-breaking order */ + private readonly array $renderers; + + /** + * Configures trusted candidates without resolving any lazy renderer. + * + * @param ThrowableResponseFactoryInterface $fallback generic fallback + * @param array $renderers lazy types in priority order + */ + public function __construct(ThrowableResponseFactoryInterface $fallback, array $renderers) + { + $this->fallback = $fallback; + $this->renderers = $renderers; + } + + /** + * Negotiates positive client quality using Symfony's media-range matching. + * + * Specific ranges determine each candidate's effective quality, including zero + * exclusions behind wildcards. Equal positive qualities retain configured order. + * Missing or unsupported preferences select the generic fallback. Selected map + * keys define the response media type; matching renderer parameters are retained + * when the key has none. Every response includes Accept in the Vary policy. + * + * @param \Throwable $throwable the original diagnostic, passed unchanged to the selected renderer + * @param ServerRequestInterface $request the current request, never retained between invocations + * + * @return ResponseInterface the selected response without reporting or emission + * + * @throws \Throwable the identical lazy factory or renderer failure; no fallback retry is performed + */ + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { + [$renderer, $mediaType] = $this->selectRenderer($request); + $response = $renderer->createResponseFromThrowable($throwable, $request); + + if (null !== $mediaType) { + $selected = AcceptHeaderItem::fromString($mediaType); + if ( + !$response->hasHeader('Content-Type') + || [] !== $selected->getAttributes() + || 0 !== strcasecmp( + $selected->getValue(), + AcceptHeaderItem::fromString($response->getHeaderLine('Content-Type'))->getValue(), + ) + ) { + $response = $response->withHeader('Content-Type', $mediaType); + } + } elseif (!$response->hasHeader('Content-Type')) { + $response = $response->withHeader('Content-Type', 'text/plain'); + } + + return $this->varyByAccept($response); + } + + /** + * Obtains only the best positive candidate or the already configured fallback. + * + * @param ServerRequestInterface $request the request supplying representation preferences + * + * @return array{ThrowableResponseFactoryInterface, ?string} the renderer and selected map key, or null for fallback + * + * @throws \Throwable the identical selected callback failure + */ + private function selectRenderer(ServerRequestInterface $request): array + { + $accept = AcceptHeader::fromString($request->getHeaderLine('Accept')); + $quality = 0.0; + $selected = null; + $selectedMediaType = null; + + foreach ($this->renderers as $mediaType => $renderer) { + $item = $accept->get($mediaType); + if (null === $item || $item->getQuality() <= $quality) { + continue; + } + + $quality = $item->getQuality(); + $selected = $renderer; + $selectedMediaType = $mediaType; + } + + return null === $selected ? [$this->fallback, null] : [$selected(), $selectedMediaType]; + } + + /** + * Adds the selecting field without replacing existing Vary fields or duplicating Accept. + * + * @param ResponseInterface $response the selected renderer's immutable response + * + * @return ResponseInterface the response with a merged Vary policy + */ + private function varyByAccept(ResponseInterface $response): ResponseInterface + { + foreach ($response->getHeader('Vary') as $line) { + foreach (explode(',', $line) as $field) { + $field = strtolower(trim($field)); + if ('accept' === $field || '*' === $field) { + return $response; + } + } + } + + return $response->withAddedHeader('Vary', 'Accept'); + } +} diff --git a/src/Http/Message/ReportingThrowableResponseFactory.php b/src/Http/Message/ReportingThrowableResponseFactory.php index 08918b7..457b86a 100644 --- a/src/Http/Message/ReportingThrowableResponseFactory.php +++ b/src/Http/Message/ReportingThrowableResponseFactory.php @@ -18,6 +18,7 @@ use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; /** * Delivers a throwable once before delegating to an explicitly selected pure renderer. @@ -40,7 +41,7 @@ final class ReportingThrowableResponseFactory implements ThrowableResponseFactor */ public function __construct( ThrowableHandlerInterface $throwableHandler, - ThrowableResponseFactoryInterface $responseFactory + ThrowableResponseFactoryInterface $responseFactory, ) { $this->throwableHandler = $throwableHandler; $this->responseFactory = $responseFactory; @@ -51,16 +52,19 @@ public function __construct( * * A reporter failure prevents rendering. Neither failure path retries or reports again. * - * @param \Throwable $throwable the original failure passed unchanged to both collaborators + * @param \Throwable $throwable the original failure passed unchanged to both collaborators + * @param ServerRequestInterface $request the incoming request forwarded unchanged to the pure renderer * * @return ResponseInterface the delegate's unmodified response * * @throws \Throwable the identical reporter or renderer failure */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { $this->throwableHandler->handleThrowable($throwable); - return $this->responseFactory->createResponseFromThrowable($throwable); + return $this->responseFactory->createResponseFromThrowable($throwable, $request); } } diff --git a/src/Http/Message/ThrowableResponseFactory.php b/src/Http/Message/ThrowableResponseFactory.php index 4afd0bf..92e805f 100644 --- a/src/Http/Message/ThrowableResponseFactory.php +++ b/src/Http/Message/ThrowableResponseFactory.php @@ -18,6 +18,7 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; /** * Class ThrowableResponseFactory. @@ -48,7 +49,7 @@ final class ThrowableResponseFactory implements ThrowableResponseFactoryInterfac */ public function __construct( ThrowableStreamFactoryInterface $streamFactory, - ResponseFactoryInterface $responseFactory + ResponseFactoryInterface $responseFactory, ) { $this->streamFactory = $streamFactory; $this->responseFactory = $responseFactory; @@ -57,67 +58,39 @@ public function __construct( /** * Creates a PSR-7 response from a throwable instance. * - * This method SHALL retain integer codes from 400 through 500, using 500 otherwise. - * A message valid for an HTTP reason phrase SHALL be passed unchanged; forbidden - * control bytes SHALL select the PSR-17 factory's default reason instead. - * The configured stream factory SHALL receive the original throwable once. + * This method SHALL map the throwable's code to an HTTP status, use the factory's + * standard reason phrase, and include the stream factory's public response body. * - * @param \Throwable $throwable the throwable to convert into an HTTP response + * @param \Throwable $throwable the throwable to convert into an HTTP response + * @param ServerRequestInterface $request the incoming request; generic rendering is request-independent * * @return ResponseInterface the generated PSR-7 response - * - * @throws \Throwable when the response or stream factory fails */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { $code = $throwable->getCode(); - $message = $throwable->getMessage(); - $statusCode = $this->getStatusCode($code); - $response = $this->createResponse($statusCode, $message); + $response = $this->responseFactory->createResponse($statusCode); $stream = $this->streamFactory->createStreamFromThrowable($throwable); return $response->withBody($stream); } - /** - * Creates a response with a valid reason or the PSR-17 implementation's default. - * - * HTAB, spaces, visible ASCII and opaque high bytes SHALL remain unchanged. - * A valid empty message SHALL still be passed explicitly as in the existing API. - * A forbidden control byte SHALL omit the reason argument entirely. - * - * @param int $statusCode the resolved legacy HTTP status - * @param string $message the original throwable message - * - * @return ResponseInterface the response before attaching the throwable stream - * - * @throws \Throwable when the application response factory fails - * - * @see https://www.rfc-editor.org/rfc/rfc9112.html#section-4 - */ - private function createResponse(int $statusCode, string $message): ResponseInterface - { - if (1 === preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $message)) { - return $this->responseFactory->createResponse($statusCode); - } - - return $this->responseFactory->createResponse($statusCode, $message); - } - /** * Determines the HTTP status code based on the throwable code. * - * This method SHALL return an integer throwable code in the 400–500 range. - * String codes, including SQLSTATE values, and other integers SHALL use 500. + * This method SHALL return an integer throwable code in the 400–599 range. + * Otherwise, it SHALL return a default status code of 500. * - * @param int|string $code the original throwable code, without numeric coercion + * @param int|string $code the throwable code (PDO can use a SQLSTATE string) * * @return int the HTTP status code to use in the response */ - private function getStatusCode($code): int + private function getStatusCode(int|string $code): int { - return (\is_int($code) && $code >= 400 && $code <= 500) ? $code : 500; + return (\is_int($code) && $code >= 400 && $code <= 599) ? $code : 500; } } diff --git a/src/Http/Message/ThrowableResponseFactoryInterface.php b/src/Http/Message/ThrowableResponseFactoryInterface.php index df745ab..85849e0 100644 --- a/src/Http/Message/ThrowableResponseFactoryInterface.php +++ b/src/Http/Message/ThrowableResponseFactoryInterface.php @@ -17,6 +17,7 @@ namespace CoiSA\ErrorHandler\Http\Message; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; /** * Interface ThrowableResponseFactoryInterface. @@ -36,9 +37,13 @@ interface ThrowableResponseFactoryInterface * that complies with the PSR-7 standard. The response MUST include * an appropriate status code, headers, and error message. * - * @param \Throwable $throwable the Throwable instance to convert into a response + * @param \Throwable $throwable the Throwable instance to convert into a response + * @param ServerRequestInterface $request the incoming request supplying representation context * * @return ResponseInterface the generated HTTP response */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface; + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface; } diff --git a/src/Http/Message/ThrowableStreamFactory.php b/src/Http/Message/ThrowableStreamFactory.php index 7484d55..80a96c8 100644 --- a/src/Http/Message/ThrowableStreamFactory.php +++ b/src/Http/Message/ThrowableStreamFactory.php @@ -23,9 +23,9 @@ /** * Class ThrowableStreamFactory. * - * Creates PSR-7 streams from Throwable instances. - * This class SHALL ensure that throwable instances are converted into a - * readable stream for inclusion in PSR-7 responses or logging mechanisms. + * Captures deliberately public renderer output as an optional compatibility adapter. + * Middleware reports diagnostics separately before rendering. Consumers MUST NOT + * pass that diagnostic reporter here again. The provider uses a pure generic renderer by default. * * @package CoiSA\ErrorHandler\Http\Message */ @@ -49,7 +49,7 @@ final class ThrowableStreamFactory implements ThrowableStreamFactoryInterface */ public function __construct( StreamFactoryInterface $streamFactory, - ThrowableHandlerInterface $throwableHandler + ThrowableHandlerInterface $throwableHandler, ) { $this->streamFactory = $streamFactory; $this->throwableHandler = $throwableHandler; @@ -60,91 +60,80 @@ public function __construct( * * This method SHALL process the throwable using the handler and convert * its output into a PSR-7 compatible stream. - * If the handler closes capture before returning, the diagnostic fallback - * is used without reading or closing buffers at or below the entry level. - * On capture failure, removable buffers above the entry level are discarded - * best-effort. Cleanup stops on failure or lack of progress and never replaces - * the primary throwable. Output-handler side effects cannot be undone. - * An output callback that terminates the process or triggers an engine fatal - * can prevent control from returning and the primary failure from being rethrown. + * It SHALL preserve the body "0" and use a generic fallback for empty output. + * Renderers MUST balance removable buffers and MUST NOT close caller-owned buffers. + * Output SHALL be read only when a buffer remains above the caller's entry depth. * * @param \Throwable $throwable the throwable instance to process * - * @return StreamInterface the resulting PSR-7 stream containing throwable details + * @return StreamInterface the rendered response body or a generic fallback * - * @throws \Throwable the original handler, capture or stream-factory failure + * @throws \Throwable If rendering, buffer cleanup or stream construction fails. + * A renderer failure takes precedence over any later cleanup failure. */ public function createStreamFromThrowable(\Throwable $throwable): StreamInterface { - return $this->streamFactory->createStream($this->captureThrowableOutput($throwable)); - } - - /** - * Captures reporter output using the existing empty and false-like fallback. - * - * When cleanup returns, the primary reporter or capture failure is rethrown. - * Successful capture retains its existing nested-buffer behavior. - * Missing capture uses the same fallback without consuming caller buffers. - * - * @param \Throwable $throwable the throwable passed unchanged to the reporter - * - * @return string the captured output or the throwable's diagnostic string - * - * @throws \Throwable the original reporter or capture failure - */ - private function captureThrowableOutput(\Throwable $throwable): string - { - $entryLevel = ob_get_level(); + $initialLevel = ob_get_level(); + $failure = null; + $buffer = false; ob_start(); try { $this->throwableHandler->handleThrowable($throwable); + $buffer = ob_get_level() > $initialLevel ? ob_get_contents() : false; + } catch (\Throwable $rendererFailure) { + $failure = $rendererFailure; + } finally { + $cleanupFailure = $this->cleanOutputBuffers($initialLevel); + } - if (ob_get_level() <= $entryLevel) { - return (string) $throwable; - } - - return ob_get_clean() ?: (string) $throwable; - } catch (\Throwable $failure) { - $this->discardOutputBuffers($entryLevel); - + if (null !== $failure) { throw $failure; } + if (null !== $cleanupFailure) { + throw $cleanupFailure; + } + if (false === $buffer || '' === $buffer) { + $buffer = 'Internal Server Error'; + } + + return $this->streamFactory->createStream($buffer); } /** - * Discards reachable removable buffers after a primary capture failure. + * Discards reachable removable buffers created during rendering. * - * Cleanup does not attempt to close buffers at or below the entry level. - * It stops on a protected buffer, failed close or absent progress. A throwing - * callback does not stop cleanup when PHP already removed its buffer. - * Callback side effects cannot be undone; cleanup failures must not replace - * the primary failure. Process termination or an engine fatal inside an output - * callback can prevent cleanup from returning. + * Cleanup MUST stop at caller-owned or non-removable buffers and when removal + * makes no progress. Its first failure is returned so the caller can preserve + * a renderer failure in preference to a later cleanup failure. * - * @param int $entryLevel the output-buffer depth before capture started + * @param int $initialLevel the caller's output-buffer depth before rendering + * + * @return null|\Throwable the first cleanup failure, or null when no removal failed */ - private function discardOutputBuffers(int $entryLevel): void + private function cleanOutputBuffers(int $initialLevel): ?\Throwable { - while (ob_get_level() > $entryLevel) { - $level = ob_get_level(); - $status = ob_get_status(); + $failure = null; - if (0 === ($status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE)) { - return; + while (ob_get_level() > $initialLevel) { + $flags = ob_get_status()['flags'] ?? null; + if (!\is_int($flags) || ($flags & PHP_OUTPUT_HANDLER_REMOVABLE) === 0) { + break; } + $level = ob_get_level(); + try { - if (!ob_end_clean()) { - return; - } + ob_end_clean(); } catch (\Throwable $cleanupFailure) { - // PHP can remove the buffer before its callback failure propagates. + $failure ??= $cleanupFailure; } if (ob_get_level() >= $level) { - return; + break; } } + + return $failure; } } diff --git a/src/Http/Message/ThrowableStreamFactoryInterface.php b/src/Http/Message/ThrowableStreamFactoryInterface.php index 9a35c8e..6716bd8 100644 --- a/src/Http/Message/ThrowableStreamFactoryInterface.php +++ b/src/Http/Message/ThrowableStreamFactoryInterface.php @@ -21,9 +21,9 @@ /** * Interface ThrowableStreamFactoryInterface. * - * Defines the contract for creating PSR-7 streams from Throwable instances. - * Implementing classes SHALL ensure that throwable details are converted - * into a valid PSR-7 stream representation, suitable for logging or HTTP responses. + * Defines the contract for creating PSR-7 streams for Throwable instances. + * Implementations choose the representation, which may be generic and omit + * diagnostic details. Creating a stream does not imply reporting or emission. * * @package CoiSA\ErrorHandler\Http\Message */ @@ -32,12 +32,12 @@ interface ThrowableStreamFactoryInterface /** * Creates a stream from a Throwable instance. * - * This method SHALL process the provided Throwable and convert its details - * into a PSR-7 compliant StreamInterface instance. + * The representation may omit the throwable's diagnostic details according + * to the application's public-output policy. * * @param \Throwable $throwable the throwable instance to convert into a stream * - * @return StreamInterface the generated PSR-7 stream containing throwable details + * @return StreamInterface the generated PSR-7 stream representation */ public function createStreamFromThrowable(\Throwable $throwable): StreamInterface; } diff --git a/src/Http/Message/WhoopsThrowableResponseFactory.php b/src/Http/Message/WhoopsThrowableResponseFactory.php index 3f81daf..4e88e7d 100644 --- a/src/Http/Message/WhoopsThrowableResponseFactory.php +++ b/src/Http/Message/WhoopsThrowableResponseFactory.php @@ -18,6 +18,7 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Whoops\Handler\PrettyPageHandler; use Whoops\Run; @@ -51,7 +52,7 @@ final class WhoopsThrowableResponseFactory implements ThrowableResponseFactoryIn public function __construct( ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory, - callable $pageHandlerFactory + callable $pageHandlerFactory, ) { if (!class_exists(Run::class) || !class_exists(PrettyPageHandler::class)) { throw new \LogicException('Install filp/whoops:^2.18.7 to use WhoopsThrowableResponseFactory.'); @@ -68,15 +69,18 @@ public function __construct( * Integer codes from 400 through 599 are preserved; other codes use 500. * This renderer does not report, emit, register callbacks or negotiate Accept. * - * @param \Throwable $throwable the unchanged original failure to render + * @param \Throwable $throwable the unchanged original failure to render + * @param ServerRequestInterface $request the request; direct selection chooses development HTML * * @return ResponseInterface the development response with a fresh HTML body and no-store policy * * @throws \Throwable the unchanged response, stream, page-factory or rendering failure * @throws \TypeError when the page factory returns an incompatible value */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { $code = $throwable->getCode(); $status = (\is_int($code) && $code >= 400 && $code <= 599) ? $code : 500; $response = $this->responseFactory->createResponse($status) diff --git a/src/Http/Middleware/ErrorHandlerMiddleware.php b/src/Http/Middleware/ErrorHandlerMiddleware.php index e6e7378..95a336f 100644 --- a/src/Http/Middleware/ErrorHandlerMiddleware.php +++ b/src/Http/Middleware/ErrorHandlerMiddleware.php @@ -17,6 +17,7 @@ namespace CoiSA\ErrorHandler\Http\Middleware; use CoiSA\ErrorHandler\ErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -32,15 +33,17 @@ * This middleware SHALL use an ErrorHandlerInterface to register * and unregister error handlers and SHALL utilize a ThrowableResponseFactoryInterface * to generate appropriate HTTP responses for captured throwables. + * The scoped handler MUST also report throwables. Reporting runs before the + * response factory; renderers MUST NOT invoke the diagnostic reporter again. * * @package CoiSA\ErrorHandler\Http\Middleware */ final class ErrorHandlerMiddleware implements MiddlewareInterface { /** - * @var ErrorHandlerInterface the error handler responsible for managing errors + * @var ErrorHandlerInterface&ThrowableHandlerInterface The lifecycle and reporter sharing one failure guard */ - private ErrorHandlerInterface $errorHandler; + private ErrorHandlerInterface&ThrowableHandlerInterface $errorHandler; /** * @var ThrowableResponseFactoryInterface factory for creating HTTP responses from throwables @@ -50,12 +53,12 @@ final class ErrorHandlerMiddleware implements MiddlewareInterface /** * Constructs the ErrorHandlerMiddleware. * - * @param ErrorHandlerInterface $errorHandler the error handler instance - * @param ThrowableResponseFactoryInterface $throwableResponseFactory factory for creating throwable responses + * @param ErrorHandlerInterface&ThrowableHandlerInterface $errorHandler scoped handler + * @param ThrowableResponseFactoryInterface $throwableResponseFactory response factory */ public function __construct( - ErrorHandlerInterface $errorHandler, - ThrowableResponseFactoryInterface $throwableResponseFactory + ErrorHandlerInterface&ThrowableHandlerInterface $errorHandler, + ThrowableResponseFactoryInterface $throwableResponseFactory, ) { $this->errorHandler = $errorHandler; $this->throwableResponseFactory = $throwableResponseFactory; @@ -72,17 +75,20 @@ public function __construct( * @param RequestHandlerInterface $handler the next request handler in the chain * * @return ResponseInterface the generated HTTP response + * + * @throws \Throwable if reporting or rendering fails; callbacks are restored before propagation */ public function process( ServerRequestInterface $request, - RequestHandlerInterface $handler + RequestHandlerInterface $handler, ): ResponseInterface { $this->errorHandler->register(); try { $response = $handler->handle($request); } catch (\Throwable $throwable) { - $response = $this->throwableResponseFactory->createResponseFromThrowable($throwable); + $this->errorHandler->handleThrowable($throwable); + $response = $this->throwableResponseFactory->createResponseFromThrowable($throwable, $request); } finally { $this->errorHandler->unregister(); } diff --git a/src/ServiceProvider/ErrorHandlerServiceProvider.php b/src/ServiceProvider/ErrorHandlerServiceProvider.php index 9735755..d483094 100644 --- a/src/ServiceProvider/ErrorHandlerServiceProvider.php +++ b/src/ServiceProvider/ErrorHandlerServiceProvider.php @@ -16,32 +16,63 @@ namespace CoiSA\ErrorHandler\ServiceProvider; -use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory; use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\ErrorHandlerInterface; -use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; -use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ThrowableHandlerAggregateFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\NegotiatingThrowableResponseFactoryFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\WhoopsThrowableResponseFactoryFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\SymfonyCliThrowableHandlerFactory; use FastForward\Container\Factory\AliasFactory; use FastForward\Container\Factory\InvokableFactory; use Interop\Container\ServiceProviderInterface; -use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Whoops\Handler\PrettyPageHandler; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; +use Symfony\Component\Serializer\Encoder\JsonEncoder; /** - * Defines optional FastForward lifecycle services while retaining the 2.x capture defaults. + * Declares lifecycle services and interface aliases for FastForward autowiring. * - * Applications supply their private reporter and PSR-17 implementations. Discovery - * never installs native callbacks; middleware scopes are created by an explicit factory. + * Resolving a service MUST NOT install PHP's process-wide callbacks. + * HTTP representation is selected for each request by a shared, stateless negotiator. + * Whoops remains an explicitly selected optional adapter, independent of the default HTML renderer. + * + * @package CoiSA\ErrorHandler\ServiceProvider */ final class ErrorHandlerServiceProvider implements ServiceProviderInterface { /** - * Describes cached process services and reusable factories for fresh HTTP scopes. + * Selects trusted diagnostics without enabling them from request headers. + * + * @param bool $debug whether JSON and Symfony HTML may include development diagnostics + * @param bool $cli explicitly enables terminal diagnostics without inferring the server API + */ + public function __construct( + private readonly bool $debug = false, + private readonly bool $cli = false, + ) { + } + + /** + * Supplies aliases and factories that define reporter composition and handler lifetimes. * - * @return array inert service definitions + * @return array lazy service definitions */ public function getFactories(): array { @@ -49,17 +80,42 @@ public function getFactories(): array ErrorHandlerInterface::class => AliasFactory::get(ErrorHandler::class), ErrorHandler::class => new ErrorHandlerFactory(), ErrorHandlerFactory::class => new InvokableFactory(ErrorHandlerFactory::class), + ThrowableHandlerInterface::class => AliasFactory::get(ThrowableHandlerAggregate::class), + ThrowableHandlerAggregate::class => new ThrowableHandlerAggregateFactory($this->cli), + SymfonyCliThrowableHandler::class => new SymfonyCliThrowableHandlerFactory(), + ThrowableResponseFactoryInterface::class => AliasFactory::get(NegotiatingThrowableResponseFactory::class), + NegotiatingThrowableResponseFactory::class => new NegotiatingThrowableResponseFactoryFactory(), + JsonThrowableResponseFactory::class => new InvokableFactory( + JsonThrowableResponseFactory::class, + ResponseFactoryInterface::class, + StreamFactoryInterface::class, + SerializerInterface::class, + $this->debug, + ), + SerializerInterface::class => AliasFactory::get(Serializer::class), + Serializer::class => new InvokableFactory( + Serializer::class, + [new ProblemNormalizer(true)], + [new JsonEncoder()], + ), + HtmlThrowableResponseFactory::class => new InvokableFactory( + HtmlThrowableResponseFactory::class, + ResponseFactoryInterface::class, + StreamFactoryInterface::class, + $this->debug, + ), + PrettyPageHandler::class => new InvokableFactory(PrettyPageHandler::class), + WhoopsThrowableResponseFactory::class => new WhoopsThrowableResponseFactoryFactory(), + ThrowableStreamFactoryInterface::class => AliasFactory::get(GenericThrowableStreamFactory::class), ErrorHandlerMiddleware::class => new ErrorHandlerMiddlewareFactory(), ErrorHandlerMiddlewareFactory::class => new InvokableFactory(ErrorHandlerMiddlewareFactory::class), - ThrowableResponseFactoryInterface::class => AliasFactory::get(ThrowableResponseFactory::class), - ThrowableStreamFactoryInterface::class => AliasFactory::get(ThrowableStreamFactory::class), ]; } /** - * Leaves application services unchanged. + * Supplies no service extensions; applications own registration and decoration. * - * @return array no implicit extensions + * @return array an empty extension map */ public function getExtensions(): array { diff --git a/src/Container/Factory/ErrorHandlerFactory.php b/src/ServiceProvider/Factory/ErrorHandlerFactory.php similarity index 71% rename from src/Container/Factory/ErrorHandlerFactory.php rename to src/ServiceProvider/Factory/ErrorHandlerFactory.php index d1bede5..69d88fb 100644 --- a/src/Container/Factory/ErrorHandlerFactory.php +++ b/src/ServiceProvider/Factory/ErrorHandlerFactory.php @@ -14,7 +14,7 @@ declare(strict_types=1); -namespace CoiSA\ErrorHandler\Container\Factory; +namespace CoiSA\ErrorHandler\ServiceProvider\Factory; use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; @@ -26,10 +26,11 @@ * Class ErrorHandlerFactory. * * A factory responsible for creating instances of ErrorHandler. - * This factory SHALL ensure proper dependency injection of ThrowableHandlerInterface, - * PhpErrorHandlerInterface, and ShutdownHandlerInterface. + * Interface service IDs SHALL supply interface implementations or matching callables. + * Normalization belongs to ErrorHandler; this factory preserves optional defaults + * and constructs a fresh scope without registering native callbacks. * - * @package CoiSA\ErrorHandler\Container\Factory + * @package CoiSA\ErrorHandler\ServiceProvider\Factory */ final class ErrorHandlerFactory { @@ -45,15 +46,21 @@ final class ErrorHandlerFactory */ public function __invoke(ContainerInterface $container): ErrorHandler { - /** @var ThrowableHandlerInterface $throwableHandler */ + /** + * @var (callable(\Throwable): mixed)|ThrowableHandlerInterface $throwableHandler + */ $throwableHandler = $container->get(ThrowableHandlerInterface::class); - /** @var null|PhpErrorHandlerInterface $phpErrorHandler */ + /** + * @var null|(callable(int, string, string, int): mixed)|PhpErrorHandlerInterface $phpErrorHandler + */ $phpErrorHandler = $container->has(PhpErrorHandlerInterface::class) ? $container->get(PhpErrorHandlerInterface::class) : null; - /** @var null|ShutdownHandlerInterface $shutdownHandler */ + /** + * @var null|(callable(): mixed)|ShutdownHandlerInterface $shutdownHandler + */ $shutdownHandler = $container->has(ShutdownHandlerInterface::class) ? $container->get(ShutdownHandlerInterface::class) : null; diff --git a/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php b/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php index 7e54e78..4e2c919 100644 --- a/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php +++ b/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php @@ -16,32 +16,36 @@ namespace CoiSA\ErrorHandler\ServiceProvider\Factory; -use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Psr\Container\ContainerInterface; -/** Creates a fresh HTTP lifecycle handler without reusing the cached process handler. */ +/** + * Builds middleware with a fresh handler separate from the process service. + * + * @package CoiSA\ErrorHandler\ServiceProvider\Factory + */ final class ErrorHandlerMiddlewareFactory { /** - * Invokes the existing core factory for each new middleware scope. - * - * @param ContainerInterface $container application and library definitions + * Creates an independent middleware scope without registering PHP callbacks. * - * @return ErrorHandlerMiddleware an unregistered wrapper with a distinct core handler + * @param ContainerInterface $container the container supplying a reusable handler factory and responses * - * @throws \Throwable a dependency or construction failure + * @return ErrorHandlerMiddleware middleware owning a fresh, initially unregistered handler */ public function __invoke(ContainerInterface $container): ErrorHandlerMiddleware { - /** @var ErrorHandlerFactory $errorHandlerFactory */ - $errorHandlerFactory = $container->get(ErrorHandlerFactory::class); - $errorHandler = $errorHandlerFactory($container); + /** + * @var ErrorHandlerFactory $handlerFactory + */ + $handlerFactory = $container->get(ErrorHandlerFactory::class); - /** @var ThrowableResponseFactoryInterface $responseFactory */ - $responseFactory = $container->get(ThrowableResponseFactoryInterface::class); + /** + * @var ThrowableResponseFactoryInterface $responses + */ + $responses = $container->get(ThrowableResponseFactoryInterface::class); - return new ErrorHandlerMiddleware($errorHandler, $responseFactory); + return new ErrorHandlerMiddleware($handlerFactory($container), $responses); } } diff --git a/src/ServiceProvider/Factory/NegotiatingThrowableResponseFactoryFactory.php b/src/ServiceProvider/Factory/NegotiatingThrowableResponseFactoryFactory.php new file mode 100644 index 0000000..a1093e3 --- /dev/null +++ b/src/ServiceProvider/Factory/NegotiatingThrowableResponseFactoryFactory.php @@ -0,0 +1,84 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\ServiceProvider\Factory; + +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use Psr\Container\ContainerInterface; + +/** Composes lazy representations without resolving a current request or development page. */ +final class NegotiatingThrowableResponseFactoryFactory +{ + /** + * Creates a reusable selector; only the selected representation is constructed on a failure. + * + * The HTML candidate includes the renderer's charset so parameter-specific + * preferences and exclusions apply to the representation actually returned. + * + * @param ContainerInterface $container the resolver supplying concrete pure renderers + * + * @return NegotiatingThrowableResponseFactory the request-independent negotiation service + * + * @throws \Throwable when construction of the required generic fallback fails + */ + public function __invoke(ContainerInterface $container): NegotiatingThrowableResponseFactory + { + $fallback = self::resolveRenderer($container, ThrowableResponseFactory::class); + + return new NegotiatingThrowableResponseFactory($fallback, [ + 'text/plain' => static fn(): ThrowableResponseFactoryInterface => $fallback, + 'application/json' => static fn(): ThrowableResponseFactoryInterface => self::resolveRenderer( + $container, + JsonThrowableResponseFactory::class, + ), + 'application/problem+json' => static fn(): ThrowableResponseFactoryInterface => self::resolveRenderer( + $container, + JsonThrowableResponseFactory::class, + ), + 'text/html; charset=UTF-8' => static fn(): ThrowableResponseFactoryInterface => self::resolveRenderer( + $container, + HtmlThrowableResponseFactory::class, + ), + ]); + } + + /** + * Validates an explicitly configured renderer without retrying a different representation. + * + * @param ContainerInterface $container the application resolver + * @param class-string $id the selected renderer service + * + * @return ThrowableResponseFactoryInterface the configured renderer + * + * @throws \Throwable the unchanged service construction failure + * @throws \TypeError when a definition returns an incompatible renderer + */ + private static function resolveRenderer( + ContainerInterface $container, + string $id, + ): ThrowableResponseFactoryInterface { + $renderer = $container->get($id); + if (!$renderer instanceof ThrowableResponseFactoryInterface) { + throw new \TypeError($id . ' must implement ' . ThrowableResponseFactoryInterface::class . '.'); + } + + return $renderer; + } +} diff --git a/src/ServiceProvider/Factory/SymfonyCliThrowableHandlerFactory.php b/src/ServiceProvider/Factory/SymfonyCliThrowableHandlerFactory.php new file mode 100644 index 0000000..ed4c016 --- /dev/null +++ b/src/ServiceProvider/Factory/SymfonyCliThrowableHandlerFactory.php @@ -0,0 +1,48 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\ServiceProvider\Factory; + +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use Psr\Container\ContainerInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** Resolves an optional Console destination without registering a process or resolving a request. */ +final class SymfonyCliThrowableHandlerFactory +{ + /** + * Constructs the explicitly selected CLI reporter with application output or native stderr. + * + * @param ContainerInterface $container the application resolver supplying an optional output + * + * @return SymfonyCliThrowableHandler the inert diagnostic reporter + * + * @throws \Throwable the unchanged optional-output discovery or construction failure + * @throws \TypeError when an explicitly configured output does not implement OutputInterface + */ + public function __invoke(ContainerInterface $container): SymfonyCliThrowableHandler + { + $output = null; + if ($container->has(OutputInterface::class)) { + $output = $container->get(OutputInterface::class); + if (!$output instanceof OutputInterface) { + throw new \TypeError(OutputInterface::class . ' must resolve to an OutputInterface instance.'); + } + } + + return new SymfonyCliThrowableHandler($output); + } +} diff --git a/src/Container/Factory/ThrowableHandlerAggregateFactory.php b/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php similarity index 57% rename from src/Container/Factory/ThrowableHandlerAggregateFactory.php rename to src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php index 906f7b8..bf983ee 100644 --- a/src/Container/Factory/ThrowableHandlerAggregateFactory.php +++ b/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php @@ -14,13 +14,16 @@ declare(strict_types=1); -namespace CoiSA\ErrorHandler\Container\Factory; +namespace CoiSA\ErrorHandler\ServiceProvider\Factory; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler; use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use FastForward\Container\Exception\AutowiringException; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\EventDispatcher\EventDispatcherInterface; /** @@ -29,10 +32,19 @@ * Factory responsible for creating and configuring an instance of ThrowableHandlerAggregate. * This factory SHALL attach available handlers from the container to the aggregate handler. * - * @package CoiSA\ErrorHandler\Container\Factory + * @package CoiSA\ErrorHandler\ServiceProvider\Factory */ final class ThrowableHandlerAggregateFactory { + /** + * Enables an explicit terminal reporter without changing ordinary HTTP discovery. + * + * @param bool $cli whether the application selected CLI diagnostics + */ + public function __construct(private readonly bool $cli = false) + { + } + /** * Creates an instance of ThrowableHandlerAggregate. * @@ -41,6 +53,9 @@ final class ThrowableHandlerAggregateFactory * - DispatchThrowableHandler * - DispatchErrorEventThrowableHandler * + * An absent or structurally unconfigured optional callable SHALL be skipped. + * Other construction failures SHALL propagate to the calling container. + * * @param ContainerInterface $container the PSR-11 container instance * * @return ThrowableHandlerAggregate the configured aggregate handler @@ -50,25 +65,43 @@ public function __invoke(ContainerInterface $container): ThrowableHandlerAggrega $handler = new ThrowableHandlerAggregate(); if ($container->has(CallableThrowableHandler::class)) { - /** @var CallableThrowableHandler $callableHandler */ - $callableHandler = $container->get(CallableThrowableHandler::class); - $handler->attach($callableHandler); + try { + /** + * @var CallableThrowableHandler $callableHandler + */ + $callableHandler = $container->get(CallableThrowableHandler::class); + $handler->attach($callableHandler); + } catch (NotFoundExceptionInterface) { + // The optional reporter entry is absent. + } catch (AutowiringException) { + // Autowiring may advertise a class whose required callable is not configured. + } } if ($container->has(EventDispatcherInterface::class)) { if ($container->has(DispatchThrowableHandler::class)) { - /** @var DispatchThrowableHandler $dispatchHandler */ + /** + * @var DispatchThrowableHandler $dispatchHandler + */ $dispatchHandler = $container->get(DispatchThrowableHandler::class); $handler->attach($dispatchHandler); } if ($container->has(DispatchErrorEventThrowableHandler::class)) { - /** @var DispatchErrorEventThrowableHandler $dispatchErrorHandler */ + /** + * @var DispatchErrorEventThrowableHandler $dispatchErrorHandler + */ $dispatchErrorHandler = $container->get(DispatchErrorEventThrowableHandler::class); $handler->attach($dispatchErrorHandler); } } + if ($this->cli) { + /** @var SymfonyCliThrowableHandler $cliHandler the explicitly enabled terminal reporter */ + $cliHandler = $container->get(SymfonyCliThrowableHandler::class); + $handler->attach($cliHandler); + } + return $handler; } } diff --git a/src/ServiceProvider/Factory/WhoopsThrowableResponseFactoryFactory.php b/src/ServiceProvider/Factory/WhoopsThrowableResponseFactoryFactory.php new file mode 100644 index 0000000..e417509 --- /dev/null +++ b/src/ServiceProvider/Factory/WhoopsThrowableResponseFactoryFactory.php @@ -0,0 +1,50 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\ServiceProvider\Factory; + +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Whoops\Handler\PrettyPageHandler; + +/** Wires the explicitly selected optional Whoops renderer and its application-owned page. */ +final class WhoopsThrowableResponseFactoryFactory +{ + /** + * Resolves HTTP dependencies while deferring the configured page until rendering. + * + * @param ContainerInterface $container the application service resolver + * + * @return WhoopsThrowableResponseFactory the pure renderer with a lazy page service + * + * @throws \Throwable when an HTTP dependency fails or the optional Whoops package is missing + */ + public function __invoke(ContainerInterface $container): WhoopsThrowableResponseFactory + { + /** @var ResponseFactoryInterface $responses the application's PSR-17 factory */ + $responses = $container->get(ResponseFactoryInterface::class); + + /** @var StreamFactoryInterface $streams the application's PSR-17 factory */ + $streams = $container->get(StreamFactoryInterface::class); + + /** @var \Closure(): PrettyPageHandler $pageFactory resolves the configured page only during rendering */ + $pageFactory = static fn() => $container->get(PrettyPageHandler::class); + + return new WhoopsThrowableResponseFactory($responses, $streams, $pageFactory); + } +} diff --git a/src/ServiceProvider/JsonErrorHandlerServiceProvider.php b/src/ServiceProvider/JsonErrorHandlerServiceProvider.php deleted file mode 100644 index dd5d2e0..0000000 --- a/src/ServiceProvider/JsonErrorHandlerServiceProvider.php +++ /dev/null @@ -1,80 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\ServiceProvider; - -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ReportingThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; -use FastForward\Container\Factory\AliasFactory; -use FastForward\Container\Factory\InvokableFactory; -use Interop\Container\ServiceProviderInterface; -use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; - -/** - * Declares report-before-render JSON services for explicit composition with the base provider. - */ -final class JsonErrorHandlerServiceProvider implements ServiceProviderInterface -{ - /** @var bool whether trusted application configuration enables diagnostic JSON */ - private bool $debug; - - /** - * Selects the JSON representation before container resolution begins. - * - * @param bool $debug explicit development diagnostics; public JSON is the default - */ - public function __construct(bool $debug = false) - { - $this->debug = $debug; - } - - /** - * Composes a private reporter with pure JSON without importing lifecycle definitions. - * - * @return array JSON-only service definitions - */ - public function getFactories(): array - { - return [ - JsonThrowableResponseFactory::class => new InvokableFactory( - JsonThrowableResponseFactory::class, - ResponseFactoryInterface::class, - StreamFactoryInterface::class, - $this->debug, - ), - ReportingThrowableResponseFactory::class => new InvokableFactory( - ReportingThrowableResponseFactory::class, - ThrowableHandlerInterface::class, - JsonThrowableResponseFactory::class, - ), - ThrowableResponseFactoryInterface::class => AliasFactory::get(ReportingThrowableResponseFactory::class), - ]; - } - - /** - * Leaves application services unchanged. - * - * @return array no implicit extensions - */ - public function getExtensions(): array - { - return []; - } -} diff --git a/src/ServiceProvider/WhoopsErrorHandlerServiceProvider.php b/src/ServiceProvider/WhoopsErrorHandlerServiceProvider.php deleted file mode 100644 index 3c0759a..0000000 --- a/src/ServiceProvider/WhoopsErrorHandlerServiceProvider.php +++ /dev/null @@ -1,94 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\ServiceProvider; - -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use CoiSA\ErrorHandler\Http\Message\ReportingThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; -use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; -use FastForward\Container\Factory\AliasFactory; -use FastForward\Container\Factory\CallableFactory; -use FastForward\Container\Factory\InvokableFactory; -use Interop\Container\ServiceProviderInterface; -use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Whoops\Handler\PrettyPageHandler; - -/** - * Selects optional development HTML and explicit reporting for the 2.x middleware. - * - * Applications supply HTTP factories, a private reporter and trusted development - * authorization, and compose lifecycle services separately. Discovery creates no - * page and installs no native callbacks. - */ -final class WhoopsErrorHandlerServiceProvider implements ServiceProviderInterface -{ - /** - * Describes only this profile's page, pure renderer and reporting selection. - * - * FastForward caches the configured page. Its lazy callback resolves that service - * only during rendering, after the renderer checks its optional dependency. - * - * @return array the lazy service definitions - */ - public function getFactories(): array - { - return [ - PrettyPageHandler::class => new InvokableFactory(PrettyPageHandler::class), - WhoopsThrowableResponseFactory::class => new CallableFactory( - /** - * @param ContainerInterface $container the application service resolver - * - * @return WhoopsThrowableResponseFactory the pure renderer with a lazy page service - */ - static function (ContainerInterface $container): WhoopsThrowableResponseFactory { - /** @var ResponseFactoryInterface $responses the application's PSR-17 factory */ - $responses = $container->get(ResponseFactoryInterface::class); - - /** @var StreamFactoryInterface $streams the application's PSR-17 factory */ - $streams = $container->get(StreamFactoryInterface::class); - - /** @var \Closure(): PrettyPageHandler $pageFactory resolves the configured page only during rendering */ - $pageFactory = static fn() => $container->get(PrettyPageHandler::class); - - return new WhoopsThrowableResponseFactory( - $responses, - $streams, - $pageFactory, - ); - }, - ), - ReportingThrowableResponseFactory::class => new InvokableFactory( - ReportingThrowableResponseFactory::class, - ThrowableHandlerInterface::class, - WhoopsThrowableResponseFactory::class, - ), - ThrowableResponseFactoryInterface::class => AliasFactory::get(ReportingThrowableResponseFactory::class), - ]; - } - - /** - * Adds no extensions to the application's services. - * - * @return array the empty extension map - */ - public function getExtensions(): array - { - return []; - } -} diff --git a/tests/Documentation/site.php b/tests/Documentation/site.php index e404e3f..f7df070 100644 --- a/tests/Documentation/site.php +++ b/tests/Documentation/site.php @@ -62,13 +62,13 @@ $links = 0; foreach ($document->getElementsByTagName('a') as $link) { $href = $link->getAttribute('href'); - if (0 === strpos($href, '#')) { + if (str_starts_with($href, '#')) { if (!isset($ids[substr($href, 1)])) { throw new \RuntimeException('Missing site anchor: ' . $href); } - } elseif (0 === strpos($href, 'https://')) { + } elseif (str_starts_with($href, 'https://')) { $prefix = 'https://github.com/coisa/error-handler/blob/master/'; - if (0 === strpos($href, $prefix) && !is_file($root . '/' . substr($href, \strlen($prefix)))) { + if (str_starts_with($href, $prefix) && !is_file($root . '/' . substr($href, \strlen($prefix)))) { throw new \RuntimeException('Missing repository link target: ' . $href); } } else { @@ -85,13 +85,32 @@ throw new \RuntimeException('Every source excerpt needs exactly one code element.'); } - $source = $code->getAttribute('data-source'); + $source = $code->getAttribute('data-example') ?: $code->getAttribute('data-source'); + if ('' === $source) { + // Commands have no source excerpt; executable PHP recipes always name their source. + if (1 !== preg_match('~^(git |cd |composer |php |vendor/bin/phpunit )~', trim($code->textContent))) { + throw new \RuntimeException('An executable source block must name its example.'); + } + + continue; + } if (1 !== preg_match('~^examples/[a-z-]+\.php$~', $source)) { - throw new \RuntimeException('Every source excerpt must name an executable example: ' . $source); + throw new \RuntimeException('Invalid executable example path: ' . $source); } $snippet = $code->textContent; - if ('' === trim($snippet) || false === strpos($readFile($root . '/' . $source), $snippet)) { + $contents = $readFile($root . '/' . $source); + if ($code->hasAttribute('data-example')) { + // Licensing headers are retained in source and omitted only from displayed recipes. + $contents = preg_replace('~/\*\*.*?\*/\s*\n~s', '', $contents, 1); + if (null === $contents || trim($snippet) !== trim($contents)) { + throw new \RuntimeException('Displayed complete recipe differs from source: ' . $source); + } + $cookbook = $readFile($root . '/docs/examples.md'); + if (!str_contains($cookbook, trim($snippet))) { + throw new \RuntimeException('The cookbook is missing the complete recipe: ' . $source); + } + } elseif ('' === trim($snippet) || !str_contains($contents, $snippet)) { throw new \RuntimeException('Displayed source differs from its executable example: ' . $source); } ++$snippets; @@ -154,7 +173,8 @@ } printf( - "Static 2.x documentation passed: %d source excerpts, %d links, %d unique anchors and %d complete recipes.\n", + 'Static major documentation passed: %d source excerpts, %d links, ' + . "%d unique anchors and %d complete recipes.\n", $snippets, $links, \count($ids), diff --git a/tests/Fixtures/lifecycle.php b/tests/Fixtures/lifecycle.php index bcce631..cf64b36 100644 --- a/tests/Fixtures/lifecycle.php +++ b/tests/Fixtures/lifecycle.php @@ -21,24 +21,34 @@ require dirname(__DIR__, 2) . '/vendor/autoload.php'; $case = $argv[1] ?? ''; -$reporter = new CallableThrowableHandler( - /** - * Records actual events delivered by native exception/shutdown callbacks. - * - * @param Throwable $event the event supplied by the real handler - */ - static function (Throwable $event): void { - echo json_encode([ - 'event' => 'error', 'class' => get_class($event), - 'severity' => $event instanceof ErrorException ? $event->getSeverity() : null, - ], JSON_THROW_ON_ERROR) . "\n"; - }, -); + +/** + * Records native events and their last-error snapshot without altering diagnostic metadata. + * + * @param Throwable $event the event supplied by the real handler + */ +$reporter = static function (Throwable $event): void { + echo json_encode([ + 'event' => 'error', 'class' => $event::class, + 'severity' => $event instanceof ErrorException ? $event->getSeverity() : null, + 'message' => $event->getMessage(), 'file' => $event->getFile(), 'line' => $event->getLine(), + 'lastError' => error_get_last(), + ], JSON_THROW_ON_ERROR) . "\n"; +}; $handler = new ErrorHandler($reporter); switch ($case) { case 'exception': + case 'exception-shutdown': $handler->register(); + if ('exception-shutdown' === $case) { + register_shutdown_function( + /** Records PHP continuing the shutdown queue after explicit uncaught exit. */ + static function (): void { + echo "{\"event\":\"shutdown\"}\n"; + }, + ); + } throw new RuntimeException('synthetic uncaught exception'); @@ -52,9 +62,9 @@ static function (Throwable $event): void { case 'type-error': $handler->register(); // @phpstan-ignore argument.type (An owned bounded child verifies the native TypeError path.) - (static fn(int $value): int => $value)('not an integer'); + $unexpected = (static fn(int $value): int => $value)('not an integer'); - break; + throw new LogicException('The invalid native argument was accepted: ' . $unexpected); case 'parse-error': $handler->register(); @@ -96,6 +106,29 @@ static function (): void { break; + case 'reregister-shutdown-fatal': + case 'fresh-shutdown-fatal': + if ('reregister-shutdown-fatal' === $case) { + $handler->register(); + $handler->unregister(); + } + register_shutdown_function( + /** Causes an engine fatal before a later handler callback can run. */ + static function (): void { + require __DIR__ . '/compile-fatal.inc'; + }, + ); + $handler->register(); + + break; + + case 'unregistered-shutdown': + $handler->register(); + $handler->unregister(); + echo "{\"event\":\"continued\"}\n"; + + break; + case 'restore': set_error_handler( /** @@ -134,9 +167,10 @@ static function (Throwable $event): void { throw new RuntimeException('previous exception active'); case 'reporter-throws': + case 'shutdown-reporter-throws': $handler = new ErrorHandler(new CallableThrowableHandler( /** - * Records actual recursive delivery in the legacy native failure path. + * Records the one actual delivery before a native reporter failure. * * @param Throwable $event the original or reentered failure delivered by PHP * @@ -149,6 +183,9 @@ static function (Throwable $event): void { }, )); $handler->register(); + if ('shutdown-reporter-throws' === $case) { + require __DIR__ . '/compile-fatal.inc'; + } throw new RuntimeException('original failure'); diff --git a/tests/Fixtures/output-buffer.php b/tests/Fixtures/output-buffer.php index 748dfec..444d9a8 100644 --- a/tests/Fixtures/output-buffer.php +++ b/tests/Fixtures/output-buffer.php @@ -39,7 +39,7 @@ static function (Throwable $event): void { echo json_encode([ 'entryLevel' => $entry, 'remaining' => ob_get_level(), - 'fallbackSame' => (string) $original === (string) $stream, + 'body' => (string) $stream, ], JSON_THROW_ON_ERROR) . "\n"; return; diff --git a/tests/Fixtures/whoops-source.php b/tests/Fixtures/whoops-source.php index 3ff8959..2aace90 100644 --- a/tests/Fixtures/whoops-source.php +++ b/tests/Fixtures/whoops-source.php @@ -16,7 +16,6 @@ use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Http\Message\ReportingThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; @@ -81,7 +80,7 @@ static function (InspectorInterface $inspector) use (&$inspected, &$frames): arr ); $middleware = new ErrorHandlerMiddleware( new ErrorHandler($reporter), - new ReportingThrowableResponseFactory($reporter, $renderer), + $renderer, ); $next = new class ($original) implements RequestHandlerInterface { /** @var Throwable the unchanged downstream diagnostic */ @@ -163,7 +162,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface 'status' => $response->getStatusCode(), 'contentType' => $response->getHeaderLine('Content-Type'), 'cacheControl' => $response->getHeaderLine('Cache-Control'), - 'htmlContainsOriginal' => false !== strpos((string) $response->getBody(), $original->getMessage()), + 'htmlContainsOriginal' => str_contains((string) $response->getBody(), $original->getMessage()), 'bufferSame' => $bufferSame && $entry === ob_get_level(), 'callbacksSame' => $previousError === $currentError && $previousException === $currentException, ], JSON_THROW_ON_ERROR) . "\n"; diff --git a/tests/Functional/ConsumerSkillArchiveTest.php b/tests/Functional/ConsumerSkillArchiveTest.php index 460691d..4abeda9 100644 --- a/tests/Functional/ConsumerSkillArchiveTest.php +++ b/tests/Functional/ConsumerSkillArchiveTest.php @@ -16,15 +16,15 @@ namespace CoiSA\ErrorHandler\Test\Functional; +use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; /** * Exercises the actual distribution gate with disposable, independently malformed archives. * * @internal - * - * @coversNothing */ +#[CoversNothing] final class ConsumerSkillArchiveTest extends TestCase { /** @var string an exclusively owned temporary archive directory */ @@ -40,7 +40,7 @@ protected function setUp(): void { $this->temporaryDirectory = sys_get_temp_dir() . '/coisa-skill-archive-' . bin2hex(random_bytes(12)); - if (!mkdir($this->temporaryDirectory, 0700)) { + if (!mkdir($this->temporaryDirectory, 0o700)) { throw new \RuntimeException('Could not create the isolated archive fixture.'); } diff --git a/tests/Functional/ConsumerSkillExamplesTest.php b/tests/Functional/ConsumerSkillExamplesTest.php index 6c46ae8..502a64b 100644 --- a/tests/Functional/ConsumerSkillExamplesTest.php +++ b/tests/Functional/ConsumerSkillExamplesTest.php @@ -16,11 +16,18 @@ namespace CoiSA\ErrorHandler\Test\Functional; +use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Example\ConfiguredErrorHandlerFactory; use CoiSA\ErrorHandler\Example\FailingThrowableHandler; use CoiSA\ErrorHandler\Example\PublicThrowableResponseFactory; use CoiSA\ErrorHandler\Example\RecordingThrowableHandler; +use CoiSA\ErrorHandler\Example\ThrowingRequestHandler; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; @@ -30,21 +37,23 @@ * Verifies the shipped application examples without attributing them to core coverage. * * @internal - * - * @coversNothing */ +#[CoversNothing] final class ConsumerSkillExamplesTest extends TestCase { /** * Configuration overrides select the actual reporter before creating a fresh scope. + * + * @param bool $callback whether to select the reporter's first-class method callable */ - public function testExplicitOverridesWinAndFactoryScopesAreDistinct(): void + #[DataProvider('provideExplicitOverridesWinAndFactoryScopesAreDistinctCases')] + public function testExplicitOverridesWinAndFactoryScopesAreDistinct(bool $callback): void { $default = new RecordingThrowableHandler(); $selected = new RecordingThrowableHandler(); $factory = new ConfiguredErrorHandlerFactory(); $configuration = ['reporter' => $default]; - $overrides = ['reporter' => $selected]; + $overrides = ['reporter' => $callback ? $selected->handleThrowable(...) : $selected]; $first = $factory($configuration, $overrides); $second = $factory($configuration, $overrides); @@ -61,13 +70,18 @@ public function testExplicitOverridesWinAndFactoryScopesAreDistinct(): void self::assertSame($default, $configuration['reporter']); } + /** @return array interface and callable application override forms */ + public static function provideExplicitOverridesWinAndFactoryScopesAreDistinctCases(): iterable + { + return ['interface' => [false], 'callable' => [true]]; + } + /** * Missing and wrong-type reporters fail before the application starts. * * @param array $configuration the malformed application configuration - * - * @dataProvider provideInvalidConfigurationIsRejectedCases */ + #[DataProvider('provideInvalidConfigurationIsRejectedCases')] public function testInvalidConfigurationIsRejected(array $configuration): void { $this->expectException(\InvalidArgumentException::class); @@ -109,18 +123,16 @@ public function testConfiguredReporterFailurePreservesItsIdentity(): void } /** - * Private diagnostics and exception codes do not become the application's public body. + * The application renderer is pure and never copies diagnostic details or codes. */ - public function testPublicResponseReportsOnceWithoutLeakingDiagnostics(): void + public function testPublicResponseIsPureWithoutLeakingDiagnostics(): void { - $reporter = new RecordingThrowableHandler(); $http = new Psr17Factory(); - $renderer = new PublicThrowableResponseFactory($reporter, $http, $http); + $renderer = new PublicThrowableResponseFactory($http, $http); $failure = new \RuntimeException('Private secret detail', 404); - $response = $renderer->createResponseFromThrowable($failure); + $response = $renderer->createResponseFromThrowable($failure, new ServerRequest('GET', 'https://example.test/')); - self::assertSame([$failure], $reporter->getThrowables()); self::assertSame(500, $response->getStatusCode()); self::assertSame('Internal Server Error', $response->getReasonPhrase()); self::assertSame('{"error":"Request failed"}', (string) $response->getBody()); @@ -129,24 +141,30 @@ public function testPublicResponseReportsOnceWithoutLeakingDiagnostics(): void } /** - * A failed report stops response construction and retains the original failure object. + * A failed middleware report stops the pure renderer before PSR-17 construction. */ public function testReporterFailurePreventsPublicResponseConstruction(): void { + $original = new \RuntimeException('Original failure'); $failure = new \RuntimeException('Synthetic reporting failure'); $responses = $this->createMock(ResponseFactoryInterface::class); $responses->expects(self::never())->method('createResponse'); $streams = $this->createMock(StreamFactoryInterface::class); $streams->expects(self::never())->method('createStream'); - - $renderer = new PublicThrowableResponseFactory( - new FailingThrowableHandler($failure), - $responses, - $streams, - ); + $scope = $this->createMockForIntersectionOfInterfaces([ + ErrorHandlerInterface::class, + ThrowableHandlerInterface::class, + ]); + self::assertInstanceOf(ErrorHandlerInterface::class, $scope); + self::assertInstanceOf(ThrowableHandlerInterface::class, $scope); + $scope->expects(self::once())->method('register'); + $scope->expects(self::once())->method('unregister'); + $scope->expects(self::once())->method('handleThrowable')->with($original)->willThrowException($failure); + $middleware = new ErrorHandlerMiddleware($scope, new PublicThrowableResponseFactory($responses, $streams)); + $request = (new Psr17Factory())->createServerRequest('GET', 'http://example.test/'); try { - $renderer->createResponseFromThrowable(new \RuntimeException('Original failure')); + $middleware->process($request, new ThrowingRequestHandler($original)); self::fail('Expected reporting failure before PSR-17 construction.'); } catch (\Throwable $observed) { @@ -155,26 +173,34 @@ public function testReporterFailurePreventsPublicResponseConstruction(): void } /** - * A response-factory failure propagates after the original was reported exactly once. + * A response-factory failure propagates after one middleware report, without retry. */ public function testResponseFactoryFailureDoesNotRetryTheReporter(): void { - $reporter = new RecordingThrowableHandler(); $original = new \RuntimeException('Original request failure'); $failure = new \RuntimeException('Synthetic response factory failure'); $responses = $this->createMock(ResponseFactoryInterface::class); $responses->expects(self::once())->method('createResponse')->with(500)->willThrowException($failure); $streams = $this->createMock(StreamFactoryInterface::class); $streams->expects(self::never())->method('createStream'); - $renderer = new PublicThrowableResponseFactory($reporter, $responses, $streams); + $scope = $this->createMockForIntersectionOfInterfaces([ + ErrorHandlerInterface::class, + ThrowableHandlerInterface::class, + ]); + self::assertInstanceOf(ErrorHandlerInterface::class, $scope); + self::assertInstanceOf(ThrowableHandlerInterface::class, $scope); + $scope->expects(self::once())->method('register'); + $scope->expects(self::once())->method('unregister'); + $scope->expects(self::once())->method('handleThrowable')->with($original); + $middleware = new ErrorHandlerMiddleware($scope, new PublicThrowableResponseFactory($responses, $streams)); + $request = (new Psr17Factory())->createServerRequest('GET', 'http://example.test/'); try { - $renderer->createResponseFromThrowable($original); + $middleware->process($request, new ThrowingRequestHandler($original)); self::fail('Expected the response-factory failure to propagate.'); } catch (\Throwable $observed) { self::assertSame($failure, $observed); - self::assertSame([$original], $reporter->getThrowables()); } } @@ -182,25 +208,25 @@ public function testResponseFactoryFailureDoesNotRetryTheReporter(): void * Execute the actual programs; HTTP callback installation stays in its own child. * * @param string $file the shipped example program + * @param list $arguments the explicit synthetic program choices * @param string $output the exact success observation - * @param list $arguments explicit synthetic application inputs - * - * @dataProvider provideShippedProgramsUseTheExplicitConsumerAutoloaderCases */ + #[DataProvider('provideShippedProgramsUseTheExplicitConsumerAutoloaderCases')] public function testShippedProgramsUseTheExplicitConsumerAutoloader( string $file, + array $arguments, string $output, - array $arguments = [] ): void { $root = \dirname(__DIR__, 2); - $process = new Process([ + $process = new Process(array_merge([ PHP_BINARY, - '-n', '-d', 'memory_limit=64M', + '-d', 'display_errors=stderr', + '-d', 'log_errors=0', + '-d', 'error_reporting=-1', $root . '/resources/skills/coisa-error-handler/examples/' . $file, $root . '/vendor/autoload.php', - ...$arguments, - ], $root, null, null, 5.0); + ], $arguments), $root, null, null, 5.0); try { $process->run(); @@ -216,38 +242,68 @@ public function testShippedProgramsUseTheExplicitConsumerAutoloader( /** * Supplies complete programs without sharing mutable application collaborators. * - * @return array}> names, observations and explicit inputs + * @return array, string}> programs, arguments and expected output */ public static function provideShippedProgramsUseTheExplicitConsumerAutoloaderCases(): iterable { return [ - 'configuration' => ['configured.php', 'configured reporting: original delivered once', []], + 'configuration' => ['configured.php', [], 'configured reporting: original delivered once'], 'container' => [ - 'container.php', 'legacy container: application reporter, caching and fresh scope passed', [], + 'container.php', [], + 'Interop provider: application reporter, caching and fresh scope passed', ], - 'HTTP' => ['http.php', 'HTTP: private report once and fixed public response passed', []], + 'HTTP' => ['http.php', [], 'HTTP: private report once and fixed public response passed'], 'JSON public' => [ - 'json.php', 'JSON public: original reported once and explicit response passed', ['public'], + 'json.php', ['public'], + 'JSON public: original reported once and explicit response passed', + ], + 'JSON debug' => [ + 'json.php', ['debug'], + 'JSON debug: original reported once and explicit response passed', + ], + 'HTML public' => [ + 'html.php', ['public'], + 'HTTP HTML public: Symfony rendering and one original report passed', + ], + 'HTML debug' => [ + 'html.php', ['debug'], + 'HTTP HTML debug: Symfony rendering and one original report passed', ], - 'JSON debug' => ['json.php', 'JSON debug: original reported once and explicit response passed', ['debug']], - 'provider public' => [ - 'provider-json.php', 'Interop JSON public: independent providers and fresh scope passed', ['public'], + 'Whoops manual' => [ + 'whoops.php', [], + 'Whoops HTML: explicit development page and one original report passed', ], - 'provider debug' => [ - 'provider-json.php', 'Interop JSON debug: independent providers and fresh scope passed', ['debug'], + 'JSON provider public' => [ + 'provider-json.php', ['public'], + 'Interop JSON public: single provider and fresh scope passed', ], - 'Whoops HTML' => [ - 'whoops.php', 'Whoops HTML: explicit development page and one original report passed', [], + 'JSON provider debug' => [ + 'provider-json.php', ['debug'], + 'Interop JSON debug: single provider and fresh scope passed', + ], + 'Whoops provider public' => [ + 'provider-whoops.php', ['public'], + 'Interop HTML public: Symfony renderer and original reports passed', ], 'Whoops provider default' => [ - 'provider-whoops.php', - 'Interop Whoops default: explicit aggregate, shared page and original reports passed', - ['default'], + 'provider-whoops.php', ['default'], + 'Interop Whoops default: single provider, shared page and original reports passed', ], 'Whoops provider configured' => [ - 'provider-whoops.php', - 'Interop Whoops configured: explicit aggregate, shared page and original reports passed', - ['configured'], + 'provider-whoops.php', ['configured'], + 'Interop Whoops configured: single provider, shared page and original reports passed', + ], + 'Symfony CLI direct' => [ + 'symfony-cli.php', ['direct'], + 'Symfony CLI direct: one terminal diagnostic and explicit capture ownership passed', + ], + 'Symfony CLI provider' => [ + 'symfony-cli.php', ['provider'], + 'Symfony CLI provider: one terminal diagnostic and explicit capture ownership passed', + ], + 'Symfony CLI console output' => [ + 'symfony-cli.php', ['console'], + 'Symfony CLI console: one terminal diagnostic and explicit capture ownership passed', ], ]; } diff --git a/tests/Functional/Container/ConfigProviderTest.php b/tests/Functional/Container/ConfigProviderTest.php deleted file mode 100644 index a9ea700..0000000 --- a/tests/Functional/Container/ConfigProviderTest.php +++ /dev/null @@ -1,207 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\Test\Functional\Container; - -use CoiSA\ErrorHandler\Container\ConfigProvider; -use CoiSA\ErrorHandler\Container\ErrorHandlerContainer; -use Laminas\ServiceManager\ServiceManager; -use Nyholm\Psr7\Factory\Psr17Factory; -use Phly\EventDispatcher\EventDispatcher; -use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; -use PHPUnit\Framework\TestCase; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; - -/** - * Class ConfigProviderTest. - * - * @package CoiSA\ErrorHandler\Test\Functional\Container - * - * @internal - * - * @coversNothing - */ -final class ConfigProviderTest extends TestCase -{ - /** @var ServiceManager */ - private $serviceManager; - - /** @var ErrorHandlerContainer */ - private $container; - - /** @var ConfigProvider */ - private $configProvider; - - /** - * Compose fresh configured containers for each integration case. - */ - protected function setUp(): void - { - $this->configProvider = new ConfigProvider(); - $configs = ($this->configProvider)(); - - $this->serviceManager = new ServiceManager($configs['dependencies']); - $this->container = new ErrorHandlerContainer($this->serviceManager); - - $this->setUpServiceManager($this->serviceManager); - } - - /** - * Supply application-owned event dispatching and HTTP factories. - * - * @param ServiceManager $serviceManager the container receiving fixture services - */ - public function setUpServiceManager(ServiceManager $serviceManager): void - { - $listenerProvider = new ListenerProviderAggregate(); - $eventDispatcher = new EventDispatcher($listenerProvider); - $factory = new Psr17Factory(); - - $serviceManager->setService(EventDispatcherInterface::class, $eventDispatcher); - $serviceManager->setService(ResponseFactoryInterface::class, $factory); - $serviceManager->setService(StreamFactoryInterface::class, $factory); - } - - /** - * Verify the configured ServiceManager advertises each library service. - * - * @param class-string $className the configured service identifier - * - * @dataProvider provideContainerDependenciesClassNames - */ - public function testServiceManagerHasConfigProviderDependencies(string $className): void - { - self::assertTrue($this->serviceManager->has($className)); - } - - /** - * Verify the wrapper advertises services supplied by its parent container. - * - * @param class-string $className the configured service identifier - * - * @dataProvider provideContainerDependenciesClassNames - */ - public function testContainerWithServiceManagerHasConfigProviderDependencies(string $className): void - { - self::assertTrue($this->container->has($className)); - } - - /** - * Verify a standalone library container advertises its own services. - * - * @param class-string $className the configured service identifier - * - * @dataProvider provideContainerDependenciesClassNames - */ - public function testContainerWithoutServiceManagerHasConfigProviderDependencies(string $className): void - { - $container = new ErrorHandlerContainer(); - self::assertTrue($container->has($className)); - } - - /** - * List every alias and factory service advertised by the provider. - * - * @return list> independent service identifiers - */ - public static function provideContainerDependenciesClassNames(): iterable - { - $dependencies = (new ConfigProvider())->getDependencies(); - - return array_chunk(array_keys(array_merge(...array_values($dependencies))), 1); - } - - /** - * Verify the configured ServiceManager creates the expected instance. - * - * @param class-string $className the configured service identifier - * @param class-string $instanceOf the expected concrete type or interface - * - * @dataProvider provideContainerDependenciesWithInstanceType - */ - public function testServiceManagerCanCreateDependecyThatImplements(string $className, string $instanceOf): void - { - $object = $this->serviceManager->get($className); - self::assertInstanceOf($instanceOf, $object); - } - - /** - * Verify the wrapper delegates or constructs the expected instance. - * - * @param class-string $className the configured service identifier - * @param class-string $instanceOf the expected concrete type or interface - * - * @dataProvider provideContainerDependenciesWithInstanceType - */ - public function testContainerCanCreateDependecyThatImplements(string $className, string $instanceOf): void - { - $object = $this->container->get($className); - self::assertInstanceOf($instanceOf, $object); - } - - /** - * Verify the configured wrapper still resolves after preparing another container. - * - * @param class-string $className the configured service identifier - * @param class-string $instanceOf the expected concrete type or interface - * - * @dataProvider provideContainerDependenciesWithInstanceType - */ - public function testWithCleanServiceManagerCanCreateDependecyThatImplements( - string $className, - string $instanceOf - ): void { - $serviceManager = new ServiceManager(); - $container = new ErrorHandlerContainer($this->serviceManager); - - $this->setUpServiceManager($serviceManager); - - $object = $container->get($className); - self::assertInstanceOf($instanceOf, $object); - } - - /** - * Pair each configured service identifier with its expected instance type. - * - * @return list service and expected type pairs - */ - public static function provideContainerDependenciesWithInstanceType(): iterable - { - $dependencies = (new ConfigProvider())->getDependencies(); - $classNames = array_keys(array_merge(...array_values($dependencies))); - - return array_map( - /** - * Resolve an alias's expected type without constructing its service. - * - * @param class-string $classNameName the configured service identifier - * - * @return array{class-string, class-string} service and expected type - */ - static function ($classNameName) use ($dependencies) { - $instanceOf = $dependencies['aliases'][$classNameName] ?? $classNameName; - - return [ - $classNameName, - $instanceOf, - ]; - }, - $classNames, - ); - } -} diff --git a/tests/Functional/Container/ErrorHandlerContainerTest.php b/tests/Functional/Container/ErrorHandlerContainerTest.php deleted file mode 100644 index d31c56d..0000000 --- a/tests/Functional/Container/ErrorHandlerContainerTest.php +++ /dev/null @@ -1,397 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\Test\Functional\Container; - -use CoiSA\ErrorHandler\Container\ConfigProvider; -use CoiSA\ErrorHandler\Container\ErrorHandlerContainer; -use CoiSA\ErrorHandler\ErrorHandler; -use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent; -use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener; -use CoiSA\ErrorHandler\EventDispatcher\Listener\LogErrorEventListener; -use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener; -use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; -use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; -use CoiSA\ErrorHandler\Exception\ErrorException; -use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; -use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; -use CoiSA\ErrorHandler\Test\Log\AssertThrowableTestCaseLogger; -use Laminas\ServiceManager\ServiceManager; -use Nyholm\Psr7\Factory\Psr17Factory; -use Phly\EventDispatcher\EventDispatcher; -use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; -use PHPUnit\Framework\TestCase; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Server\RequestHandlerInterface; - -/** - * Class ErrorHandlerContainerTest. - * - * @package CoiSA\ErrorHandler\Test\Functional\Container - * - * @internal - * - * @coversNothing - * - * @runTestsInSeparateProcesses - * - * @preserveGlobalState disabled - */ -final class ErrorHandlerContainerTest extends TestCase -{ - /** @var ServiceManager */ - private $serviceManager; - - /** @var ErrorHandlerContainer */ - private $container; - - /** @var EventDispatcher */ - private $eventDispatcher; - - /** @var ListenerProviderAggregate */ - private $listenerProvider; - - /** - * Compose fresh application dependencies without installing PHP callbacks. - */ - protected function setUp(): void - { - $this->serviceManager = new ServiceManager(); - $this->container = new ErrorHandlerContainer($this->serviceManager); - - $this->listenerProvider = new ListenerProviderAggregate(); - $this->eventDispatcher = new EventDispatcher($this->listenerProvider); - - $factory = new Psr17Factory(); - - $this->serviceManager->setService(ResponseFactoryInterface::class, $factory); - $this->serviceManager->setService(StreamFactoryInterface::class, $factory); - } - - /** - * Verify the library container advertises each factory-backed service. - * - * @param class-string $service the configured service identifier - * - * @dataProvider provideServiceProviderClassNames - */ - public function testContainerHasConfigProviderFactories(string $service): void - { - self::assertTrue($this->container->has($service)); - } - - /** - * Verify each factory-backed service resolves to its declared type. - * - * @param class-string $service the configured service identifier - * - * @dataProvider provideServiceProviderClassNames - */ - public function testContainerProvideConfigProviderFactories(string $service): void - { - $this->serviceManager->setService(EventDispatcherInterface::class, $this->eventDispatcher); - self::assertInstanceOf($service, $this->container->get($service)); - } - - /** - * List the service identifiers backed by the provider's factory map. - * - * @return list> independent configured service identifiers - */ - public static function provideServiceProviderClassNames(): iterable - { - $factories = (new ConfigProvider())->getFactories(); - - return array_chunk(array_keys($factories), 1); - } - - /** - * Verify the default empty reporter aggregate accepts a throwable. - */ - public function testErrorHandlerWithoutHandlerWillDoNothing(): void - { - $message = uniqid('test', true); - $exception = new \InvalidArgumentException($message); - - /** @var ErrorHandler $errorHandler The known process-handler service from the fixture map. */ - $errorHandler = $this->container->get(ErrorHandler::class); - $errorHandler->register(); - - $this->expectNotToPerformAssertions(); - $errorHandler->handleThrowable($exception); - } - - /** - * Verify an application-supplied reporter receives the original throwable. - */ - public function testCallableHandlerWillHandleThrowable(): void - { - $message = uniqid('test', true); - $exception = new \InvalidArgumentException($message); - - $callableThrowableHandler = new CallableThrowableHandler( - /** - * Receive the throwable reported by this behavior fixture. - * - * @param \Throwable $throwable the throwable supplied to the reporter - */ - function ($throwable) use ($exception): void { - $this->assertSame($exception, $throwable); - }, - ); - $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler); - - /** @var ErrorHandler $errorHandler The known process-handler service from the fixture map. */ - $errorHandler = $this->container->get(ErrorHandler::class); - $errorHandler->register(); - - $errorHandler->handleThrowable($exception); - } - - /** - * Verify a reporter discovered by the default aggregate receives the throwable. - */ - public function testCallableHandlerFromAggregationWillHandleThrowable(): void - { - $message = uniqid('test', true); - $exception = new \InvalidArgumentException($message); - - $callableThrowableHandler = new CallableThrowableHandler( - /** - * Receive the throwable reported by this behavior fixture. - * - * @param \Throwable $throwable the throwable supplied to the reporter - */ - function ($throwable) use ($exception): void { - $this->assertSame($exception, $throwable); - }, - ); - $this->serviceManager->setService(CallableThrowableHandler::class, $callableThrowableHandler); - - /** @var ErrorHandler $errorHandler The known process-handler service from the fixture map. */ - $errorHandler = $this->container->get(ErrorHandler::class); - $errorHandler->register(); - - $errorHandler->handleThrowable($exception); - } - - /** - * Verify both event adapters and logging listeners receive the reported error. - */ - public function testEventDispatcherCallableHandlerWillHandleThrowable(): void - { - $message = uniqid('test', true); - $exception = new \InvalidArgumentException($message); - - $errorEventCallableListener = new ErrorEventCallableListener( - /** - * Assert the dispatched error event preserves the fixture throwable. - * - * @param ErrorEvent $event the dispatched error event - */ - function ($event) use ($exception): void { - $this->assertInstanceOf(ErrorEvent::class, $event); - $this->assertSame($exception, $event->getThrowable()); - $this->assertSame((string) $exception, (string) $event); - }, - ); - - $throwableListener = new ThrowableCallableListener( - /** - * Assert the throwable event preserves the fixture object. - * - * @param \Throwable $throwable the dispatched throwable - */ - function (\Throwable $throwable) use ($exception): void { - $this->assertSame($exception, $throwable); - }, - ); - - $logErrorEventListener = new LogErrorEventListener( - new AssertThrowableTestCaseLogger($this, $exception), - ); - - $errorEventListenerProvider = new ErrorEventListenerProvider($errorEventCallableListener); - $errorEventListenerProvider->attach($logErrorEventListener); - - $throwableListenerProvider = new ThrowableListenerProvider($throwableListener); - $throwableListenerProvider->attach($throwableListener); - - $this->listenerProvider->attach($errorEventListenerProvider); - $this->listenerProvider->attach($throwableListenerProvider); - - $this->serviceManager->setService(EventDispatcherInterface::class, $this->eventDispatcher); - - /** @var ErrorHandler $errorHandler The known process-handler service from the fixture map. */ - $errorHandler = $this->container->get(ErrorHandler::class); - $errorHandler->register(); - - $errorHandler->handleThrowable($exception); - } - - /** - * Verify a reported PHP error is converted into the library's error exception. - */ - public function testThrowErrorExceptionPhpErrorHandlerThrowErrorException(): void - { - $callableThrowableHandler = new CallableThrowableHandler( - /** - * Receive the throwable reported by this behavior fixture. - * - * @param \Throwable $throwable the throwable supplied to the reporter - */ - function ($throwable): void { - $this->assertInstanceOf(ErrorException::class, $throwable); - }, - ); - $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler); - - /** @var ErrorHandler $errorHandler The known process-handler service from the fixture map. */ - $errorHandler = $this->container->get(ErrorHandler::class); - $errorHandler->register(); - - trigger_error('Test user error', E_USER_ERROR); - } - - /** - * Verify middleware reports a request failure and builds the expected response. - */ - public function testErrorHandlerMiddlewareHandleRequestException(): void - { - $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500)); - - $callableThrowableHandler = new CallableThrowableHandler( - /** - * Receive the throwable reported by this behavior fixture. - * - * @param \Throwable $throwable the throwable supplied to the reporter - */ - static function (\Throwable $throwable): void { - echo json_encode([ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - }, - ); - $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler); - - /** @var ErrorHandler $errorHandler The known process-handler service from the fixture map. */ - $errorHandler = $this->container->get(ErrorHandler::class); - $errorHandler->register(); - - $serverRequest = (new Psr17Factory())->createServerRequest('GET', '/'); - $requestHandler = new class ($exception) implements RequestHandlerInterface { - /** @var \Throwable The failure raised by this request fixture. */ - private $exception; - - /** - * Retain the request failure without throwing during construction. - * - * @param \Throwable $exception the throwable raised when handling a request - */ - public function __construct(\Throwable $exception) - { - $this->exception = $exception; - } - - /** - * Raise the configured fixture failure for the supplied request. - * - * @param ServerRequestInterface $serverRequest the synthetic request - * - * @return ResponseInterface a response is never produced by this fixture - * - * @throws \Throwable the failure supplied at construction - */ - public function handle(ServerRequestInterface $serverRequest): ResponseInterface - { - throw $this->exception; - } - }; - - /** @var ErrorHandlerMiddleware $middleware The known middleware service from the fixture map. */ - $middleware = $this->container->get(ErrorHandlerMiddleware::class); - $response = $middleware->process($serverRequest, $requestHandler); - - self::assertInstanceOf(ResponseInterface::class, $response); - self::assertSame($exception->getCode(), $response->getStatusCode()); - - $body = json_decode((string) $response->getBody(), true); - - self::assertIsArray($body); - self::assertSame($exception->getCode(), $body['code']); - self::assertSame($exception->getMessage(), $body['message']); - } - - /** - * Verify shutdown handling is inert while the process handler is unregistered. - */ - public function testUnregisteredErrorHandlerHandleShutdownWillDoNothing(): void - { - /** @var ErrorHandler $errorHandler The known process-handler service from the fixture map. */ - $errorHandler = $this->container->get(ErrorHandler::class); - $errorHandler->handleShutdown(); - - $output = $this->getActualOutput(); - self::assertEmpty($output); - } - - /** - * Verify a shutdown failure is reported through the configured throwable handler. - */ - public function testErrorHandlerHandleShutdownWithErrorWillHandledByThrowableHandler(): void - { - $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500)); - - $callableThrowableHandler = new CallableThrowableHandler( - /** - * Receive the throwable reported by this behavior fixture. - * - * @param \Throwable $throwable the throwable supplied to the reporter - */ - function ($throwable) use ($exception): void { - $this->assertSame($exception, $throwable); - }, - ); - - $callableShutdownHandler = new CallableShutdownHandler( - /** - * Raise the configured shutdown failure for the reporting assertion. - * - * @throws \InvalidArgumentException the shutdown fixture throwable - */ - static function () use ($exception): void { - throw $exception; - }, - ); - - $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler); - $this->serviceManager->setService(ShutdownHandlerInterface::class, $callableShutdownHandler); - - /** @var ErrorHandler $errorHandler The known process-handler service from the fixture map. */ - $errorHandler = $this->container->get(ErrorHandler::class); - $errorHandler->register(); - - $errorHandler->handleShutdown(); - } -} diff --git a/tests/Functional/ErrorHandlerLifecycleTest.php b/tests/Functional/ErrorHandlerLifecycleTest.php new file mode 100644 index 0000000..900c766 --- /dev/null +++ b/tests/Functional/ErrorHandlerLifecycleTest.php @@ -0,0 +1,157 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Functional; + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; +use Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * Covers independent direct deliveries and reporter failure within a middleware scope. + * + * @internal + */ +#[CoversNothing] +final class ErrorHandlerLifecycleTest extends TestCase +{ + /** + * Verifies a failed direct delivery does not poison later independent calls. + */ + public function testDirectDeliveryCanResumeAfterReporterFailure(): void + { + $calls = 0; + $failure = new \RuntimeException('Temporary reporter failure'); + $handler = new ErrorHandler(new CallableThrowableHandler( + static function () use (&$calls, $failure): void { + if (1 === ++$calls) { + throw $failure; + } + }, + )); + + try { + $handler->handleThrowable(new \RuntimeException('First delivery')); + self::fail('The reporter failure must propagate.'); + } catch (\RuntimeException $caught) { + self::assertSame($failure, $caught); + } + $handler->handleThrowable(new \RuntimeException('Independent delivery')); + self::assertSame(2, $calls); + } + + /** + * Verifies unregistering ends suppression even without a new registration. + */ + public function testDirectDeliveryAfterFailedRegistrationIsIndependent(): void + { + $calls = 0; + $handler = new ErrorHandler(new CallableThrowableHandler(static function () use (&$calls): void { + if (1 === ++$calls) { + throw new \RuntimeException('Reporter failed'); + } + })); + $handler->register(); + + try { + try { + $handler->handleThrowable(new \RuntimeException('First delivery')); + } catch (\RuntimeException) { + } + $second = new \RuntimeException('Suppressed retry'); + + try { + $handler->handleThrowable($second); + self::fail('The failed registration must suppress retries.'); + } catch (\RuntimeException $caught) { + self::assertSame($second, $caught); + } + self::assertSame(1, $calls); + } finally { + $handler->unregister(); + } + $handler->handleThrowable(new \RuntimeException('Independent delivery')); + self::assertSame(2, $calls); + } + + /** + * Verifies middleware propagates a failed reporter without rendering through it again. + */ + #[WithoutErrorHandler] + public function testMiddlewareDoesNotRetryReporterAfterPhpWarning(): void + { + $previousError = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $previousException = set_exception_handler(static function (): void { + }); + restore_exception_handler(); + $calls = 0; + $failure = new \RuntimeException('Reporter failed'); + $reporter = new CallableThrowableHandler(static function () use (&$calls, $failure): void { + ++$calls; + + throw $failure; + }); + $psr17 = new Psr17Factory(); + $errors = new ErrorHandler($reporter); + $middleware = new ErrorHandlerMiddleware( + $errors, + new ThrowableResponseFactory(new ThrowableStreamFactory($psr17, $reporter), $psr17), + ); + $downstream = new class implements RequestHandlerInterface { + /** + * Triggers a reportable warning inside the active middleware scope. + * + * @param ServerRequestInterface $request the synthetic request + * + * @return ResponseInterface a response that must never be reached + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + trigger_error('Downstream warning', E_USER_WARNING); + + throw new \LogicException('The warning reporter should have failed.'); + } + }; + $previousReporting = error_reporting(E_ALL); + + try { + $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream); + self::fail('Reporter failure must propagate to the application.'); + } catch (\RuntimeException $caught) { + self::assertSame($failure, $caught); + } finally { + error_reporting($previousReporting); + } + self::assertSame(1, $calls); + $activeError = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $activeException = set_exception_handler(static function (): void { + }); + restore_exception_handler(); + self::assertSame($previousError, $activeError); + self::assertSame($previousException, $activeException); + } +} diff --git a/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php b/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php new file mode 100644 index 0000000..91582b5 --- /dev/null +++ b/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php @@ -0,0 +1,110 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Functional\Http\Middleware; + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; +use Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * Verifies native callback restoration after response construction fails. + * + * @package CoiSA\ErrorHandler\Test\Functional\Http\Middleware + * + * @internal + */ +#[CoversNothing] +final class ErrorHandlerCleanupTest extends TestCase +{ + /** + * Verifies response failures preserve the previous error and exception callbacks. + */ + public function testRestoresHandlersEvenWhenTheResponseFactoryThrows(): void + { + $previousError = static fn(): bool => true; + $previousException = static function (\Throwable $error): void { + }; + set_error_handler($previousError, E_USER_NOTICE); + set_exception_handler($previousException); + $failure = new \RuntimeException('response factory failed'); + $responses = new class ($failure) implements ThrowableResponseFactoryInterface { + /** + * Stores synthetic collaborators or failures for this test fixture. + * + * @param \Throwable $failure the synthetic failure to propagate + */ + public function __construct(private \Throwable $failure) + { + } + + /** + * Throws the configured rendering failure to exercise callback restoration. + * + * @param \Throwable $throwable the throwable being rendered + * @param ServerRequestInterface $request the incoming request associated with the failure + * + * @return ResponseInterface the fixture result + */ + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { + throw $this->failure; + } + }; + $next = new class implements RequestHandlerInterface { + /** + * Executes the synthetic downstream behavior for the requested test route. + * + * @param ServerRequestInterface $request the synthetic incoming request + * + * @return ResponseInterface the fixture result + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new \TypeError('request failed'); + } + }; + $reporter = new CallableThrowableHandler(static function (): void { + }); + $middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); + + try { + $middleware->process((new Psr17Factory())->createServerRequest('GET', '/'), $next); + self::fail('Response factory failure must propagate.'); + } catch (\RuntimeException $actual) { + self::assertSame($failure, $actual); + $activeError = set_error_handler(static fn(): bool => true); + restore_error_handler(); + $activeException = set_exception_handler(static function (\Throwable $error): void { + }); + restore_exception_handler(); + self::assertSame($previousError, $activeError); + self::assertSame($previousException, $activeException); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + } +} diff --git a/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php b/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php index 8442e38..a14fa6d 100644 --- a/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php +++ b/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php @@ -18,94 +18,169 @@ use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; use Psr\Http\Server\RequestHandlerInterface; /** - * Class ErrorHandlerMiddlewareTest. - * - * @package CoiSA\ErrorHandler\Test\Functional\Http\Middleware + * Verifies reporting and public HTTP rendering remain separate operations. * * @internal - * - * @coversNothing */ +#[CoversNothing] final class ErrorHandlerMiddlewareTest extends TestCase { /** - * Verify explicit middleware composition reports and renders a request failure. + * Verifies reporting precedes pure JSON rendering, without captured output or private details. */ - public function testErrorHandlerMiddlewareHandleRequestException(): void + public function testReportsOnceBeforeRenderingTheResponse(): void { - $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500)); - - $callableThrowableHandler = new CallableThrowableHandler( + $failure = new \RuntimeException('Private database credentials', 503); + $events = self::operations(); + $received = []; + $reporter = new CallableThrowableHandler( + static function (\Throwable $throwable) use ($events, &$received): void { + $events[] = 'report'; + $received[] = $throwable; + }, + ); + $psr17 = new Psr17Factory(); + $streams = new class ($psr17, $events) implements ThrowableStreamFactoryInterface { /** - * Receive the throwable reported by this behavior fixture. + * Stores the public stream factory and the observable operation order. * - * @param \Throwable $throwable the throwable supplied to the reporter + * @param StreamFactoryInterface $streams creates public body streams + * @param \ArrayObject $events receives ordered fixture operations */ - static function (\Throwable $throwable): void { - echo json_encode([ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - }, - ); + public function __construct(private StreamFactoryInterface $streams, private \ArrayObject $events) + { + } - $streamFactory = $reponseFactory = new Psr17Factory(); - $serverRequest = $streamFactory->createServerRequest('GET', 'http://localhost'); + /** + * Returns a JSON stream directly without invoking a reporter or writing output. + * + * @param \Throwable $throwable the private failure whose details remain undisclosed + * + * @return StreamInterface the application's controlled public JSON body + */ + public function createStreamFromThrowable(\Throwable $throwable): StreamInterface + { + $this->events[] = 'render'; - $requestHandler = new class ($exception) implements RequestHandlerInterface { - /** @var \Throwable The failure raised by this request fixture. */ - private $exception; + return $this->streams->createStream('{"error":"Service unavailable"}'); + } + }; + $next = self::downstream($failure); + $middleware = new ErrorHandlerMiddleware( + new ErrorHandler($reporter), + new ThrowableResponseFactory($streams, $psr17), + ); + ob_start(); + + try { + $response = $middleware->process($psr17->createServerRequest('GET', '/'), $next); + $output = ob_get_contents(); + } finally { + ob_end_clean(); + } + + self::assertSame('', $output); + self::assertSame(['report', 'render'], $events->getArrayCopy()); + self::assertSame([$failure], $received); + self::assertSame(503, $response->getStatusCode()); + self::assertSame('{"error":"Service unavailable"}', (string) $response->getBody()); + } + /** + * Verifies successful responses pass through without reporting or rendering. + */ + public function testPassesThroughSuccessfulResponses(): void + { + $psr17 = new Psr17Factory(); + $expected = $psr17->createResponse(204); + $reports = 0; + $middleware = new ErrorHandlerMiddleware( + new ErrorHandler(new CallableThrowableHandler(static function () use (&$reports): void { + ++$reports; + })), + new ThrowableResponseFactory(new GenericThrowableStreamFactory($psr17), $psr17), + ); + $next = new class ($expected) implements RequestHandlerInterface { /** - * Retain the failure raised when the synthetic request is handled. + * Stores the application's successful response. * - * @param \Throwable $exception the request's configured throwable + * @param ResponseInterface $response the response that must pass through unchanged */ - public function __construct(\Throwable $exception) + public function __construct(private ResponseInterface $response) { - $this->exception = $exception; } /** - * Raise the configured failure instead of returning a response. + * Returns the application's successful response. * * @param ServerRequestInterface $request the synthetic request * - * @return ResponseInterface a response is never produced by this fixture - * - * @throws \Throwable the failure supplied at construction + * @return ResponseInterface the unchanged downstream response */ public function handle(ServerRequestInterface $request): ResponseInterface { - throw $this->exception; + return $this->response; } }; - $errorHandler = new ErrorHandler($callableThrowableHandler); - - $throwableStreamFactory = new ThrowableStreamFactory($streamFactory, $callableThrowableHandler); - $throwableResponseFactory = new ThrowableResponseFactory($throwableStreamFactory, $reponseFactory); - - $middleware = new ErrorHandlerMiddleware($errorHandler, $throwableResponseFactory); - $response = $middleware->process($serverRequest, $requestHandler); + self::assertSame($expected, $middleware->process($psr17->createServerRequest('GET', '/'), $next)); + self::assertSame(0, $reports); + } - self::assertInstanceOf(ResponseInterface::class, $response); - self::assertSame($exception->getCode(), $response->getStatusCode()); + /** + * Creates an empty operation log shared by reporting and rendering collaborators. + * + * @return \ArrayObject ordered operation labels recorded by the fixture + */ + private static function operations(): \ArrayObject + { + return new \ArrayObject(); + } - $body = json_decode((string) $response->getBody(), true); + /** + * Creates the failing application used by the rendering regression. + * + * @param \Throwable $failure the exact failure to report and render + * + * @return RequestHandlerInterface the synthetic downstream application + */ + private static function downstream(\Throwable $failure): RequestHandlerInterface + { + return new class ($failure) implements RequestHandlerInterface { + /** + * Stores the downstream failure. + * + * @param \Throwable $failure the exact throwable to propagate + */ + public function __construct(private \Throwable $failure) + { + } - self::assertIsArray($body); - self::assertSame($exception->getCode(), $body['code']); - self::assertSame($exception->getMessage(), $body['message']); + /** + * Throws the private failure for the middleware to handle. + * + * @param ServerRequestInterface $request the synthetic incoming request + * + * @return ResponseInterface the error response supplied by middleware + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw $this->failure; + } + }; } } diff --git a/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php new file mode 100644 index 0000000..34cc0ec --- /dev/null +++ b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php @@ -0,0 +1,471 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Functional\ServiceProvider; + +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent; +use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener; +use CoiSA\ErrorHandler\EventDispatcher\Listener\LogErrorEventListener; +use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener; +use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; +use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; +use CoiSA\ErrorHandler\Exception\ErrorException; +use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use Nyholm\Psr7\Factory\Psr17Factory; +use FastForward\Config\ArrayConfig; +use FastForward\EventDispatcher\ServiceProvider\EventDispatcherServiceProvider; +use Psr\EventDispatcher\ListenerProviderInterface; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\AbstractLogger; +use Psr\Log\LogLevel; + +use function FastForward\Container\container; + +/** + * Verifies reporter, event and HTTP composition through the Interop service provider. + * + * @package CoiSA\ErrorHandler\Test\Functional\ServiceProvider + * + * @internal + */ +#[CoversNothing] +final class ErrorHandlerCompositionTest extends TestCase +{ + /** + * @var array application services supplied before resolving the container + */ + private array $services = []; + + /** + * @var null|ContainerInterface lazily composed container for the current test + */ + private ?ContainerInterface $container = null; + + /** + * @var EventDispatcherInterface the application dispatcher composed through FastForward + */ + private $eventDispatcher; + + /** + * Creates isolated container and reporting collaborators for each test. + */ + protected function setUp(): void + { + $dispatcher = container( + new ArrayConfig([ListenerProviderInterface::class => []]), + EventDispatcherServiceProvider::class, + )->get(EventDispatcherInterface::class); + self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $this->eventDispatcher = $dispatcher; + + $factory = new Psr17Factory(); + + $this->services = [ResponseFactoryInterface::class => $factory, StreamFactoryInterface::class => $factory]; + } + + /** + * Restores native PHP callbacks after each integration test. + */ + protected function tearDown(): void + { + if (null !== $this->container) { + $this->errorHandler()->unregister(); + } + } + + /** + * Verifies the configured container advertises every library service. + * + * @param class-string $service the service identifier under test + */ + #[DataProvider('provideServiceProviderClassNames')] + public function testContainerHasProviderServices(string $service): void + { + self::assertTrue($this->container()->has($service)); + } + + /** + * Verifies service construction with application HTTP and event dependencies. + * + * @param class-string $service the service identifier under test + */ + #[DataProvider('provideServiceProviderClassNames')] + public function testContainerResolvesProviderServices(string $service): void + { + $this->services[EventDispatcherInterface::class] = $this->eventDispatcher; + self::assertInstanceOf($service, $this->container()->get($service)); + } + + /** + * Provides service identifiers explicitly declared by the Interop provider. + * + * @return list declared service-identifier cases + */ + public static function provideServiceProviderClassNames(): iterable + { + $factories = (new ErrorHandlerServiceProvider())->getFactories(); + + return array_chunk(array_keys($factories), 1); + } + + /** + * Composes the library provider with application service definitions last. + * + * @return ContainerInterface the shared container for the current test + */ + private function container(): ContainerInterface + { + $application = new ArrayServiceProvider(array_map( + static fn(object $service): \Closure => static fn(): object => $service, + $this->services, + )); + + return $this->container ??= container(new AggregateServiceProvider( + new ErrorHandlerServiceProvider(), + $application, + )); + } + + /** + * Resolves and verifies the concrete process handler supplied by the provider. + * + * @return ErrorHandler the real container-resolved process handler + */ + private function errorHandler(): ErrorHandler + { + $handler = $this->container()->get(ErrorHandler::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + + return $handler; + } + + /** + * Verifies an empty aggregate intentionally produces no diagnostics. + */ + public function testErrorHandlerWithoutHandlerWillDoNothing(): void + { + $message = uniqid('test', true); + $exception = new \InvalidArgumentException($message); + + $errorHandler = $this->errorHandler(); + $errorHandler->register(); + + $this->expectOutputString(''); + $errorHandler->handleThrowable($exception); + } + + /** + * Verifies an explicit reporter receives the original throwable. + */ + public function testCallableHandlerWillHandleThrowable(): void + { + $message = uniqid('test', true); + $exception = new \InvalidArgumentException($message); + + $callableThrowableHandler = new CallableThrowableHandler(function ($throwable) use ($exception): void { + $this->assertSame($exception, $throwable); + }); + $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler; + + $errorHandler = $this->errorHandler(); + $errorHandler->register(); + + $errorHandler->handleThrowable($exception); + } + + /** + * Verifies optional callable reporting through the default aggregate. + */ + public function testCallableHandlerFromAggregationWillHandleThrowable(): void + { + $message = uniqid('test', true); + $exception = new \InvalidArgumentException($message); + + $callableThrowableHandler = new CallableThrowableHandler(function ($throwable) use ($exception): void { + $this->assertSame($exception, $throwable); + }); + $this->services[CallableThrowableHandler::class] = $callableThrowableHandler; + + $errorHandler = $this->errorHandler(); + $errorHandler->register(); + + $errorHandler->handleThrowable($exception); + } + + /** + * Verifies throwable and error-event listeners receive the original diagnostics. + */ + public function testEventDispatcherCallableHandlerWillHandleThrowable(): void + { + $exception = new \InvalidArgumentException('Default PSR-14 composition'); + $events = []; + $throwables = []; + $logger = new class extends AbstractLogger { + /** @var list Synthetic private records collected after delivery. */ + public array $records = []; + + /** + * Retains diagnostics for assertions after the reporter has returned. + * + * @param mixed $level the configured PSR-3 level + * @param string|\Stringable $message the private diagnostic + * @param array $context unused synthetic context + */ + public function log($level, $message, array $context = []): void + { + $this->records[] = [$level, (string) $message]; + } + }; + $errorEventCallableListener = new ErrorEventCallableListener( + /** @param ErrorEvent $event The wrapper emitted by the default aggregate. */ + static function (ErrorEvent $event) use (&$events): void { + $events[] = $event; + }, + ); + $throwableListener = new ThrowableCallableListener( + /** @param \Throwable $throwable The raw diagnostic emitted by the default aggregate. */ + static function (\Throwable $throwable) use (&$throwables): void { + $throwables[] = $throwable; + }, + ); + $logErrorEventListener = new LogErrorEventListener($logger); + + $errorEventListenerProvider = new ErrorEventListenerProvider($errorEventCallableListener); + $errorEventListenerProvider->attach($logErrorEventListener); + + $throwableListenerProvider = new ThrowableListenerProvider($throwableListener); + + $dispatcher = container( + new ArrayConfig([ + ListenerProviderInterface::class => [$errorEventListenerProvider, $throwableListenerProvider], + ]), + EventDispatcherServiceProvider::class, + )->get(EventDispatcherInterface::class); + self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $this->eventDispatcher = $dispatcher; + + $this->services[EventDispatcherInterface::class] = $this->eventDispatcher; + + $errorHandler = $this->errorHandler(); + self::assertCount(0, $throwables); + self::assertCount(0, $events); + self::assertSame([], $logger->records); + + $errorHandler->handleThrowable($exception); + + self::assertSame([$exception], $throwables); + self::assertCount(1, $events); + self::assertSame($exception, $events[0]->getThrowable()); + self::assertSame((string) $exception, (string) $events[0]); + self::assertSame([[LogLevel::ERROR, (string) $exception]], $logger->records); + } + + /** + * Verifies masked PHP notices do not reach the reporter. + */ + #[WithoutErrorHandler] + public function testErrorHandlerRespectErrorReporting(): void + { + $received = []; + $callableThrowableHandler = new CallableThrowableHandler( + /** + * Records any delivery that would violate the masked-notice policy. + * + * @param \Throwable $throwable the converted event, if incorrectly delivered + */ + static function (\Throwable $throwable) use (&$received): void { + $received[] = $throwable; + }, + ); + $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler; + + $errorHandler = $this->errorHandler(); + $errorHandler->register(); + + $oldLevel = error_reporting(E_ALL ^ E_USER_NOTICE); + + try { + trigger_error('Test error reporting', E_USER_NOTICE); + } finally { + error_reporting($oldLevel); + } + + self::assertSame([], $received); + } + + /** + * Verifies reportable PHP warnings are converted to ErrorException. + */ + #[WithoutErrorHandler] + public function testThrowErrorExceptionPhpErrorHandlerThrowErrorException(): void + { + $callableThrowableHandler = new CallableThrowableHandler(function ($throwable): void { + $this->assertInstanceOf(ErrorException::class, $throwable); + }); + $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler; + + $errorHandler = $this->errorHandler(); + $errorHandler->register(); + + trigger_error('Test user warning', E_USER_WARNING); + } + + /** + * Verifies middleware captures downstream exceptions and renders an application response. + */ + public function testErrorHandlerMiddlewareHandleRequestException(): void + { + $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500)); + + $received = []; + $callableThrowableHandler = new CallableThrowableHandler( + static function (\Throwable $throwable) use (&$received): void { + $received[] = $throwable; + }, + ); + $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler; + + $errorHandler = $this->errorHandler(); + $errorHandler->register(); + + $serverRequest = (new Psr17Factory())->createServerRequest('GET', '/'); + $requestHandler = new class ($exception) implements RequestHandlerInterface { + /** + * @var \Throwable the downstream failure to propagate + */ + private $exception; + + /** + * Stores synthetic collaborators or failures for this test fixture. + * + * @param \Throwable $exception the downstream throwable + */ + public function __construct(\Throwable $exception) + { + $this->exception = $exception; + } + + /** + * Executes the synthetic downstream behavior for the requested test route. + * + * @param ServerRequestInterface $serverRequest the synthetic incoming request + * + * @return ResponseInterface the fixture result + */ + public function handle(ServerRequestInterface $serverRequest): ResponseInterface + { + throw $this->exception; + } + }; + + $middleware = $this->container()->get(ErrorHandlerMiddleware::class); + self::assertInstanceOf(ErrorHandlerMiddleware::class, $middleware); + $response = $middleware->process($serverRequest, $requestHandler); + + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame($exception->getCode(), $response->getStatusCode()); + + self::assertSame('Internal Server Error', (string) $response->getBody()); + self::assertSame([$exception], $received); + } + + /** + * Verifies an inactive handler does not process shutdown notifications. + */ + public function testUnregisteredErrorHandlerHandleShutdownWillDoNothing(): void + { + $this->expectOutputString(''); + $errorHandler = $this->errorHandler(); + $errorHandler->handleShutdown(); + } + + /** + * Verifies a custom shutdown failure reaches the configured reporter. + */ + public function testErrorHandlerHandleShutdownWithErrorWillHandledByThrowableHandler(): void + { + $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500)); + + $callableThrowableHandler = new CallableThrowableHandler(function ($throwable) use ($exception): void { + $this->assertSame($exception, $throwable); + }); + + $callableShutdownHandler = new CallableShutdownHandler(static function () use ($exception): void { + throw $exception; + }); + + $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler; + $this->services[ShutdownHandlerInterface::class] = $callableShutdownHandler; + + $errorHandler = $this->errorHandler(); + $errorHandler->register(); + + $errorHandler->handleShutdown(); + } + + /** + * Verifies unregistering an inactive handler is idempotent. + */ + public function testUnregisterNotRegisteredErrorHandlerShouldNotThrowExceptions(): void + { + $errorHandler = $this->errorHandler(); + $before = self::callbacks(); + $errorHandler->unregister(); + self::assertSame($before, self::callbacks()); + } + + /** + * Snapshots both callbacks without retaining a probe on either native stack. + * + * @return array{null|callable, null|callable} current PHP error and exception callbacks + */ + private static function callbacks(): array + { + /** @return bool A temporary callback that observes the prior error callback. */ + $error = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $exception = set_exception_handler( + /** + * Supplies a temporary exception callback without handling a real failure. + * + * @param \Throwable $error the unused probe input + */ + static function (\Throwable $error): void { + }, + ); + restore_exception_handler(); + + return [$error, $exception]; + } +} diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php new file mode 100644 index 0000000..6bac1df --- /dev/null +++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php @@ -0,0 +1,1026 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Functional\ServiceProvider; + +use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\ErrorHandlerInterface; +use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent; +use CoiSA\ErrorHandler\Exception\ErrorException; +use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler; +use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler; +use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ThrowableHandlerAggregateFactory; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; +use FastForward\Container\Exception\AutowiringException; +use FastForward\Container\Exception\ContainerException; +use FastForward\Container\ServiceProviderContainer; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use FastForward\Container\Factory\ServiceFactory; +use Interop\Container\ServiceProviderInterface; +use Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Server\RequestHandlerInterface; + +use function FastForward\Container\container; +use function FastForward\Config\config; + +/** + * Verifies real FastForward wiring, reporting failures and independent middleware scopes. + * + * @package CoiSA\ErrorHandler\Test\Functional\ServiceProvider + * + * @internal + */ +#[CoversNothing] +final class ErrorHandlerServiceProviderTest extends TestCase +{ + /** A supplied Console destination cannot activate diagnostics without the explicit CLI option. */ + public function testCliFalseDoesNotPrintEvenWithConfiguredConsoleOutput(): void + { + $output = new BufferedOutput(); + $before = self::callbacks(); + $services = container(new ArrayServiceProvider([ + OutputInterface::class => new ServiceFactory($output), + ]), new ErrorHandlerServiceProvider(cli: false)); + $handler = $services->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + self::assertSame($before, self::callbacks()); + + $handler->handleThrowable(new \RuntimeException('CLI must remain explicitly selected')); + + self::assertSame('', $output->fetch()); + self::assertSame($before, self::callbacks()); + } + + /** A selected CLI profile composes the real adapter without registering process-wide callbacks. */ + public function testCliTrueWritesToApplicationOutputWithoutInstallingCallbacks(): void + { + $output = new BufferedOutput(); + $before = self::callbacks(); + $services = container(new ArrayServiceProvider([ + OutputInterface::class => new ServiceFactory($output), + ]), new ErrorHandlerServiceProvider(cli: true)); + $handler = $services->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + self::assertSame('', $output->fetch()); + self::assertSame($before, self::callbacks()); + $original = new \RuntimeException('CLI provider diagnostic', 503); + $trace = $original->getTrace(); + + $handler->handleThrowable($original); + $text = $output->fetch(); + + self::assertStringContainsString('CLI provider diagnostic', $text); + self::assertStringContainsString('RuntimeException', $text); + self::assertSame($trace, $original->getTrace()); + self::assertSame($before, self::callbacks()); + } + + /** An application reporter override wins and does not resolve or print through the CLI adapter. */ + public function testApplicationReporterOverridePreventsCliResolutionAndOutput(): void + { + $received = []; + $reporter = new CallableThrowableHandler( + /** @param \Throwable $event the unchanged original failure received privately */ + static function (\Throwable $event) use (&$received): void { + $received[] = $event; + }, + ); + $output = new BufferedOutput(); + $before = self::callbacks(); + $services = container(new ArrayServiceProvider([ + OutputInterface::class => new ServiceFactory($output), + ThrowableHandlerInterface::class => new ServiceFactory($reporter), + SymfonyCliThrowableHandler::class => static function (): never { + throw new \LogicException('An application reporter override must not resolve the CLI adapter.'); + }, + ]), new ErrorHandlerServiceProvider(cli: true)); + self::assertSame($reporter, $services->get(ThrowableHandlerInterface::class)); + $handler = $services->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + $original = new \RuntimeException('Private application override'); + + $handler->handleThrowable($original); + + self::assertSame([$original], $received); + self::assertSame('', $output->fetch()); + self::assertSame($before, self::callbacks()); + } + + /** + * Verifies constructor autowiring preserves the provider's public interface aliases. + */ + public function testHttpAdaptersAreAutowiredAndShareTheirInterfaceServices(): void + { + $received = []; + $container = self::httpServices($received); + self::assertInstanceOf( + GenericThrowableStreamFactory::class, + $container->get(GenericThrowableStreamFactory::class), + ); + self::assertSame( + $container->get(GenericThrowableStreamFactory::class), + $container->get(ThrowableStreamFactoryInterface::class), + ); + self::assertInstanceOf(ThrowableResponseFactory::class, $container->get(ThrowableResponseFactory::class)); + self::assertSame( + $container->get(NegotiatingThrowableResponseFactory::class), + $container->get(ThrowableResponseFactoryInterface::class), + ); + } + + /** + * Verifies an explicit interface binding can select the optional output-capture renderer. + */ + public function testApplicationCanSelectAnExplicitOutputCaptureRenderer(): void + { + $renderer = new ThrowableStreamFactory(new Psr17Factory(), new CallableThrowableHandler( + static function (): void { + echo 'Application response'; + }, + )); + $container = self::services([ThrowableStreamFactoryInterface::class => $renderer]); + + self::assertSame($renderer, $container->get(ThrowableStreamFactoryInterface::class)); + self::assertSame( + 'Application response', + (string) $renderer->createStreamFromThrowable(new \RuntimeException()), + ); + } + + /** + * Verifies successful concrete response definitions remain effective under autowiring. + */ + public function testApplicationCanOverrideAnAutowiredConcreteResponseAdapter(): void + { + $psr17 = new Psr17Factory(); + $renderer = new ThrowableResponseFactory(new ThrowableStreamFactory( + $psr17, + new CallableThrowableHandler(static function (): void { + echo 'Application response'; + }), + ), $psr17); + $container = self::services([ThrowableResponseFactory::class => $renderer]); + + $negotiator = $container->get(ThrowableResponseFactoryInterface::class); + self::assertInstanceOf(NegotiatingThrowableResponseFactory::class, $negotiator); + self::assertSame($renderer, $container->get(ThrowableResponseFactory::class)); + self::assertNotSame($renderer, $negotiator); + $response = $negotiator->createResponseFromThrowable(new \RuntimeException(), self::request()); + self::assertSame('Application response', (string) $response->getBody()); + self::assertSame('text/plain', $response->getHeaderLine('Content-Type')); + self::assertSame('Accept', $response->getHeaderLine('Vary')); + } + + /** + * Verifies missing required HTTP dependencies fail construction instead of returning partial middleware. + */ + public function testMissingPsr17FactoriesPreventMiddlewareConstruction(): void + { + $container = self::services(); + $this->expectException(ContainerExceptionInterface::class); + $container->get(ErrorHandlerMiddleware::class); + } + + /** + * Verifies required renderer interface failures cannot fall back to an autowired renderer. + * + * @param class-string $service the required renderer interface + * @param \Throwable $failure the synthetic construction failure + */ + #[DataProvider('provideRequiredRendererFailurePreventsMiddlewareConstructionCases')] + public function testRequiredRendererFailurePreventsMiddlewareConstruction( + string $service, + \Throwable $failure, + ): void { + self::assertRequiredServiceFails(ErrorHandlerMiddleware::class, $service, $failure); + } + + /** + * Provides PSR and application failures for both required renderer interfaces. + * + * @return iterable + * Required renderer construction failures + */ + public static function provideRequiredRendererFailurePreventsMiddlewareConstructionCases(): iterable + { + $cases = self::provideRequiredReporterFailureCannotFallBackToASilentDefaultCases(); + foreach ($cases as $label => [$service, $failure]) { + if (ErrorHandlerInterface::class !== $service) { + continue; + } + foreach ([ThrowableStreamFactoryInterface::class, ThrowableResponseFactoryInterface::class] as $renderer) { + yield $renderer . ' / ' . $label => [$renderer, $failure]; + } + } + } + + /** + * Verifies provider bootstrap is lazy and preserves process-handler alias identity. + */ + public function testProviderClassBootstrapIsLazyAndAliasesTheProcessHandler(): void + { + $before = self::callbacks(); + $container = container(ErrorHandlerServiceProvider::class); + + self::assertTrue($container->has(ErrorHandlerInterface::class)); + $handler = $container->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + self::assertSame($handler, $container->get(ErrorHandler::class)); + self::assertSame($handler, $container->get(ErrorHandlerInterface::class)); + self::assertSame($before, self::callbacks()); + $handler->handleThrowable(new \RuntimeException('No reporter is configured.')); + } + + /** + * Verifies application definitions replace the silent default reporter. + */ + public function testApplicationReporterOverridesTheDefaultAggregate(): void + { + $received = []; + $reporter = new CallableThrowableHandler(static function (\Throwable $error) use (&$received): void { + $received[] = $error; + }); + $container = self::services([ThrowableHandlerInterface::class => $reporter]); + $error = new \RuntimeException('Custom reporter'); + + self::assertSame($reporter, $container->get(ThrowableHandlerInterface::class)); + $handler = $container->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + $handler->handleThrowable($error); + + self::assertSame([$error], $received); + } + + /** + * Verifies required reporter failures prevent process and middleware construction. + * + * @param class-string $service the service identifier under test + * @param \Throwable $failure the synthetic failure to propagate + */ + #[DataProvider('provideRequiredReporterFailureCannotFallBackToASilentDefaultCases')] + public function testRequiredReporterFailureCannotFallBackToASilentDefault( + string $service, + \Throwable $failure, + ): void { + self::assertRequiredServiceFails($service, ThrowableHandlerInterface::class, $failure); + } + + /** + * Provides required-reporter failure categories for both lifecycle services. + * + * @return iterable + * Required-reporter failure cases + */ + public static function provideRequiredReporterFailureCannotFallBackToASilentDefaultCases(): iterable + { + $failures = [ + 'PSR construction failure' => [ + new class ('Reporter failed') extends \RuntimeException implements ContainerExceptionInterface { + }, + ], + 'PSR missing dependency' => [ + new class ('Missing dependency') extends \RuntimeException implements NotFoundExceptionInterface { + }, + ], + 'application failure' => [new \LogicException('Reporter failed')], + ]; + foreach ([ErrorHandlerInterface::class, ErrorHandlerMiddleware::class] as $service) { + foreach ($failures as $label => [$failure]) { + yield $service . ' / ' . $label => [$service, $failure]; + } + } + } + + /** + * Verifies callable reporting works without a PSR-14 dispatcher. + */ + public function testAnOptionalCallableReporterIsComposedWithoutAnEventDispatcher(): void + { + $received = []; + $container = self::services([ + CallableThrowableHandler::class => new CallableThrowableHandler( + static function (\Throwable $error) use (&$received): void { + $received[] = $error; + }, + ), + ]); + $error = new \RuntimeException('Callable reporter'); + + $handler = $container->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + $handler->handleThrowable($error); + + self::assertSame([$error], $received); + } + + /** Verifies the native container advertises the optional class without an inferable callback. */ + public function testTheNativeOptionalCallableIsStructurallyUnavailable(): void + { + $container = self::services(); + self::assertTrue($container->has(CallableThrowableHandler::class)); + $this->expectException(AutowiringException::class); + + $container->get(CallableThrowableHandler::class); + } + + /** Verifies real provider-only resolution skips a genuinely absent optional callable. */ + public function testAMissingOptionalCallableDoesNotPreventProviderOnlyEventDelivery(): void + { + $events = []; + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects(self::exactly(2))->method('dispatch')->willReturnCallback( + /** + * Retains both routes without invoking listeners during construction. + * + * @param object $event the raw throwable or ErrorEvent wrapper + * + * @return object the unchanged event + */ + static function (object $event) use (&$events): object { + $events[] = $event; + + return $event; + }, + ); + $application = new ArrayServiceProvider([ + EventDispatcherInterface::class => new ServiceFactory($dispatcher), + DispatchThrowableHandler::class => new ServiceFactory(new DispatchThrowableHandler($dispatcher)), + DispatchErrorEventThrowableHandler::class => new ServiceFactory( + new DispatchErrorEventThrowableHandler($dispatcher), + ), + ]); + $container = new ServiceProviderContainer(new AggregateServiceProvider( + new ErrorHandlerServiceProvider(), + $application, + )); + self::assertFalse($container->has(CallableThrowableHandler::class)); + $reporter = $container->get(ThrowableHandlerInterface::class); + self::assertInstanceOf(ThrowableHandlerAggregate::class, $reporter); + self::assertCount(0, $events); + $original = new \RuntimeException('Provider-only event delivery'); + + $reporter->handleThrowable($original); + + self::assertCount(2, $events); + self::assertSame($original, $events[0]); + self::assertInstanceOf(ErrorEvent::class, $events[1]); + self::assertSame($original, $events[1]->getThrowable()); + } + + /** + * Verifies the factory preserves explicit failures while native concrete fallback remains visible. + * + * @param \Throwable $failure the exact failure from an explicitly configured callable factory + */ + #[DataProvider('provideExplicitCallableFactoryFailuresRetainCausesAndNativeFallbackCases')] + public function testExplicitCallableFactoryFailuresRetainCausesAndNativeFallback(\Throwable $failure): void + { + $attempts = 0; + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects(self::never())->method('dispatch'); + $application = new ArrayServiceProvider([ + CallableThrowableHandler::class => static function () use ($failure, &$attempts): never { + ++$attempts; + + throw $failure; + }, + EventDispatcherInterface::class => new ServiceFactory($dispatcher), + ]); + $container = self::withApplication($application); + + try { + (new ThrowableHandlerAggregateFactory())($container); + self::fail('The aggregate factory must not swallow an explicit callable factory failure.'); + } catch (ContainerException $caught) { + self::assertSame($failure, $caught->getPrevious()); + } + self::assertGreaterThan(0, $attempts); + + // The final container can still replace the failed concrete aggregate with an empty autowired one. + $reporter = $container->get(ThrowableHandlerInterface::class); + self::assertInstanceOf(ThrowableHandlerAggregate::class, $reporter); + self::assertSame($reporter, $container->get(ThrowableHandlerAggregate::class)); + self::assertSame($reporter, $container->get(ThrowableHandlerInterface::class)); + $reporter->handleThrowable(new \RuntimeException('Native fallback remains silent')); + } + + /** @return iterable Explicit application and structural factory failures. */ + public static function provideExplicitCallableFactoryFailuresRetainCausesAndNativeFallbackCases(): iterable + { + yield 'application failure' => [new \LogicException('Explicit callable factory failed')]; + + yield 'structural failure thrown by an explicit factory' => [ + AutowiringException::forUnresolvableParameter(CallableThrowableHandler::class, 'callable', 'callable'), + ]; + } + + /** + * Verifies optional callable, raw throwable and wrapped event delivery in order. + */ + public function testOptionalPsr14DispatcherReceivesTheThrowableAndErrorEvent(): void + { + $dispatcher = new class implements EventDispatcherInterface { + /** + * @var list route and original event for each reporter delivery + */ + public array $events = []; + + /** + * Records the event and returns the same object to the caller. + * + * @param object $event event for the fixture + * + * @return object the fixture result + */ + public function dispatch(object $event): object + { + $this->events[] = ['dispatch', $event]; + + return $event; + } + }; + $callable = new CallableThrowableHandler( + /** + * Records the optional callable's position before the two dispatch adapters. + * + * @param \Throwable $event the unchanged diagnostic supplied by the aggregate + * + * @return void + */ + static function (\Throwable $event) use ($dispatcher): void { + $dispatcher->events[] = ['callable', $event]; + }, + ); + $container = self::services([ + CallableThrowableHandler::class => $callable, + EventDispatcherInterface::class => $dispatcher, + ]); + $error = new \RuntimeException('Dispatched'); + + self::assertInstanceOf(DispatchThrowableHandler::class, $container->get(DispatchThrowableHandler::class)); + self::assertInstanceOf( + DispatchErrorEventThrowableHandler::class, + $container->get(DispatchErrorEventThrowableHandler::class), + ); + + $handler = $container->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + $handler->handleThrowable($error); + + self::assertCount(3, $dispatcher->events); + self::assertSame(['callable', $error], $dispatcher->events[0]); + self::assertSame(['dispatch', $error], $dispatcher->events[1]); + self::assertSame('dispatch', $dispatcher->events[2][0]); + self::assertInstanceOf(ErrorEvent::class, $dispatcher->events[2][1]); + self::assertSame($error, $dispatcher->events[2][1]->getThrowable()); + } + + /** + * Verifies optional application callbacks are injected and previous callbacks restored. + */ + #[WithoutErrorHandler] + public function testCustomPhpAndShutdownHandlersAreInjected(): void + { + $phpHandler = new class implements PhpErrorHandlerInterface { + /** + * @var list messages received by the synthetic PHP error handler + */ + public array $messages = []; + + /** + * Records a PHP error message without escalating the synthetic warning. + * + * @param int $errno errno for the fixture + * @param string $message message for the fixture + * @param string $file file for the fixture + * @param int $line line for the fixture + */ + public function handlePhpError(int $errno, string $message, string $file, int $line): void + { + $this->messages[] = $message; + } + }; + $shutdowns = 0; + $container = self::services([ + PhpErrorHandlerInterface::class => $phpHandler, + ShutdownHandlerInterface::class => new CallableShutdownHandler( + static function () use (&$shutdowns): void { + ++$shutdowns; + }, + ), + ]); + $handler = $container->get(ErrorHandler::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + $before = self::callbacks(); + $handler->register(); + + try { + trigger_error('Custom PHP handler', E_USER_WARNING); + $handler->handleShutdown(); + } finally { + $handler->unregister(); + } + + self::assertSame(['Custom PHP handler'], $phpHandler->messages); + self::assertSame(1, $shutdowns); + self::assertSame($before, self::callbacks()); + } + + /** + * Verifies factory invocations create independent handlers while process lookups remain cached. + */ + public function testReusableFactoryCreatesFreshHandlersWithoutReplacingTheProcessService(): void + { + $container = self::services(); + $process = $container->get(ErrorHandlerInterface::class); + $factory = $container->get(ErrorHandlerFactory::class); + self::assertInstanceOf(ErrorHandlerFactory::class, $factory); + $first = $factory($container); + $second = $factory($container); + + self::assertInstanceOf(ErrorHandler::class, $first); + self::assertNotSame($first, $second); + self::assertNotSame($first, $process); + self::assertSame($process, $container->get(ErrorHandlerInterface::class)); + } + + /** Real configuration can bind callbacks without replacing the provider's explicit scope factories. */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testConfiguredCallbacksPreserveOverridesCachingAndFreshScopes(): void + { + $reports = []; + $phpErrors = []; + $shutdowns = 0; + + /** @param \Throwable $event the original private diagnostic */ + $reportCallback = static function (\Throwable $event) use (&$reports): void { + $reports[] = $event; + }; + + /** + * Records native metadata while returning a value the void adapter ignores. + * + * @param int $code the exact PHP severity + * @param string $message the exact PHP diagnostic + * @param string $filename the actual diagnostic source + * @param int $line the actual source line + * + * @return false no request for native fall-through is implied + */ + $phpErrorCallback = static function ( + int $code, + string $message, + string $filename, + int $line, + ) use (&$phpErrors): false { + $phpErrors[] = [$code, $message, $filename, $line]; + + return false; + }; + + /** Records the explicitly notified active scope. */ + $shutdownCallback = static function () use (&$shutdowns): void { + ++$shutdowns; + }; + $settings = config(['callbacks' => [ + 'reporter' => $reportCallback, + 'php_errors' => $phpErrorCallback, + 'shutdown' => $shutdownCallback, + ]]); + $reporter = $settings->get('callbacks.reporter'); + $phpCallback = $settings->get('callbacks.php_errors'); + $shutdown = $settings->get('callbacks.shutdown'); + self::assertIsCallable($reporter); + self::assertIsCallable($phpCallback); + self::assertIsCallable($shutdown); + $messages = new Psr17Factory(); + $application = new ArrayServiceProvider([ + ThrowableHandlerInterface::class => new ServiceFactory($reporter), + PhpErrorHandlerInterface::class => new ServiceFactory($phpCallback), + ShutdownHandlerInterface::class => new ServiceFactory($shutdown), + ResponseFactoryInterface::class => new ServiceFactory($messages), + StreamFactoryInterface::class => new ServiceFactory($messages), + ]); + $before = self::callbacks(); + $services = self::withApplication($application); + $process = $services->get(ErrorHandler::class); + self::assertInstanceOf(ErrorHandler::class, $process); + self::assertSame($process, $services->get(ErrorHandlerInterface::class)); + self::assertSame($reporter, $services->get(ThrowableHandlerInterface::class)); + $factory = $services->get(ErrorHandlerFactory::class); + self::assertInstanceOf(ErrorHandlerFactory::class, $factory); + $first = $factory($services); + $second = $factory($services); + self::assertNotSame($first, $second); + self::assertNotSame($process, $first); + $middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); + self::assertInstanceOf(ErrorHandlerMiddlewareFactory::class, $middlewareFactory); + $middleware = $services->get(ErrorHandlerMiddleware::class); + self::assertInstanceOf(ErrorHandlerMiddleware::class, $middleware); + self::assertSame($middleware, $services->get(ErrorHandlerMiddleware::class)); + self::assertNotSame($middleware, $middlewareFactory($services)); + self::assertSame([[], [], 0], [$reports, $phpErrors, $shutdowns]); + self::assertSame($before, self::callbacks()); + $original = new \RuntimeException('Configured private diagnostic', 503); + $process->handleThrowable($original); + $process->register(); + + try { + $outer = self::callbacks(); + $first->register(); + + try { + self::assertNotSame($outer, self::callbacks()); + $line = __LINE__ + 1; + trigger_error('Configured callback warning', E_USER_WARNING); + $first->handleShutdown(); + } finally { + $first->unregister(); + } + + self::assertSame($outer, self::callbacks()); + $response = $middleware->process(self::request(), self::failsWith($original)); + self::assertSame(503, $response->getStatusCode()); + self::assertSame('Internal Server Error', (string) $response->getBody()); + self::assertSame($outer, self::callbacks()); + } finally { + $process->unregister(); + } + + self::assertSame([$original, $original], $reports); + self::assertSame([[E_USER_WARNING, 'Configured callback warning', __FILE__, $line]], $phpErrors); + self::assertSame(1, $shutdowns); + self::assertSame($process, $services->get(ErrorHandler::class)); + self::assertSame($before, self::callbacks()); + } + + /** + * Verifies middleware restores the active process handler after a downstream failure. + */ + #[WithoutErrorHandler] + public function testMiddlewareRestoresTheAlreadyRegisteredProcessHandler(): void + { + $received = []; + $container = self::httpServices($received); + $process = $container->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $process); + $process->register(); + + try { + $before = self::callbacks(); + $middleware = $container->get(ErrorHandlerMiddleware::class); + self::assertInstanceOf(ErrorHandlerMiddleware::class, $middleware); + self::assertSame($before, self::callbacks()); + $failure = new \RuntimeException('Private diagnostics', 503); + $response = $middleware->process(self::request(), self::failsWith($failure)); + + self::assertSame(503, $response->getStatusCode()); + self::assertSame('Internal Server Error', (string) $response->getBody()); + self::assertSame($before, self::callbacks()); + trigger_error('After middleware', E_USER_WARNING); + } finally { + $process->unregister(); + } + + self::assertCount(2, $received); + self::assertSame($failure, $received[0]); + self::assertInstanceOf(ErrorException::class, $received[1]); + self::assertSame('After middleware', $received[1]->getMessage()); + } + + /** + * Verifies nested middleware uses independent handlers and restores the process scope. + */ + #[WithoutErrorHandler] + public function testFactoryCreatesIndependentMiddlewareForNestedScopes(): void + { + $received = []; + $container = self::httpServices($received); + $factory = $container->get(ErrorHandlerMiddlewareFactory::class); + self::assertInstanceOf(ErrorHandlerMiddlewareFactory::class, $factory); + $outer = $factory($container); + $inner = $factory($container); + $failure = new \RuntimeException('Nested'); + $downstream = new class ($inner, self::failsWith($failure)) implements RequestHandlerInterface { + /** + * Stores synthetic collaborators or failures for this test fixture. + * + * @param ErrorHandlerMiddleware $inner inner for the fixture + * @param RequestHandlerInterface $downstream downstream for the fixture + */ + public function __construct( + private ErrorHandlerMiddleware $inner, + private RequestHandlerInterface $downstream, + ) { + } + + /** + * Executes the synthetic downstream behavior for the requested test route. + * + * @param ServerRequestInterface $request the synthetic incoming request + * + * @return ResponseInterface the fixture result + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->inner->process($request, $this->downstream); + } + }; + $process = $container->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $process); + $process->register(); + + try { + $before = self::callbacks(); + $response = $outer->process(self::request(), $downstream); + self::assertSame(500, $response->getStatusCode()); + self::assertSame($before, self::callbacks()); + } finally { + $process->unregister(); + } + + self::assertNotSame($outer, $inner); + self::assertSame([$failure], $received); + } + + /** + * Verifies renderer failures propagate after restoring the active process callbacks. + */ + #[WithoutErrorHandler] + public function testRendererFailureRestoresTheProcessHandler(): void + { + $failure = new \RuntimeException('Renderer failed'); + $renderer = new class ($failure) implements ThrowableResponseFactoryInterface { + /** + * Stores synthetic collaborators or failures for this test fixture. + * + * @param \Throwable $failure the synthetic failure to propagate + */ + public function __construct(private \Throwable $failure) + { + } + + /** + * Throws the configured rendering failure to exercise callback restoration. + * + * @param \Throwable $throwable the throwable being rendered + * @param ServerRequestInterface $request the current request being rendered + * + * @return ResponseInterface the fixture result + */ + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { + throw $this->failure; + } + }; + $container = self::services([ThrowableResponseFactoryInterface::class => $renderer]); + $process = $container->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $process); + $process->register(); + + try { + $before = self::callbacks(); + $middleware = $container->get(ErrorHandlerMiddleware::class); + self::assertInstanceOf(ErrorHandlerMiddleware::class, $middleware); + + try { + $middleware->process(self::request(), self::failsWith(new \RuntimeException('Original'))); + self::fail('The renderer failure must propagate.'); + } catch (\RuntimeException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame($before, self::callbacks()); + } finally { + $process->unregister(); + } + } + + /** + * Verifies the required factory is reached and construction cannot silently fall back. + * + * FastForward may propagate the original failure or normalize it to a PSR-11 exception. + * + * @param class-string $requestedService the process or middleware service being constructed + * @param class-string $failedService the explicitly required reporter or renderer interface + * @param \Throwable $failure the exact failure thrown by the application factory + */ + private static function assertRequiredServiceFails( + string $requestedService, + string $failedService, + \Throwable $failure, + ): void { + $attempts = 0; + $application = new ArrayServiceProvider([ + $failedService => static function () use ($failure, &$attempts): never { + ++$attempts; + + throw $failure; + }, + ResponseFactoryInterface::class => static fn(): Psr17Factory => new Psr17Factory(), + StreamFactoryInterface::class => static fn(): Psr17Factory => new Psr17Factory(), + ]); + $container = self::withApplication($application); + + try { + $container->get($requestedService); + } catch (\Throwable $caught) { + self::assertTrue( + $caught === $failure || $caught instanceof ContainerExceptionInterface, + 'Construction must propagate the original failure or a PSR-11 normalization.', + ); + self::assertGreaterThan(0, $attempts, 'The explicitly configured failing factory must be reached.'); + + return; + } + + self::fail('A required service failure must prevent construction instead of falling back.'); + } + + /** + * Composes library definitions with application service instances. + * + * @param array $services application service overrides + * + * @return ContainerInterface the container with application definitions merged last + */ + private static function services(array $services = []): ContainerInterface + { + $application = new class ($services) implements ServiceProviderInterface { + /** + * Stores synthetic collaborators or failures for this test fixture. + * + * @param array $services application service instances + */ + public function __construct(private array $services) + { + } + + /** + * Supplies application service definitions for the integration fixture. + * + * @return array application service factories + */ + public function getFactories(): array + { + return array_map( + static fn(object $service): \Closure => static fn(): object => $service, + $this->services, + ); + } + + /** + * Declares no application service extensions. + * + * @return array an empty extension map + */ + public function getExtensions(): array + { + return []; + } + }; + + return self::withApplication($application); + } + + /** + * Merges application definitions last to prevent silent required-reporter fallback. + * + * @param ServiceProviderInterface $application application for the fixture + * + * @return ContainerInterface the fixture result + */ + private static function withApplication(ServiceProviderInterface $application): ContainerInterface + { + return container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + } + + /** + * Supplies HTTP factories and records private reporting for middleware tests. + * + * @param list<\Throwable> $received receives privately reported throwables + * + * @return ContainerInterface the fixture result + */ + private static function httpServices(array &$received): ContainerInterface + { + $psr17 = new Psr17Factory(); + + return self::services([ + ThrowableHandlerInterface::class => new CallableThrowableHandler( + static function (\Throwable $error) use (&$received): void { + $received[] = $error; + }, + ), + ResponseFactoryInterface::class => $psr17, + StreamFactoryInterface::class => $psr17, + ]); + } + + /** + * Creates a synthetic request for the provider integration tests. + * + * @return ServerRequestInterface the fixture result + */ + private static function request(): ServerRequestInterface + { + return (new Psr17Factory())->createServerRequest('GET', '/provider-test'); + } + + /** + * Creates a downstream handler that throws the supplied failure. + * + * @param \Throwable $failure the synthetic failure to propagate + * + * @return RequestHandlerInterface the fixture result + */ + private static function failsWith(\Throwable $failure): RequestHandlerInterface + { + return new class ($failure) implements RequestHandlerInterface { + /** + * Stores synthetic collaborators or failures for this test fixture. + * + * @param \Throwable $failure the synthetic failure to propagate + */ + public function __construct(private \Throwable $failure) + { + } + + /** + * Executes the synthetic downstream behavior for the requested test route. + * + * @param ServerRequestInterface $request the synthetic incoming request + * + * @return ResponseInterface the fixture result + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw $this->failure; + } + }; + } + + /** + * Snapshots native PHP callbacks while preserving their stacks and error masks. + * + * @return array{0: null|callable, 1: null|callable} current error and exception callbacks + */ + private static function callbacks(): array + { + $error = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $exception = set_exception_handler(static function (\Throwable $error): void { + }); + restore_exception_handler(); + + return [$error, $exception]; + } +} diff --git a/tests/Process/ProcessBehaviorTest.php b/tests/Process/ProcessBehaviorTest.php index 53a85eb..62886ce 100644 --- a/tests/Process/ProcessBehaviorTest.php +++ b/tests/Process/ProcessBehaviorTest.php @@ -18,6 +18,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; /** * Verifies native termination outside PHPUnit's result protocol using public process control. @@ -25,30 +27,28 @@ * These are process tests; their children do not contribute parent unit coverage. * * @internal - * - * @coversNothing */ +#[CoversNothing] final class ProcessBehaviorTest extends TestCase { /** * Verifies actual exit status, native reporting order and emitted exception metadata. * * @param string $case the owned fixture selection - * @param int $exit the established2.x native exit status + * @param int $exit the major native exit status * @param string[] $events expected event names in actual delivery order * @param null|string $class the first reported Throwable type when relevant * @param null|int $severity the fatal severity when relevant * @param bool $native whether PHP emits its own fatal diagnostic - * - * @dataProvider provideNativeLifecycleCases */ + #[DataProvider('provideNativeLifecycleCases')] public function testNativeLifecycle( string $case, int $exit, array $events, ?string $class = null, ?int $severity = null, - bool $native = false + bool $native = false, ): void { $process = new Process([ PHP_BINARY, '-n', '-d', 'memory_limit=16M', '-d', 'display_errors=stderr', @@ -97,6 +97,26 @@ static function (string $type, string $chunk) use (&$bytes, &$limited): void { if (null !== $severity) { self::assertSame($severity, $observed[0]['severity'] ?? null); } + if (\in_array($case, ['compile-fatal', 'nested-fatal'], true)) { + $fatal = $observed[0]; + $lastError = $fatal['lastError'] ?? null; + self::assertIsArray($lastError); + self::assertSame(E_COMPILE_ERROR, $lastError['type']); + self::assertSame($lastError['message'], $fatal['message']); + self::assertSame($lastError['file'], $fatal['file']); + self::assertSame($lastError['line'], $fatal['line']); + + $fixture = \dirname(__DIR__) . '/Fixtures/compile-fatal.inc'; + $lines = file($fixture, FILE_IGNORE_NEW_LINES); + self::assertIsArray($lines); + $declaration = array_search('class FireInvalidSubclass extends FireFinalClass {}', $lines, true); + self::assertIsInt($declaration); + self::assertSame($fixture, $fatal['file']); + self::assertSame($declaration + 1, $fatal['line']); + self::assertIsString($fatal['message']); + self::assertStringContainsString('FireInvalidSubclass', $fatal['message']); + self::assertStringContainsString('FireFinalClass', $fatal['message']); + } if ($native) { self::assertNotSame('', $process->getErrorOutput()); } else { @@ -110,19 +130,32 @@ public static function provideNativeLifecycleCases(): iterable $fatal = 'CoiSA\ErrorHandler\Exception\ErrorException'; return [ - 'uncaught exception exits zero' => ['exception', 0, ['error'], \RuntimeException::class, null, false], - 'uncaught engine Error' => ['error', 0, ['error'], \Error::class, null, false], - 'uncaught TypeError' => ['type-error', 0, ['error'], \TypeError::class, null, false], - 'uncaught ParseError' => ['parse-error', 0, ['error'], \ParseError::class, null, false], + 'uncaught exception exits one' => ['exception', 1, ['error'], \RuntimeException::class, null, false], + 'uncaught engine Error' => ['error', 1, ['error'], \Error::class, null, false], + 'uncaught TypeError' => ['type-error', 1, ['error'], \TypeError::class, null, false], + 'uncaught ParseError' => ['parse-error', 1, ['error'], \ParseError::class, null, false], 'compile fatal' => ['compile-fatal', 255, ['error'], $fatal, E_COMPILE_ERROR, true], - 'nested fatal reports twice' => ['nested-fatal', 255, ['error', 'error'], $fatal, E_COMPILE_ERROR, true], + 'nested fatal reports to active owner' => ['nested-fatal', 255, ['error'], $fatal, E_COMPILE_ERROR, true], 'memory fatal' => ['memory-limit', 255, ['error'], $fatal, E_ERROR, true], - 'shutdown queue retains registrations' => ['shutdown-repeated', 0, - ['shutdown', 'shutdown', 'shutdown', 'shutdown'], null, null, false], + 'shutdown queue retains one callback' => ['shutdown-repeated', 0, ['shutdown'], null, null, false], + 'reused handler cannot recover an earlier shutdown fatal' => [ + 'reregister-shutdown-fatal', 255, [], null, null, true, + ], + 'fresh handler cannot recover an earlier shutdown fatal' => [ + 'fresh-shutdown-fatal', 255, [], null, null, true, + ], + 'uncaught delivery still runs shutdown queue' => [ + 'exception-shutdown', 1, ['error', 'shutdown'], \RuntimeException::class, null, false, + ], + 'unregistered shutdown remains inert' => [ + 'unregistered-shutdown', 0, ['continued'], null, null, false, + ], 'native previous callbacks restored' => ['restore', 42, ['error', 'previous-error', 'previous-exception'], $fatal, E_USER_WARNING, false], - 'reporter failure reenters' => ['reporter-throws', 255, - ['reporter-called', 'reporter-called', 'reporter-called'], null, null, true], + 'reporter failure is not retried' => ['reporter-throws', 255, + ['reporter-called'], null, null, true], + 'fatal reporter failure is not retried' => ['shutdown-reporter-throws', 255, + ['reporter-called'], null, null, true], 'actual shutdown adapter failure' => [ 'shutdown-throws', 0, ['error'], \RuntimeException::class, null, false, ], @@ -135,9 +168,8 @@ public static function provideNativeLifecycleCases(): iterable * @param string $case the hazardous native fixture selection * @param int $remaining the established number of remaining scopes above entry * @param int $callbacks the expected output callback count before shutdown - * - * @dataProvider provideHazardousOutputHandlersCases */ + #[DataProvider('provideHazardousOutputHandlersCases')] public function testHazardousOutputHandlers(string $case, int $remaining, int $callbacks): void { $process = new Process([ @@ -182,7 +214,7 @@ static function (string $type, string $chunk) use (&$bytes, &$limited): void { if ('closed-capture-without-caller' === $case) { self::assertSame('', $process->getErrorOutput()); self::assertSame( - ['entryLevel' => 0, 'remaining' => 0, 'fallbackSame' => true], + ['entryLevel' => 0, 'remaining' => 0, 'body' => 'Internal Server Error'], json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR), ); @@ -214,9 +246,8 @@ public static function provideHazardousOutputHandlersCases(): iterable * Preserves native source frames and real warnings through the released Whoops integration. * * @param string $mode the actual PHP input route, without fabricated throwable paths - * - * @dataProvider provideWhoopsNativeSourceFramesCases */ + #[DataProvider('provideWhoopsNativeSourceFramesCases')] public function testWhoopsNativeSourceFrames(string $mode): void { $fixture = \dirname(__DIR__) . '/Fixtures/whoops-source.php'; @@ -238,7 +269,6 @@ public function testWhoopsNativeSourceFrames(string $mode): void array_splice($command, \count($command) - 1, 0, ['--']); } $inherited = getenv(); - self::assertIsArray($inherited); $environment = array_fill_keys(array_keys($inherited), false); $process = new Process($command, null, $environment, 'stdin' === $mode ? $source : null, 2.0); $bytes = 0; diff --git a/tests/Production/README.md b/tests/Production/README.md index 314d9aa..9442fcd 100644 --- a/tests/Production/README.md +++ b/tests/Production/README.md @@ -1,24 +1,50 @@ -# Alias factory runtime regression +# Production provider regressions -Run `php tests/Production/alias-factory.php /path/to/consumer/vendor/autoload.php` -from the source checkout against an isolated consumer on actual PHP 7.4 and modern -PHP. It verifies exact delegated values, object identity, exception propagation, -legacy container aliases and core handler construction. It does not install PHP -callbacks. Tests are excluded from Composer distribution archives. +Run these entrypoints from the source checkout against a disposable Composer +consumer requiring only `coisa/error-handler`, with no library development +dependencies: -`container.php` also constructs the base, JSON and Whoops Interop provider objects -without requesting their definitions. Their interface contract loads from runtime -metadata on PHP 7.4 without FastForward or Whoops; using their service maps remains an -opt-in PHP 8.3+ application integration. +```sh +php tests/Production/container.php /path/to/consumer/vendor/autoload.php +php tests/Production/alias-factory.php /path/to/consumer/vendor/autoload.php +``` -The consumer must require only `coisa/error-handler`; its runtime metadata must -install `psr/container` transitively. Use Composer 2 with -`--no-plugins --no-scripts` and no library development dependencies. The -[PHP CI workflow](../../.github/workflows/ci.yml) runs this fixture together with -`container.php` and the distribution check in `archive.php`, using current -runtime dependency resolutions on PHP 7.4 and 8.0–8.5 and lowest resolutions on -PHP 7.4 and 8.5. See [contributor checks](../../CONTRIBUTING.md) for local commands -and the distinction between production and development runners. +The library declares PHP `^8.3`. CI tests PHP 8.3–8.5 with the documented +dependency bounds; this is a tested range, not an +artificial 8.5 upper constraint. Resolve with the actual PHP runtime and Composer 2 +using `--no-dev --no-plugins --no-scripts`. The runtime graph supplies FastForward, +Interop and the PSR interfaces. No Nyholm, application logger or other incidental +consumer dependency is needed for these construction/reporting checks. The +production autoloader also exposes JSON, Whoops and the explicit reporting +adapter, and both profile maps can be discovered without constructing optional +Whoops objects or installing callbacks. Actual HTML rendering requires the +application to install Whoops; the PHPUnit and documented recipes exercise that +composition separately. + +`container.php` uses the real Interop provider and FastForward container. It +checks default aliases, cached identities, distinct explicit core scopes and +fresh middleware wrappers, then reports synthetic events through the configured +application reporter. The PSR-17 construction-only sentinels throw if lookup tries +to render, open a file or consume a resource; they are not response implementations. +Temporary surrounding PHP callbacks are restored, and lookup must leave them +unchanged. Nested middleware lifecycle/rendering is covered separately by the +functional tests; fresh wrapper identity alone does not prove a fresh handler. + +`alias-factory.php` retains its historical CI entrypoint name but now tests the +major integration: application-required interface overrides are merged after +library definitions in one real `AggregateServiceProvider`. It verifies exact +reporter/core identities, original-event delivery and failure instead of silent +fallback when required construction fails. FastForward may retry resolution and +normalize failures to PSR11 exceptions; this fixture does not promise a single +construction attempt or unchanged failure identity through that dependency. +The removed package `AliasFactory` and PHP 7.4 repair are not restored or retested. + +The independent `archive.php` check verifies the committed distribution before +consumer installation. Tests are export-ignored and never shipped as runtime +services. The [native CI workflow](../../.github/workflows/ci.yml) owns the actual +PHP/resolution matrix; report its exact candidate and results. These checks prove +installed provider composition, not production transport delivery, server +behavior, arbitrary callback recovery or release/publication authority. The distribution check admits only root metadata, runtime source, useful guides and `resources/skills/coisa-error-handler/`. Every runtime and canonical skill diff --git a/tests/Production/alias-factory.php b/tests/Production/alias-factory.php index 1c83d8c..d19789a 100644 --- a/tests/Production/alias-factory.php +++ b/tests/Production/alias-factory.php @@ -14,94 +14,107 @@ declare(strict_types=1); -use CoiSA\ErrorHandler\Container\ErrorHandlerContainer; -use CoiSA\ErrorHandler\Container\Factory\AliasFactory; use CoiSA\ErrorHandler\ErrorHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; +use CoiSA\ErrorHandler\ErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use Psr\Container\ContainerInterface; +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use Psr\Container\ContainerExceptionInterface; + +use function FastForward\Container\container; + +error_reporting(E_ALL); +set_error_handler( + /** + * Reject runtime diagnostics in the resolved production graph, including deprecations. + * + * @param int $severity the PHP diagnostic severity + * @param string $message the diagnostic message + * @param string $file the originating source file + * @param int $line the originating source line + * + * @return never diagnostics are failures of this standalone verification process + * + * @throws ErrorException for every runtime diagnostic + */ + static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }, +); require $argv[1] ?? dirname(__DIR__, 2) . '/vendor/autoload.php'; -$values = [null, false, true, 0, 42, 0.5, '', 'service value', [], ['service' => true], new stdClass()]; - -foreach ($values as $value) { - $container = new class ($value) implements ContainerInterface { - /** @var mixed The exact value delegated by the synthetic container. */ - private $value; - - /** @param mixed $value the synthetic service value */ - public function __construct($value) - { - $this->value = $value; - } - - /** @return mixed the original value for the expected opaque identifier */ - public function get(string $id) - { - if ('fixture-service' !== $id) { - throw new RuntimeException('AliasFactory changed the delegated service identifier.'); - } - - return $this->value; - } - - /** Report whether this synthetic container owns the identifier. */ - public function has(string $id): bool - { - return 'fixture-service' === $id; - } - }; - - if ((new AliasFactory('fixture-service'))($container) !== $value) { - throw new RuntimeException('AliasFactory must preserve exact service values and object identity.'); - } +$reported = []; +$reporter = new CallableThrowableHandler( + /** + * Record the required application override's exact diagnostic input. + * + * @param Throwable $throwable the original synthetic event + * + * @return void + */ + static function (Throwable $throwable) use (&$reported): void { + $reported[] = $throwable; + }, +); + +$application = new ArrayServiceProvider([ + // @return ThrowableHandlerInterface the application override, not the default empty aggregate + ThrowableHandlerInterface::class => static fn(): ThrowableHandlerInterface => $reporter, +]); +$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$handler = $services->get(ErrorHandlerInterface::class); + +if ( + !$handler instanceof ErrorHandler || $services->get(ErrorHandler::class) !== $handler + || $services->get(ThrowableHandlerInterface::class) !== $reporter +) { + throw new RuntimeException('Merged application definitions must win without replacing service identities.'); } -$failure = new RuntimeException('Synthetic delegate failure.'); -$throwingContainer = new class ($failure) implements ContainerInterface { - /** @var RuntimeException The exact delegate failure. */ - private RuntimeException $failure; - - /** Store the synthetic failure thrown by lookup. */ - public function __construct(RuntimeException $failure) - { - $this->failure = $failure; - } - - /** @return mixed lookup always throws the original synthetic failure */ - public function get(string $id) - { - throw $this->failure; - } - - /** This fixture owns the entry whose construction fails. */ - public function has(string $id): bool - { - return true; - } -}; +$original = new RuntimeException('Synthetic required-override diagnostic.'); +$handler->handleThrowable($original); +if ([$original] !== $reported) { + throw new RuntimeException('The required override must receive the original event once.'); +} -try { - (new AliasFactory('fixture-service'))($throwingContainer); +$failure = new LogicException('Synthetic required reporter construction failure.'); +$attempts = 0; +$failingApplication = new ArrayServiceProvider([ + /* + * Reject required reporter construction through the actual provider graph. + * + * @return ThrowableHandlerInterface this configured factory never returns + * + * @throws LogicException the original synthetic application failure + */ + ThrowableHandlerInterface::class => static function () use ($failure, &$attempts): ThrowableHandlerInterface { + ++$attempts; + + throw $failure; + }, +]); +$failingServices = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $failingApplication)); +$caught = null; - throw new LogicException('AliasFactory must propagate delegate failures.'); -} catch (RuntimeException $exception) { - if ($exception !== $failure) { - throw new LogicException('AliasFactory replaced the original delegate failure.'); - } +try { + $failingServices->get(ErrorHandlerInterface::class); +} catch (Throwable $throwable) { + $caught = $throwable; } -$legacy = new ErrorHandlerContainer(); -$aggregate = $legacy->get(ThrowableHandlerInterface::class); -$handler = $legacy->get(ErrorHandler::class); - -if (!$aggregate instanceof ThrowableHandlerAggregate || !$handler instanceof ErrorHandler) { - throw new RuntimeException('Legacy aliases and the core factory must resolve on the declared runtime.'); +if ($attempts < 1 || null === $caught) { + throw new RuntimeException('Required construction must fail instead of silently yielding a default handler.'); } -if ($legacy->get(ThrowableHandlerInterface::class) !== $aggregate || $legacy->get(ErrorHandler::class) !== $handler) { - throw new RuntimeException('Legacy alias and handler caching must remain unchanged.'); +if ($caught !== $failure && !$caught instanceof ContainerExceptionInterface) { + throw new RuntimeException( + 'FastForward must expose the original or its PSR-11-normalized construction failure.', + 0, + $caught, + ); } -echo 'AliasFactory values, failures and legacy composition passed on PHP ' . PHP_VERSION . PHP_EOL; +echo 'Production provider aliases and required overrides passed on PHP ' . PHP_VERSION . PHP_EOL; diff --git a/tests/Production/archive.php b/tests/Production/archive.php index c8e5eab..abbf1b9 100644 --- a/tests/Production/archive.php +++ b/tests/Production/archive.php @@ -37,11 +37,11 @@ $path = substr($file->getPathname(), strlen($prefix)); - // Runtime code, useful guides and the canonical consumer skill are distribution assets. + // Runtime code, useful guides and the canonical skill are distribution assets. $allowed = in_array($path, ['composer.json', 'LICENSE', 'README.md', 'CHANGELOG.md', 'SECURITY.md'], true) - || 0 === strpos($path, 'src/') - || 0 === strpos($path, 'docs/') - || 0 === strpos($path, 'resources/skills/coisa-error-handler/'); + || str_starts_with($path, 'src/') + || str_starts_with($path, 'docs/') + || str_starts_with($path, 'resources/skills/coisa-error-handler/'); if (!$allowed) { throw new RuntimeException('Unexpected development asset in distribution: ' . $path); diff --git a/tests/Production/container.php b/tests/Production/container.php index 9c44c06..6b8cc89 100644 --- a/tests/Production/container.php +++ b/tests/Production/container.php @@ -14,89 +14,246 @@ declare(strict_types=1); -use CoiSA\ErrorHandler\Container\ConfigProvider; -use CoiSA\ErrorHandler\Container\ErrorHandlerContainer; -use CoiSA\ErrorHandler\Container\Exception\ContainerException; -use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory; use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ReportingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use CoiSA\ErrorHandler\ServiceProvider\JsonErrorHandlerServiceProvider; -use CoiSA\ErrorHandler\ServiceProvider\WhoopsErrorHandlerServiceProvider; -use Interop\Container\ServiceProviderInterface; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\ContainerInterface; -use Psr\Container\NotFoundExceptionInterface; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; + +use function FastForward\Container\container; require $argv[1] ?? dirname(__DIR__, 2) . '/vendor/autoload.php'; -$interopProviders = [ - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider(), - new WhoopsErrorHandlerServiceProvider(), -]; +/** Keep observable reporter state across indirect calls through container services. */ +$reports = new class { + /** @var list Original events in the order they were reported. */ + private array $events = []; -foreach ($interopProviders as $interopProvider) { - if (!$interopProvider instanceof ServiceProviderInterface) { - throw new RuntimeException('Optional Interop providers must load without FastForward or development packages.'); + /** + * Record the exact event passed to the configured application reporter. + * + * @param Throwable $throwable the original synthetic event + */ + public function record(Throwable $throwable): void + { + $this->events[] = $throwable; } -} - -$container = new ErrorHandlerContainer(); -$provider = new ConfigProvider(); -$configuration = $provider(); - -if (!$container instanceof ContainerInterface || $configuration['dependencies'] !== $provider->getDependencies()) { - throw new RuntimeException('The public container and ConfigProvider must load without development dependencies.'); -} - -$aggregate = $container->get(ThrowableHandlerInterface::class); -$handler = $container->get(ErrorHandler::class); -if (!$aggregate instanceof ThrowableHandlerAggregate || !$handler instanceof ErrorHandler) { - throw new RuntimeException('The configured aliases and factories must resolve the core services.'); -} + /** @return bool whether any event has reached the application reporter */ + public function hasReports(): bool + { + return [] !== $this->events; + } -if (!$container->has(ErrorHandler::class) || $container->get(ErrorHandler::class) !== $handler) { - throw new RuntimeException('The container must retain its cached handler.'); -} + /** @return list the complete ordered observations */ + public function all(): array + { + return $this->events; + } +}; -$reported = []; $reporter = new CallableThrowableHandler( - /** Record only the synthetic throwable passed through the public composition APIs. */ - static function (Throwable $throwable) use (&$reported): void { - $reported[] = $throwable; + /** + * Record synthetic reports without a logger, transport or diagnostic output. + * + * @param Throwable $throwable the original diagnostic supplied to each core handler + * + * @return void + */ + static function (Throwable $throwable) use ($reports): void { + $reports->record($throwable); }, ); -$failure = new RuntimeException('Synthetic production-install regression.'); -$core = new ErrorHandler($reporter); -$core->handleThrowable($failure); -$aggregate->attach($reporter); -$handler->handleThrowable($failure); -(new ErrorHandlerFactory())($container)->handleThrowable($failure); - -if ($reported !== [$failure, $failure, $failure]) { - throw new RuntimeException('Core construction and container factories must preserve throwable reporting.'); -} -if ($container->has('missing-production-fixture-service')) { - throw new RuntimeException('The container must reject an unknown service.'); -} +/** Application-owned PSR-17 sentinel that must remain unused during lookup. */ +$messages = new class implements ResponseFactoryInterface, StreamFactoryInterface { + /** + * Reject rendering during a construction-only provider lookup. + * + * @param int $code the unused response status + * @param string $reasonPhrase the unused public reason phrase + * + * @return ResponseInterface no response is constructed by this sentinel + * + * @throws LogicException if service lookup unexpectedly renders a response + */ + public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface + { + throw new LogicException('Provider lookup must not render a response.'); + } + + /** + * Reject stream creation during a construction-only provider lookup. + * + * @param string $content the unused public stream content + * + * @return StreamInterface no stream is constructed by this sentinel + * + * @throws LogicException if service lookup unexpectedly renders content + */ + public function createStream(string $content = ''): StreamInterface + { + throw new LogicException('Provider lookup must not create a stream.'); + } + + /** + * Reject filesystem access during provider construction. + * + * @param string $filename the unused synthetic filename + * @param string $mode the unused file mode + * + * @return StreamInterface no file is opened by this sentinel + * + * @throws LogicException if service lookup unexpectedly opens a stream + */ + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + throw new LogicException('Provider lookup must not open a file.'); + } + + /** + * Reject resource access during provider construction. + * + * @param resource $resource the unused caller-owned stream resource + * + * @return StreamInterface no resource is consumed by this sentinel + * + * @throws LogicException if service lookup unexpectedly consumes a resource + */ + public function createStreamFromResource($resource): StreamInterface + { + throw new LogicException('Provider lookup must not consume a resource.'); + } +}; + +$application = new ArrayServiceProvider([ + // @return CallableThrowableHandler the application's explicitly configured reporter + CallableThrowableHandler::class => static fn(): CallableThrowableHandler => $reporter, + // @return StreamFactoryInterface the construction-only application stream dependency + StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $messages, + // @return ResponseFactoryInterface the construction-only application response dependency + ResponseFactoryInterface::class => static fn(): ResponseFactoryInterface => $messages, +]); + +/** @return bool confirms that the fixture owns the surrounding PHP error callback */ +$previousError = static fn(): bool => true; + +/** + * Fail if unexpected uncaught delivery reaches the surrounding fixture callback. + * + * @param Throwable $throwable the unexpected native failure + * + * @return void + * + * @throws LogicException with that failure as its cause + */ +$previousException = static function (Throwable $throwable): void { + throw new LogicException('Unexpected exception delivery during provider construction.', 0, $throwable); +}; + +set_error_handler($previousError); +set_exception_handler($previousException); try { - $container->get('missing-production-fixture-service'); + $publicClasses = [ + JsonThrowableResponseFactory::class, + ReportingThrowableResponseFactory::class, + WhoopsThrowableResponseFactory::class, + HtmlThrowableResponseFactory::class, + NegotiatingThrowableResponseFactory::class, + ]; - throw new RuntimeException('An unknown service must throw a PSR-11 exception.'); -} catch (NotFoundExceptionInterface $exception) { - if (!$exception instanceof ContainerExceptionInterface) { - throw new RuntimeException('Missing entries must also satisfy the PSR-11 container exception contract.'); + foreach ($publicClasses as $class) { + if (!class_exists($class)) { + throw new RuntimeException('The production autoloader must expose ' . $class); + } + } + + // Discovering representations must not construct a diagnostic page or register callbacks. + (new ErrorHandlerServiceProvider())->getFactories(); + + $services = container(ErrorHandlerServiceProvider::class, $application); + $handler = $services->get(ErrorHandlerInterface::class); + $aggregate = $services->get(ThrowableHandlerInterface::class); + $streams = $services->get(ThrowableStreamFactoryInterface::class); + $responses = $services->get(ThrowableResponseFactoryInterface::class); + + if ( + !$handler instanceof ErrorHandler || !$aggregate instanceof ThrowableHandlerAggregate + || !$streams instanceof GenericThrowableStreamFactory + || !$responses instanceof NegotiatingThrowableResponseFactory + ) { + throw new RuntimeException('The Interop provider must resolve its actual default classes.'); } -} -if (!ContainerException::createFromThrowable($failure) instanceof ContainerExceptionInterface) { - throw new RuntimeException('Construction failures must retain their PSR-11 exception contract.'); + if ( + $services->get(ErrorHandler::class) !== $handler + || $services->get(ThrowableHandlerAggregate::class) !== $aggregate + || $services->get(GenericThrowableStreamFactory::class) !== $streams + || $services->get(NegotiatingThrowableResponseFactory::class) !== $responses + ) { + throw new RuntimeException('Provider aliases must preserve the cached concrete service identities.'); + } + + $handlerFactory = $services->get(ErrorHandlerFactory::class); + $middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); + if ( + !$handlerFactory instanceof ErrorHandlerFactory + || !$middlewareFactory instanceof ErrorHandlerMiddlewareFactory + ) { + throw new RuntimeException('Fresh-scope factory services must be available in the production graph.'); + } + + $first = $handlerFactory($services); + $second = $handlerFactory($services); + if ($first === $handler || $second === $handler || $first === $second) { + throw new RuntimeException('Handler factories must produce scopes distinct from the cached process handler.'); + } + + $cachedMiddleware = $services->get(ErrorHandlerMiddleware::class); + if ( + !$cachedMiddleware instanceof ErrorHandlerMiddleware + || $services->get(ErrorHandlerMiddleware::class) !== $cachedMiddleware + || $middlewareFactory($services) === $middlewareFactory($services) + ) { + throw new RuntimeException('Middleware lookup must cache while explicit factories return fresh wrappers.'); + } + + $activeError = set_error_handler($previousError); + restore_error_handler(); + $activeException = set_exception_handler($previousException); + restore_exception_handler(); + if ($activeError !== $previousError || $activeException !== $previousException || $reports->hasReports()) { + throw new RuntimeException('Service resolution must not register callbacks or invoke reporters.'); + } + + $failure = new RuntimeException('Synthetic production-provider diagnostic.'); + $handler->handleThrowable($failure); + $first->handleThrowable($failure); + $second->handleThrowable($failure); + if ([$failure, $failure, $failure] !== $reports->all()) { + throw new RuntimeException('Cached and explicit fresh handlers must report the identical event once each.'); + } +} finally { + restore_error_handler(); + restore_exception_handler(); } -echo 'Production container passed on PHP ' . PHP_VERSION . PHP_EOL; +echo 'Production Interop provider passed on PHP ' . PHP_VERSION . PHP_EOL; diff --git a/tests/Unit/Container/ConfigProviderTest.php b/tests/Unit/Container/ConfigProviderTest.php deleted file mode 100644 index ce48575..0000000 --- a/tests/Unit/Container/ConfigProviderTest.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\Test\Unit\Container; - -use CoiSA\ErrorHandler\Container\ConfigProvider; -use CoiSA\ErrorHandler\Container\Factory; -use CoiSA\ErrorHandler\ErrorHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use PHPUnit\Framework\TestCase; - -/** - * Verifies the public configuration has consistent usable factories and aliases. - * - * @internal - * - * @covers \CoiSA\ErrorHandler\Container\ConfigProvider - */ -final class ConfigProviderTest extends TestCase -{ - /** Verifies each alias targets a declared service and every factory can be invoked. */ - public function testConfigurationKeepsAliasesAndFactoriesConsistent(): void - { - $provider = new ConfigProvider(); - $dependencies = $provider->getDependencies(); - self::assertSame(['dependencies' => $dependencies], $provider()); - self::assertSame($provider->getAliases(), $dependencies['aliases']); - self::assertSame($provider->getFactories(), $dependencies['factories']); - self::assertSame(ThrowableHandlerAggregate::class, $dependencies['aliases'][ThrowableHandlerInterface::class]); - self::assertSame(Factory\ErrorHandlerFactory::class, $dependencies['factories'][ErrorHandler::class]); - foreach ($dependencies['aliases'] as $target) { - self::assertArrayHasKey($target, $dependencies['factories']); - } - foreach ($dependencies['factories'] as $factory) { - self::assertIsCallable(new $factory()); - } - } -} diff --git a/tests/Unit/Container/ContainerResolutionTest.php b/tests/Unit/Container/ContainerResolutionTest.php deleted file mode 100644 index 27e0233..0000000 --- a/tests/Unit/Container/ContainerResolutionTest.php +++ /dev/null @@ -1,234 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\Test\Unit\Container; - -use CoiSA\ErrorHandler\Container\ErrorHandlerContainer; -use CoiSA\ErrorHandler\Container\Exception\ContainerException; -use CoiSA\ErrorHandler\Container\Exception\NotFoundException; -use CoiSA\ErrorHandler\ErrorHandler; -use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; - -/** - * Verifies parent precedence, local caching, aliases and exact exception chaining. - * - * @internal - * - * @covers \CoiSA\ErrorHandler\Container\ErrorHandlerContainer - * @covers \CoiSA\ErrorHandler\Container\Exception\ContainerException - * @covers \CoiSA\ErrorHandler\Container\Exception\NotFoundException - */ -final class ContainerResolutionTest extends TestCase -{ - /** Verifies a parent-owned value takes precedence over a known library service. */ - public function testParentValueTakesPrecedenceWithoutLocalCaching(): void - { - $first = new \stdClass(); - $second = new \stdClass(); - $parent = $this->createMock(ContainerInterface::class); - $parent->method('has')->with(ErrorHandler::class)->willReturn(true); - $parent->expects(self::exactly(2))->method('get')->with(ErrorHandler::class) - ->willReturnOnConsecutiveCalls($first, $second) - ; - $container = new ErrorHandlerContainer($parent); - self::assertTrue($container->has(ErrorHandler::class)); - self::assertSame($first, $container->get(ErrorHandler::class)); - self::assertSame($second, $container->get(ErrorHandler::class)); - } - - /** Verifies local services are shared, including aliases to the same aggregate. */ - public function testLocalInstancesAndAliasesShareTheSameService(): void - { - $container = new ErrorHandlerContainer(); - self::assertFalse($container->has('missing fixture')); - $aggregate = $container->get(ThrowableHandlerAggregate::class); - self::assertInstanceOf(ThrowableHandlerAggregate::class, $aggregate); - self::assertSame($aggregate, $container->get(ThrowableHandlerAggregate::class)); - self::assertSame($aggregate, $container->get(ThrowableHandlerInterface::class)); - $handler = $container->get(ErrorHandler::class); - self::assertInstanceOf(ErrorHandler::class, $handler); - self::assertSame($handler, $container->get(ErrorHandler::class)); - } - - /** Verifies the unknown identifier survives in the not-found diagnostic. */ - public function testMissingLocalServiceNamesTheRequestedIdentifier(): void - { - $this->expectException(NotFoundException::class); - $this->expectExceptionMessage('Factory for class fixture.missing was not found'); - (new ErrorHandlerContainer())->get('fixture.missing'); - } - - /** Verifies an internal factory failure is wrapped with exact cause, message and code. */ - public function testFactoryFailurePreservesOriginalCause(): void - { - $failure = new \RuntimeException('synthetic dependency failure', 17); - $parent = $this->createMock(ContainerInterface::class); - $parent->method('has')->willReturnCallback( - /** - * Selects the single dependency owned by the synthetic parent. - * - * @param string $id the requested identifier - * - * @return bool whether the fixture supplies this dependency - */ - static fn(string $id): bool => ThrowableHandlerInterface::class === $id, - ); - $parent->method('get')->with(ThrowableHandlerInterface::class)->willThrowException($failure); - - try { - (new ErrorHandlerContainer($parent))->get(ErrorHandler::class); - self::fail('The failed dependency must propagate as a container exception.'); - } catch (ContainerException $caught) { - self::assertSame($failure, $caught->getPrevious()); - self::assertSame('synthetic dependency failure', $caught->getMessage()); - self::assertSame(17, $caught->getCode()); - } - } - - /** Verifies a delegated not-found failure retains its original identity. */ - public function testInternalNotFoundExceptionIsNotRewrapped(): void - { - $failure = new NotFoundException('synthetic missing dependency'); - $parent = $this->createMock(ContainerInterface::class); - $parent->method('has')->willReturnCallback( - /** - * Selects the single dependency owned by the synthetic parent. - * - * @param string $id the requested identifier - * - * @return bool whether the fixture supplies this dependency - */ - static fn(string $id): bool => ThrowableHandlerInterface::class === $id, - ); - $parent->method('get')->with(ThrowableHandlerInterface::class)->willThrowException($failure); - - try { - (new ErrorHandlerContainer($parent))->get(ErrorHandler::class); - self::fail('The dependency not-found exception must propagate.'); - } catch (NotFoundException $caught) { - self::assertSame($failure, $caught); - } - } - - /** - * Verifies both factories preserve integer codes and retain string codes on the cause. - * - * @param int|string $code the original exception code without coercion - * @param int $expectedCode the integer code accepted by the native wrapper - * - * @dataProvider provideExceptionFactoriesPreserveDiagnosticMetadataCases - */ - public function testExceptionFactoriesPreserveDiagnosticMetadata($code, int $expectedCode): void - { - $previous = new \RuntimeException('Earlier synthetic failure'); - $cause = self::createThrowableWithCode($code, $previous); - - foreach ([ContainerException::class, NotFoundException::class] as $class) { - $exception = $class::createFromThrowable($cause); - self::assertInstanceOf($class, $exception); - self::assertSame($cause, $exception->getPrevious()); - self::assertSame('original cause', $exception->getMessage()); - self::assertSame($expectedCode, $exception->getCode()); - self::assertSame($code, $cause->getCode()); - self::assertSame($previous, $cause->getPrevious()); - } - } - - /** - * Supplies native integer codes and string codes that must not be coerced. - * - * @return array original and expected wrapper codes - */ - public static function provideExceptionFactoriesPreserveDiagnosticMetadataCases(): iterable - { - return [ - 'positive integer' => [42, 42], - 'zero integer' => [0, 0], - 'negative integer' => [-17, -17], - 'alphanumeric SQLSTATE' => ['HY000', 0], - 'numeric SQLSTATE' => ['23000', 0], - 'numeric-looking HTTP status' => ['404', 0], - ]; - } - - /** Verifies nested alias and factory failures retain the original SQLSTATE cause. */ - public function testNestedFactoryFailurePreservesStringCodeCause(): void - { - $failure = self::createThrowableWithCode('HY000'); - $parent = $this->createMock(ContainerInterface::class); - $parent->method('has')->willReturnCallback( - /** - * Selects the optional reporter whose construction fails in the parent. - * - * @param string $id the requested identifier - * - * @return bool whether the parent advertises the reporter - */ - static fn(string $id): bool => CallableThrowableHandler::class === $id, - ); - $parent->expects(self::once())->method('get')->with(CallableThrowableHandler::class) - ->willThrowException($failure) - ; - - try { - (new ErrorHandlerContainer($parent))->get(ErrorHandler::class); - self::fail('The failed reporter must propagate through the container cause chain.'); - } catch (ContainerException $caught) { - $aliasFailure = $caught->getPrevious(); - self::assertInstanceOf(ContainerException::class, $aliasFailure); - $aggregateFailure = $aliasFailure->getPrevious(); - self::assertInstanceOf(ContainerException::class, $aggregateFailure); - self::assertSame($failure, $aggregateFailure->getPrevious()); - - foreach ([$caught, $aliasFailure, $aggregateFailure] as $wrapper) { - self::assertSame('original cause', $wrapper->getMessage()); - self::assertSame(0, $wrapper->getCode()); - } - - self::assertSame('HY000', $failure->getCode()); - } - } - - /** - * Creates a driver-free native exception with the code type supplied by a PDO driver. - * - * @param int|string $code the original code, including string SQLSTATE values - * @param null|\Throwable $previous an optional earlier failure preserved by the fixture - * - * @return \PDOException an exception carrying the original diagnostic metadata - */ - private static function createThrowableWithCode($code, ?\Throwable $previous = null): \PDOException - { - return new class ($code, $previous) extends \PDOException { - /** - * Initializes metadata without requiring a database connection or driver. - * - * @param int|string $code the code a PDO driver could provide - * @param null|\Throwable $previous the original earlier failure - */ - public function __construct($code, ?\Throwable $previous) - { - parent::__construct('original cause', 0, $previous); - - $this->code = $code; - } - }; - } -} diff --git a/tests/Unit/Container/ServiceFactoriesTest.php b/tests/Unit/Container/ServiceFactoriesTest.php deleted file mode 100644 index e2eba16..0000000 --- a/tests/Unit/Container/ServiceFactoriesTest.php +++ /dev/null @@ -1,282 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\Test\Unit\Container; - -use CoiSA\ErrorHandler\Container\Factory; -use CoiSA\ErrorHandler\ErrorHandler; -use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler; -use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler; -use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; -use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; -use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; -use Nyholm\Psr7\Factory\Psr17Factory; -use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Server\RequestHandlerInterface; -use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent; - -/** - * Verifies injected collaborators are used through public factory results. - * - * @internal - * - * @covers \CoiSA\ErrorHandler\Container\Factory\AliasFactory - * @covers \CoiSA\ErrorHandler\Container\Factory\DispatchErrorEventThrowableHandlerFactory - * @covers \CoiSA\ErrorHandler\Container\Factory\DispatchThrowableHandlerFactory - * @covers \CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory - * @covers \CoiSA\ErrorHandler\Container\Factory\ErrorHandlerMiddlewareFactory - * @covers \CoiSA\ErrorHandler\Container\Factory\ThrowableHandlerAggregateFactory - * @covers \CoiSA\ErrorHandler\Container\Factory\ThrowableResponseFactoryFactory - * @covers \CoiSA\ErrorHandler\Container\Factory\ThrowableStreamFactoryFactory - */ -final class ServiceFactoriesTest extends TestCase -{ - /** - * Verifies alias delegation preserves every PSR-11 value, including null and scalars. - * - * @param mixed $value the exact parent-owned value - * - * @dataProvider provideAliasReturnsTheExactValueCases - */ - public function testAliasReturnsTheExactValue($value): void - { - $container = $this->createMock(ContainerInterface::class); - $container->expects(self::once())->method('get')->with('fixture.alias')->willReturn($value); - self::assertSame($value, (new Factory\AliasFactory('fixture.alias'))($container)); - } - - /** - * Supplies identity-sensitive and false-like values accepted by PSR-11. - * - * @return array representative legal PSR-11 values - */ - public static function provideAliasReturnsTheExactValueCases(): iterable - { - return ['null' => [null], 'false' => [false], 'zero' => [0], 'text' => ['value'], - 'array' => [['key' => 'value']], 'object' => [new \stdClass()]]; - } - - /** Verifies the alias does not wrap or replace a container failure. */ - public function testAliasPreservesContainerFailure(): void - { - $failure = new \RuntimeException('lookup'); - $container = $this->createMock(ContainerInterface::class); - $container->method('get')->willThrowException($failure); - - try { - (new Factory\AliasFactory('fixture.alias'))($container); - self::fail('Lookup failure must propagate.'); - } catch (\RuntimeException $caught) { - self::assertSame($failure, $caught); - } - } - - /** Verifies both dispatch factories inject the selected dispatcher without discovery. */ - public function testDispatchFactoriesUseTheExactDispatcher(): void - { - $original = new \Error('incoming'); - $dispatcher = $this->createMock(EventDispatcherInterface::class); - $events = []; - $dispatcher->expects(self::exactly(2))->method('dispatch')->willReturnCallback( - /** - * Records the exact object supplied by each constructed reporter. - * - * @param object $event the dispatched object - * - * @return object the same dispatched object - */ - static function (object $event) use (&$events): object { - $events[] = $event; - - return $event; - }, - ); - $container = $this->createMock(ContainerInterface::class); - $container->method('get')->with(EventDispatcherInterface::class)->willReturn($dispatcher); - (new Factory\DispatchThrowableHandlerFactory())($container)->handleThrowable($original); - (new Factory\DispatchErrorEventThrowableHandlerFactory())($container)->handleThrowable($original); - self::assertSame($original, $events[0]); - self::assertInstanceOf(ErrorEvent::class, $events[1]); - self::assertSame($original, $events[1]->getThrowable()); - } - - /** Verifies optional handlers are selected and constructor resolution installs no callbacks. */ - public function testErrorHandlerFactoryUsesExplicitOptionalHandlers(): void - { - $reporter = $this->createMock(ThrowableHandlerInterface::class); - $reporter->expects(self::never())->method('handleThrowable'); - $php = $this->createMock(PhpErrorHandlerInterface::class); - $php->expects(self::once())->method('handlePhpError')->with(E_NOTICE, 'event', '/fixture.php', 8); - $shutdown = $this->createMock(ShutdownHandlerInterface::class); - $shutdown->expects(self::never())->method('handleShutdown'); - $container = $this->createMock(ContainerInterface::class); - $container->method('has')->willReturn(true); - $container->method('get')->willReturnMap([ - [ThrowableHandlerInterface::class, $reporter], [PhpErrorHandlerInterface::class, $php], - [ShutdownHandlerInterface::class, $shutdown], - ]); - $handler = (new Factory\ErrorHandlerFactory())($container); - $handler->handlePhpError(E_NOTICE, 'event', '/fixture.php', 8); - $handler->handleShutdown(); - } - - /** Verifies absent optional adapters preserve the established PHP-error conversion default. */ - public function testErrorHandlerFactoryKeepsDefaultAdapters(): void - { - $reporter = $this->createMock(ThrowableHandlerInterface::class); - $reporter->expects(self::once())->method('handleThrowable')->with(self::callback( - /** - * Checks metadata from the real default PHP-error adapter. - * - * @param \Throwable $event the converted warning - * - * @return bool whether the adapter retained the supplied metadata - */ - static fn(\Throwable $event): bool => $event instanceof \ErrorException - && E_USER_WARNING === $event->getSeverity() && 'fixture warning' === $event->getMessage(), - )); - $container = $this->createMock(ContainerInterface::class); - $container->method('has')->willReturn(false); - $container->method('get')->with(ThrowableHandlerInterface::class)->willReturn($reporter); - $mask = error_reporting(E_ALL); - - try { - $handler = (new Factory\ErrorHandlerFactory())($container); - $handler->handlePhpError(E_USER_WARNING, 'fixture warning', __FILE__, 4); - } finally { - error_reporting($mask); - } - } - - /** Verifies configured aggregation orders optional handlers and preserves the original event. */ - public function testAggregateFactoryComposesAvailableHandlersInOrder(): void - { - $original = new \RuntimeException('original'); - $seen = []; - $callable = new CallableThrowableHandler( - /** - * Records the first explicitly configured reporter. - * - * @param \Throwable $event the original event - */ - static function (\Throwable $event) use (&$seen): void { - $seen[] = ['callable', $event]; - }, - ); - $dispatcher = $this->createMock(EventDispatcherInterface::class); - $dispatcher->method('dispatch')->willReturnCallback( - /** - * Records raw and wrapped dispatch in the configured order. - * - * @param object $event the dispatched object - * - * @return object the same dispatched object - */ - static function (object $event) use (&$seen): object { - $seen[] = ['dispatch', $event]; - - return $event; - }, - ); - $container = $this->createMock(ContainerInterface::class); - $container->method('has')->willReturn(true); - $container->method('get')->willReturnMap([ - [CallableThrowableHandler::class, $callable], - [DispatchThrowableHandler::class, new DispatchThrowableHandler($dispatcher)], - [DispatchErrorEventThrowableHandler::class, new DispatchErrorEventThrowableHandler($dispatcher)], - ]); - (new Factory\ThrowableHandlerAggregateFactory())($container)->handleThrowable($original); - self::assertSame(['callable', 'dispatch', 'dispatch'], array_column($seen, 0)); - self::assertSame($original, $seen[0][1]); - self::assertSame($original, $seen[1][1]); - self::assertInstanceOf(ErrorEvent::class, $seen[2][1]); - self::assertSame($original, $seen[2][1]->getThrowable()); - } - - /** Verifies unavailable handlers are never resolved and an empty aggregate returns normally. */ - public function testAggregateFactoryDoesNotResolveMissingHandlers(): void - { - $container = $this->createMock(ContainerInterface::class); - $container->method('has')->willReturn(false); - $container->expects(self::never())->method('get'); - (new Factory\ThrowableHandlerAggregateFactory())($container)->handleThrowable(new \Error('ignored')); - } - - /** Verifies stream and response factory results use the supplied PSR collaborators. */ - public function testMessageFactoriesDelegateOriginalFailureAndBody(): void - { - $messages = new Psr17Factory(); - $original = new \RuntimeException('incoming', 404); - $reporter = $this->createMock(ThrowableHandlerInterface::class); - $reporter->expects(self::once())->method('handleThrowable')->with(self::identicalTo($original)) - ->willReturnCallback( - /** - * Emits the public fixture body. - * - * @param \Throwable $event the supplied reporting event - */ - static function (\Throwable $event): void { - echo 'captured'; - }, - ) - ; - $container = $this->createMock(ContainerInterface::class); - $container->method('get')->willReturnMap([ - [StreamFactoryInterface::class, $messages], [ThrowableHandlerInterface::class, $reporter], - ]); - $streams = (new Factory\ThrowableStreamFactoryFactory())($container); - self::assertSame('captured', (string) $streams->createStreamFromThrowable($original)); - $body = $messages->createStream('delegated'); - $throwableStreams = $this->createMock(ThrowableStreamFactoryInterface::class); - $throwableStreams->expects(self::once())->method('createStreamFromThrowable') - ->with(self::identicalTo($original))->willReturn($body) - ; - $responses = $this->createMock(ContainerInterface::class); - $responses->method('get')->willReturnMap([ - [ThrowableStreamFactoryInterface::class, $throwableStreams], [ResponseFactoryInterface::class, $messages], - ]); - $response = (new Factory\ThrowableResponseFactoryFactory())($responses)->createResponseFromThrowable($original); - self::assertSame($body, $response->getBody()); - self::assertSame(404, $response->getStatusCode()); - } - - /** Verifies the middleware factory returns a usable scope around the supplied request handler. */ - public function testMiddlewareFactoryReturnsTheDownstreamResponse(): void - { - $reporter = $this->createMock(ThrowableHandlerInterface::class); - $reporter->expects(self::never())->method('handleThrowable'); - $responses = $this->createMock(ThrowableResponseFactoryInterface::class); - $responses->expects(self::never())->method('createResponseFromThrowable'); - $container = $this->createMock(ContainerInterface::class); - $container->method('get')->willReturnMap([ - [ErrorHandler::class, new ErrorHandler($reporter)], [ThrowableResponseFactoryInterface::class, $responses], - ]); - $messages = new Psr17Factory(); - $request = $messages->createServerRequest('GET', 'http://localhost'); - $response = $messages->createResponse(204); - $next = $this->createMock(RequestHandlerInterface::class); - $next->expects(self::once())->method('handle')->with(self::identicalTo($request))->willReturn($response); - $middleware = (new Factory\ErrorHandlerMiddlewareFactory())($container); - self::assertSame($response, $middleware->process($request, $next)); - } -} diff --git a/tests/Unit/ErrorHandlerConstructorTest.php b/tests/Unit/ErrorHandlerConstructorTest.php new file mode 100644 index 0000000..d4a8be3 --- /dev/null +++ b/tests/Unit/ErrorHandlerConstructorTest.php @@ -0,0 +1,394 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Unit; + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Exception\ErrorException; +use CoiSA\ErrorHandler\Handler\CallablePhpErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\PhpLastErrorShutdownHandler; +use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowErrorExceptionPhpErrorHandler; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; + +/** + * Verifies constructor normalization without changing reporting or lifecycle policy. + * + * @internal + */ +#[CoversClass(ErrorHandler::class)] +#[UsesClass(CallableThrowableHandler::class)] +#[UsesClass(CallablePhpErrorHandler::class)] +#[UsesClass(CallableShutdownHandler::class)] +#[UsesClass(ErrorException::class)] +#[UsesClass(ThrowErrorExceptionPhpErrorHandler::class)] +#[UsesClass(PhpLastErrorShutdownHandler::class)] +final class ErrorHandlerConstructorTest extends TestCase +{ + /** + * Normalizes three closures or callable arrays without executing them early. + * + * @param bool $arrays whether each callback is supplied as an object/method pair + */ + #[DataProvider('provideThreeClosuresAreInertAndKeepTheirDelegationContractsCases')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testThreeClosuresAreInertAndKeepTheirDelegationContracts(bool $arrays): void + { + $events = []; + $before = self::callbacks(); + + /** + * Records the original event and returns a value unrelated to diagnostic delivery. + * + * @param \Throwable $event the original event supplied to the reporter + * + * @return string a callback result the void adapter must ignore + */ + $reporter = static function (\Throwable $event) use (&$events): string { + $events[] = ['throwable', $event]; + + return 'ignored'; + }; + + /** + * Retains the exact metadata without adding native false/fall-through semantics. + * + * @param int $code the unmodified severity + * @param string $message the unmodified error message + * @param string $filename the unmodified source filename + * @param int $line the unmodified source line + * + * @return false a callback result that the void adapter must ignore + */ + $phpErrors = static function (int $code, string $message, string $filename, int $line) use (&$events): false { + $events[] = ['php', $code, $message, $filename, $line]; + + return false; + }; + + /** @return int a callback result unrelated to the shutdown interface */ + $shutdown = static function () use (&$events): int { + $events[] = ['shutdown']; + + return 7; + }; + $handler = new ErrorHandler( + $arrays ? [$reporter, '__invoke'] : $reporter, + $arrays ? [$phpErrors, '__invoke'] : $phpErrors, + $arrays ? [$shutdown, '__invoke'] : $shutdown, + ); + self::assertSame([], $events); + self::assertSame($before, self::callbacks()); + $handler->handleShutdown(); + self::assertSame([], $events); + + $original = new \RuntimeException('Original constructor fixture'); + $handler->handleThrowable($original); + $handler->handlePhpError(E_NOTICE, 'Exact metadata', '/synthetic.php', 23); + $handler->register(); + + try { + $handler->handleShutdown(); + } finally { + $handler->unregister(); + } + + self::assertSame([ + ['throwable', $original], + ['php', E_NOTICE, 'Exact metadata', '/synthetic.php', 23], + ['shutdown'], + ], $events); + self::assertSame($before, self::callbacks()); + } + + /** @return array native callable forms for all three inputs */ + public static function provideThreeClosuresAreInertAndKeepTheirDelegationContractsCases(): iterable + { + return ['closures' => [false], 'object and method pairs' => [true]]; + } + + /** + * Omitted and explicitly null options retain the same default suppression and conversion policy. + * + * @param bool $explicitNulls whether to supply the two nullable defaults explicitly + */ + #[DataProvider('provideNullableDefaultsKeepTheExistingErrorPolicyCases')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testNullableDefaultsKeepTheExistingErrorPolicy(bool $explicitNulls): void + { + $delegate = $this->createMock(ThrowableHandlerInterface::class); + $delegate->expects(self::once())->method('handleThrowable')->with(self::callback( + /** + * Checks enabled conversion while rejecting suppression or shutdown deliveries. + * + * @param mixed $event the reported diagnostic + * + * @return bool whether all original PHP metadata was preserved + */ + static fn(mixed $event): bool => $event instanceof ErrorException + && 'Enabled default' === $event->getMessage() + && E_USER_WARNING === $event->getSeverity() + && '/defaults.php' === $event->getFile() + && 13 === $event->getLine(), + )); + $reporter = $delegate->handleThrowable(...); + $handler = $explicitNulls ? new ErrorHandler($reporter, null, null) : new ErrorHandler($reporter); + $mask = error_reporting(E_ALL); + + try { + $handler->handlePhpError(E_USER_WARNING, 'Enabled default', '/defaults.php', 13); + error_reporting(0); + $handler->handlePhpError(E_USER_WARNING, 'Suppressed default', '/defaults.php', 14); + $handler->handleShutdown(); + } finally { + error_reporting($mask); + } + } + + /** @return array omission and explicit null for the optional inputs */ + public static function provideNullableDefaultsKeepTheExistingErrorPolicyCases(): iterable + { + return ['omitted' => [false], 'explicit null' => [true]]; + } + + /** Supplied interfaces win over __invoke and their original mutable identity is retained. */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testInvokableInterfacesKeepTheirInstancesAndInterfaceMethods(): void + { + $delegate = new class implements ThrowableHandlerInterface, PhpErrorHandlerInterface, ShutdownHandlerInterface { + /** @var list> observations on the exact application instance */ + public array $events = []; + + /** @param \Throwable $throwable the unchanged explicitly reported event */ + public function handleThrowable(\Throwable $throwable): void + { + $this->events[] = ['throwable', $throwable]; + } + + /** + * Receives the exact PHP-error metadata through its interface method. + * + * @param int $code the selected severity + * @param string $message the selected error message + * @param string $filename the selected source filename + * @param int $line the selected source line + */ + public function handlePhpError(int $code, string $message, string $filename, int $line): void + { + $this->events[] = ['php', $code, $message, $filename, $line]; + } + + /** Records an explicit shutdown notification on the same instance. */ + public function handleShutdown(): void + { + $this->events[] = ['shutdown']; + } + + /** @param mixed ...$arguments inputs that must never take precedence over the interfaces */ + public function __invoke(mixed ...$arguments): void + { + $this->events[] = ['invoked', $arguments]; + } + }; + $handler = new ErrorHandler($delegate, $delegate, $delegate); + self::assertSame([], $delegate->events); + $original = new \RuntimeException('Interface identity'); + $handler->handleThrowable($original); + $handler->handlePhpError(E_WARNING, 'Interface policy', '/interface.php', 41); + $handler->register(); + + try { + $handler->handleShutdown(); + } finally { + $handler->unregister(); + } + + self::assertSame([ + ['throwable', $original], + ['php', E_WARNING, 'Interface policy', '/interface.php', 41], + ['shutdown'], + ], $delegate->events); + } + + /** Callable reporters retain original identity and propagate the identical failure without retry. */ + public function testCallableReporterFailurePropagatesUnchanged(): void + { + $original = new \RuntimeException('Original failure'); + $failure = new \LogicException('Reporter failure'); + $received = []; + $handler = new ErrorHandler( + /** @throws \LogicException the exact private reporter failure */ + static function (\Throwable $event) use (&$received, $failure): void { + $received[] = $event; + + throw $failure; + }, + ); + + try { + $handler->handleThrowable($original); + self::fail('Expected the reporter failure.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + + self::assertSame([$original], $received); + } + + /** A valid function name is accepted without invoking it to preflight its incompatible signature. */ + public function testCallbackSignatureErrorsAriseOnlyWhenTheCallbackIsInvoked(): void + { + // @phpstan-ignore argument.type (Deliberately defer the callable's invalid input contract to PHP.) + $handler = new ErrorHandler('strlen'); + + $this->expectException(\TypeError::class); + $handler->handleThrowable(new \RuntimeException('Not a string callback argument')); + } + + /** PHP-error callables preserve the existing ErrorException-routing versus other-failure policy. */ + public function testCallablePhpFailuresPreserveConversionAndFailureIdentity(): void + { + $converted = new \ErrorException('Converted error', 0, E_WARNING); + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::once())->method('handleThrowable')->with(self::identicalTo($converted)); + $handler = new ErrorHandler( + $reporter, + /** @throws \ErrorException the application-converted error */ + static function () use ($converted): never { + throw $converted; + }, + ); + $handler->handlePhpError(E_WARNING, 'Original metadata', '/failure.php', 17); + $failure = new \LogicException('PHP callback failed'); + $handler = new ErrorHandler( + $reporter, + /** @throws \LogicException an unrelated failure that must not be reported again */ + static function () use ($failure): never { + throw $failure; + }, + ); + + try { + $handler->handlePhpError(E_WARNING, 'Original metadata', '/failure.php', 17); + self::fail('Expected the unrelated PHP callback failure.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + } + + /** A callable shutdown failure reaches the reporter once with its original identity. */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testCallableShutdownFailureIsReportedUnchanged(): void + { + $failure = new \LogicException('Shutdown callback failed'); + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::once())->method('handleThrowable')->with(self::identicalTo($failure)); + $handler = new ErrorHandler( + $reporter, + null, + /** @throws \LogicException the original callback failure to report */ + static function () use ($failure): never { + throw $failure; + }, + ); + $handler->handleShutdown(); + $handler->register(); + + try { + $handler->handleShutdown(); + } finally { + $handler->unregister(); + } + } + + /** + * Native validation rejects unsupported inputs before normalization or execution. + * + * @param int $position the constructor argument to violate deliberately + * @param mixed $input an invalid native argument for that position + */ + #[DataProvider('provideInvalidInputsAreRejectedCases')] + public function testInvalidInputsAreRejected(int $position, mixed $input): void + { + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::never())->method('handleThrowable'); + $this->expectException(\TypeError::class); + + if (0 === $position) { + // @phpstan-ignore argument.type (The test deliberately violates the public native parameter.) + $handler = new ErrorHandler($input); + } elseif (1 === $position) { + // @phpstan-ignore argument.type (The test deliberately violates the public native parameter.) + $handler = new ErrorHandler($reporter, $input); + } else { + // @phpstan-ignore argument.type (The test deliberately violates the public native parameter.) + $handler = new ErrorHandler($reporter, null, $input); + } + + self::fail('Unexpectedly constructed ' . $handler::class); + } + + /** @return iterable invalid native inputs for each constructor position */ + public static function provideInvalidInputsAreRejectedCases(): iterable + { + $inputs = [ + 'false' => false, + 'integer' => 1, + 'unknown function' => 'missing_error_handler_callback', + 'object' => new \stdClass(), + ]; + foreach (['reporter', 'PHP errors', 'shutdown'] as $position => $label) { + foreach ($inputs as $name => $input) { + yield $label . ' ' . $name => [$position, $input]; + } + } + + yield 'required reporter null' => [0, null]; + } + + /** + * Snapshots callback identity while restoring the temporary native probes immediately. + * + * @return array{null|callable, null|callable} the surrounding error and exception callbacks + */ + private static function callbacks(): array + { + /** @return bool leave native diagnostic handling available to the caller */ + $error = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $exception = set_exception_handler( + /** @param \Throwable $error the unused temporary probe input */ + static function (\Throwable $error): void { + }, + ); + restore_exception_handler(); + + return [$error, $exception]; + } +} diff --git a/tests/Unit/ErrorHandlerTest.php b/tests/Unit/ErrorHandlerTest.php index 6ca7a2e..ee606b9 100644 --- a/tests/Unit/ErrorHandlerTest.php +++ b/tests/Unit/ErrorHandlerTest.php @@ -22,18 +22,20 @@ use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use PHPUnit\Framework\TestCase; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; /** * Tests direct delegation and the real PHP callback stack in disposable PHPUnit processes. * * @internal - * - * @covers \CoiSA\ErrorHandler\ErrorHandler - * - * @runTestsInSeparateProcesses - * - * @preserveGlobalState disabled */ +#[CoversClass(ErrorHandler::class)] +#[RunTestsInSeparateProcesses] +#[PreserveGlobalState(false)] final class ErrorHandlerTest extends TestCase { /** Verifies registration is explicit, idempotent and restores both previous callbacks. */ @@ -55,34 +57,321 @@ public function testRegistrationIsExplicitIdempotentAndRestoresPreviousCallbacks set_error_handler($previousError); set_exception_handler($previousException); $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::never())->method('handleThrowable'); $shutdown = $this->createMock(ShutdownHandlerInterface::class); $shutdown->expects(self::never())->method('handleShutdown'); $handler = new ErrorHandler($reporter, null, $shutdown); try { - self::assertSame($previousError, set_error_handler($previousError)); - restore_error_handler(); - self::assertSame($previousException, set_exception_handler($previousException)); - restore_exception_handler(); + self::assertSame([$previousError, $previousException], self::callbacks()); $handler->unregister(); $handler->register(); + [$errorCallback, $exceptionCallback] = self::callbacks(); + self::assertInstanceOf(\Closure::class, $errorCallback); + self::assertInstanceOf(\Closure::class, $exceptionCallback); + self::assertNotSame($previousError, $errorCallback); + self::assertNotSame($previousException, $exceptionCallback); $handler->register(); - self::assertSame([$handler, 'handlePhpError'], set_error_handler($previousError)); - restore_error_handler(); - self::assertSame([$handler, 'handleThrowable'], set_exception_handler($previousException)); - restore_exception_handler(); + self::assertSame([$errorCallback, $exceptionCallback], self::callbacks()); $handler->unregister(); $handler->unregister(); - self::assertSame($previousError, set_error_handler($previousError)); + self::assertSame([$previousError, $previousException], self::callbacks()); + $handler->register(); + self::assertSame([$errorCallback, $exceptionCallback], self::callbacks()); + $handler->unregister(); + $handler->handleShutdown(); + } finally { + $handler->unregister(); restore_error_handler(); - self::assertSame($previousException, set_exception_handler($previousException)); restore_exception_handler(); + } + } + + /** Verifies an out-of-order unregister leaves the inner scope active until proper cleanup. */ + public function testUnregisterRequiresReverseRegistrationOrder(): void + { + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::never())->method('handleThrowable'); + $outer = new ErrorHandler($reporter); + $inner = new ErrorHandler($reporter); + $before = self::callbacks(); + $outer->register(); + $inner->register(); + + try { + $activeInner = self::callbacks(); + + try { + $outer->unregister(); + self::fail('An outer scope must not remove the active inner callbacks.'); + } catch (\LogicException) { + self::assertSame($activeInner, self::callbacks()); + } + } finally { + $inner->unregister(); + $outer->unregister(); + } + + self::assertSame($before, self::callbacks()); + } + + /** + * Reject partial ownership loss without changing callback identities or their native masks. + * + * @param bool $replaceError whether the foreign integration replaces only the error callback + */ + #[DataProvider('providePartialCallbackReplacementPreservesOwnershipAndMasksCases')] + #[WithoutErrorHandler] + public function testPartialCallbackReplacementPreservesOwnershipAndMasks(bool $replaceError): void + { + $before = self::callbacks(); + $mask = error_reporting(E_ALL & ~E_USER_WARNING); + $previousEvents = []; + $foreignEvents = []; + $ownedEvents = []; + set_error_handler( + /** + * Observe delivery through the original callback's restricted mask. + * + * @param int $code the delivered PHP severity + * + * @return bool suppress the native diagnostic after recording it + */ + static function (int $code) use (&$previousEvents): bool { + $previousEvents[] = $code; + + return true; + }, + E_USER_NOTICE, + ); + set_exception_handler( + /** @param \Throwable $error an unused input on the original exception callback */ + static function (\Throwable $error): void { + }, + ); + $previous = self::callbacks(); + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::never())->method('handleThrowable'); + $shutdown = $this->createMock(ShutdownHandlerInterface::class); + $shutdown->expects(self::never())->method('handleShutdown'); + $handler = new ErrorHandler( + $reporter, + /** @param int $code the severity delivered through the handler's unrestricted native mask */ + static function (int $code) use (&$ownedEvents): void { + $ownedEvents[] = $code; + }, + $shutdown, + ); + $handler->register(); + + if ($replaceError) { + set_error_handler( + /** + * Record the foreign callback without accepting severities outside its own mask. + * + * @param int $code the delivered PHP severity + * + * @return bool suppress the native diagnostic after recording it + */ + static function (int $code) use (&$foreignEvents): bool { + $foreignEvents[] = $code; + + return true; + }, + E_USER_NOTICE, + ); + } else { + set_exception_handler( + /** @param \Throwable $error an unused input on the foreign exception callback */ + static function (\Throwable $error): void { + }, + ); + } + + try { + try { + $foreign = self::callbacks(); + + try { + $handler->unregister(); + self::fail('Losing either callback must prevent unregistering the active scope.'); + } catch (\LogicException) { + self::assertSame($foreign, self::callbacks()); + } + $handler->handleShutdown(); + self::assertSame($foreign, self::callbacks()); + self::assertSame(E_ALL & ~E_USER_WARNING, error_reporting()); + trigger_error('Observe current notice mask', E_USER_NOTICE); + trigger_error('Observe current warning mask', E_USER_WARNING); + self::assertSame($replaceError ? [E_USER_NOTICE] : [], $foreignEvents); + self::assertSame($replaceError ? [] : [E_USER_NOTICE, E_USER_WARNING], $ownedEvents); + self::assertSame([], $previousEvents); + } finally { + if ($replaceError) { + restore_error_handler(); + } else { + restore_exception_handler(); + } + $handler->unregister(); + } + + self::assertSame($previous, self::callbacks()); + trigger_error('Observe restored notice mask', E_USER_NOTICE); + trigger_error('Observe restored warning mask', E_USER_WARNING); + self::assertSame([E_USER_NOTICE], $previousEvents); + } finally { + restore_error_handler(); + restore_exception_handler(); + error_reporting($mask); + } + + self::assertSame($before, self::callbacks()); + } + + /** @return array independent replacement of each native callback */ + public static function providePartialCallbackReplacementPreservesOwnershipAndMasksCases(): iterable + { + return ['only error callback' => [true], 'only exception callback' => [false]]; + } + + /** Verifies a poisoned registration skips shutdown and resets only after a completed scope. */ + public function testFailedRegistrationSkipsShutdownAndCanStartANewScope(): void + { + $first = new \Error('first delivery'); + $retry = new \RuntimeException('suppressed retry'); + $afterReset = new \RuntimeException('new scope'); + $failure = new \LogicException('reporter failed'); + $received = []; + $reporter = new CallableThrowableHandler( + /** + * Records original deliveries and fails only for the first registration. + * + * @param \Throwable $error the event delivered by the handler + * + * @throws \LogicException the first reporter failure + */ + static function (\Throwable $error) use (&$received, $failure): void { + $received[] = $error; + if (1 === \count($received)) { + throw $failure; + } + }, + ); + $shutdown = $this->createMock(ShutdownHandlerInterface::class); + $shutdown->expects(self::never())->method('handleShutdown'); + $handler = new ErrorHandler($reporter, null, $shutdown); + $handler->register(); + + try { + try { + $handler->handleThrowable($first); + self::fail('The first reporter failure must propagate unchanged.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + + $handler->register(); + + try { + $handler->handleThrowable($retry); + self::fail('Idempotent register must not retry a failed reporter.'); + } catch (\RuntimeException $caught) { + self::assertSame($retry, $caught); + } $handler->handleShutdown(); + self::assertSame([$first], $received); + + $handler->unregister(); + $handler->register(); + $handler->handleThrowable($afterReset); + self::assertSame([$first, $afterReset], $received); } finally { $handler->unregister(); - restore_error_handler(); + } + } + + /** Verifies recursion is rejected before nested delivery and the direct-call guard is released. */ + public function testRecursiveDeliveryIsRejectedWithoutPoisoningAnIndependentCall(): void + { + $nested = new \RuntimeException('nested delivery'); + $first = new \Error('outer delivery'); + $independent = new \RuntimeException('independent delivery'); + $received = []; + $scope = new class { + /** @var null|ErrorHandler The handler linked after its reporter is constructed. */ + public ?ErrorHandler $handler = null; + }; + $reporter = new CallableThrowableHandler( + /** + * Attempts one recursive delivery through the same configured handler. + * + * @param \Throwable $error the original event received by this reporter + * + * @throws \LogicException when the handler rejects recursive delivery + */ + static function (\Throwable $error) use (&$received, $scope, $nested): void { + $received[] = $error; + if (1 === \count($received)) { + $handler = $scope->handler; + if (!$handler instanceof ErrorHandler) { + throw new \LogicException('The recursive fixture must be configured before delivery.'); + } + $handler->handleThrowable($nested); + } + }, + ); + $handler = new ErrorHandler($reporter); + $scope->handler = $handler; + + try { + $handler->handleThrowable($first); + self::fail('A recursive delivery must be rejected.'); + } catch (\LogicException $caught) { + self::assertSame($nested, $caught->getPrevious()); + } + + self::assertSame([$first], $received); + $handler->handleThrowable($independent); + self::assertSame([$first, $independent], $received); + } + + /** Verifies the installed uncaught callback preserves reporter failure and restores its scope. */ + public function testInstalledUncaughtCallbackPropagatesReporterFailureOnce(): void + { + $original = new \Error('uncaught input'); + $failure = new \RuntimeException('uncaught reporter failed'); + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::once())->method('handleThrowable')->with(self::identicalTo($original)) + ->willThrowException($failure) + ; + $handler = new ErrorHandler($reporter); + $before = self::callbacks(); + $handler->register(); + + try { + $callback = set_exception_handler( + /** + * Temporarily exposes the installed callback without dispatching a native failure. + * + * @param \Throwable $error the unused probe input + */ + static function (\Throwable $error): void { + }, + ); restore_exception_handler(); + self::assertInstanceOf(\Closure::class, $callback); + + try { + $callback($original); + self::fail('The installed callback must propagate its reporter failure.'); + } catch (\RuntimeException $caught) { + self::assertSame($failure, $caught); + } + } finally { + $handler->unregister(); } + + self::assertSame($before, self::callbacks()); } /** Verifies direct reporting retains the supplied event and propagates reporter failures once. */ @@ -118,7 +407,7 @@ public function testPhpAdapterErrorExceptionIsReportedWithOriginalIdentity(): vo { $failure = new \ErrorException('adapter', 0, E_WARNING); $adapter = $this->createMock(PhpErrorHandlerInterface::class); - $adapter->method('handlePhpError')->willThrowException($failure); + $adapter->expects(self::once())->method('handlePhpError')->willThrowException($failure); $reporter = $this->createMock(ThrowableHandlerInterface::class); $reporter->expects(self::once())->method('handleThrowable')->with(self::identicalTo($failure)); (new ErrorHandler($reporter, $adapter))->handlePhpError(E_WARNING, 'warning', '/fixture.php', 42); @@ -129,7 +418,7 @@ public function testOtherPhpAdapterFailuresPropagate(): void { $failure = new \LogicException('adapter'); $adapter = $this->createMock(PhpErrorHandlerInterface::class); - $adapter->method('handlePhpError')->willThrowException($failure); + $adapter->expects(self::once())->method('handlePhpError')->willThrowException($failure); $reporter = $this->createMock(ThrowableHandlerInterface::class); $reporter->expects(self::never())->method('handleThrowable'); @@ -177,6 +466,7 @@ public function testRegisteredShutdownCanReturnNormally(): void } /** Verifies actual native warning delivery, masking and @ suppression without terminating PHP. */ + #[WithoutErrorHandler] public function testNativeWarningsAndSuppression(): void { $events = []; @@ -215,4 +505,30 @@ static function (\Throwable $event) use (&$events): void { error_reporting($mask); } } + + /** + * Snapshots both callback identities without retaining a probe on either native stack. + * + * @return array{null|callable, null|callable} the current PHP error and exception callbacks + * + * @phpstan-impure + */ + private static function callbacks(): array + { + /** @return bool the temporary error callback used only to inspect the active callback */ + $error = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $exception = set_exception_handler( + /** + * Supplies a temporary exception callback without handling an actual failure. + * + * @param \Throwable $error the unused probe input + */ + static function (\Throwable $error): void { + }, + ); + restore_exception_handler(); + + return [$error, $exception]; + } } diff --git a/tests/Unit/EventDispatcher/Event/ErrorEventTest.php b/tests/Unit/EventDispatcher/Event/ErrorEventTest.php index ff3fd0a..96fd41d 100644 --- a/tests/Unit/EventDispatcher/Event/ErrorEventTest.php +++ b/tests/Unit/EventDispatcher/Event/ErrorEventTest.php @@ -18,14 +18,15 @@ use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; /** * Verifies a wrapped event retains the original throwable and its full string form. * * @internal - * - * @covers \CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent */ +#[CoversClass(ErrorEvent::class)] final class ErrorEventTest extends TestCase { /** @@ -33,9 +34,8 @@ final class ErrorEventTest extends TestCase * * @param bool $isError whether the original is an Error instead of an Exception * @param string $message the synthetic throwable's message - * - * @dataProvider provideRetainsTheOriginalThrowableAndStringCases */ + #[DataProvider('provideRetainsTheOriginalThrowableAndStringCases')] public function testRetainsTheOriginalThrowableAndString(bool $isError, string $message): void { $original = $isError ? new \Error($message) : new \RuntimeException($message); diff --git a/tests/Unit/EventDispatcher/Listener/ErrorEventCallableListenerTest.php b/tests/Unit/EventDispatcher/Listener/ErrorEventCallableListenerTest.php index 47f6cc3..fa9ad1d 100644 --- a/tests/Unit/EventDispatcher/Listener/ErrorEventCallableListenerTest.php +++ b/tests/Unit/EventDispatcher/Listener/ErrorEventCallableListenerTest.php @@ -19,14 +19,14 @@ use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEventInterface; use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; /** * Verifies exact event delivery, ignored callback results and unchanged failures. * * @internal - * - * @covers \CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener */ +#[CoversClass(ErrorEventCallableListener::class)] final class ErrorEventCallableListenerTest extends TestCase { /** @@ -34,8 +34,8 @@ final class ErrorEventCallableListenerTest extends TestCase */ public function testReturnsTheOriginalEventAfterOneCallback(): void { - $original = $this->createMock(ErrorEventInterface::class); - $replacement = $this->createMock(ErrorEventInterface::class); + $original = self::createStub(ErrorEventInterface::class); + $replacement = self::createStub(ErrorEventInterface::class); $observed = null; $calls = 0; $listener = new ErrorEventCallableListener( @@ -65,7 +65,7 @@ static function (ErrorEventInterface $event) use (&$observed, &$calls, $replacem */ public function testPropagatesTheIdenticalCallbackFailure(): void { - $event = $this->createMock(ErrorEventInterface::class); + $event = self::createStub(ErrorEventInterface::class); $failure = new \Error('Synthetic event callback failure'); $calls = 0; $listener = new ErrorEventCallableListener( diff --git a/tests/Unit/EventDispatcher/Listener/LogErrorEventListenerTest.php b/tests/Unit/EventDispatcher/Listener/LogErrorEventListenerTest.php index bfa7b9f..e49dc37 100644 --- a/tests/Unit/EventDispatcher/Listener/LogErrorEventListenerTest.php +++ b/tests/Unit/EventDispatcher/Listener/LogErrorEventListenerTest.php @@ -21,14 +21,15 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; /** * Verifies the legacy event listener logs string output and returns the same event. * * @internal - * - * @covers \CoiSA\ErrorHandler\EventDispatcher\Listener\LogErrorEventListener */ +#[CoversClass(LogErrorEventListener::class)] final class LogErrorEventListenerTest extends TestCase { /** @@ -36,9 +37,8 @@ final class LogErrorEventListenerTest extends TestCase * * @param bool $customLevel whether to configure an application-selected level * @param string $message the event's exact string representation - * - * @dataProvider provideLogsTheEventStringAndReturnsTheOriginalCases */ + #[DataProvider('provideLogsTheEventStringAndReturnsTheOriginalCases')] public function testLogsTheEventStringAndReturnsTheOriginal(bool $customLevel, string $message): void { $event = $this->createMock(ErrorEventInterface::class); diff --git a/tests/Unit/EventDispatcher/Listener/ThrowableCallableListenerTest.php b/tests/Unit/EventDispatcher/Listener/ThrowableCallableListenerTest.php index 59352ce..b1194ac 100644 --- a/tests/Unit/EventDispatcher/Listener/ThrowableCallableListenerTest.php +++ b/tests/Unit/EventDispatcher/Listener/ThrowableCallableListenerTest.php @@ -18,14 +18,14 @@ use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; /** * Verifies callback return values cannot replace the listener's throwable event. * * @internal - * - * @covers \CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener */ +#[CoversClass(ThrowableCallableListener::class)] final class ThrowableCallableListenerTest extends TestCase { /** diff --git a/tests/Unit/EventDispatcher/ListenerProvider/ErrorEventListenerProviderTest.php b/tests/Unit/EventDispatcher/ListenerProvider/ErrorEventListenerProviderTest.php index 95617c4..4f18e7c 100644 --- a/tests/Unit/EventDispatcher/ListenerProvider/ErrorEventListenerProviderTest.php +++ b/tests/Unit/EventDispatcher/ListenerProvider/ErrorEventListenerProviderTest.php @@ -20,16 +20,16 @@ use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEventInterface; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; /** * Verifies concrete-event selection and stable listener order without dispatch side effects. * * @internal - * - * @covers \CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider - * - * @uses \CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent */ +#[CoversClass(ErrorEventListenerProvider::class)] +#[UsesClass(ErrorEvent::class)] final class ErrorEventListenerProviderTest extends TestCase { /** @@ -75,7 +75,7 @@ public function testRejectsUnrelatedAndInterfaceOnlyEvents(): void $provider = new ErrorEventListenerProvider($this->createListener('unused', $calls)); self::assertSame([], [...$provider->getListenersForEvent(new \stdClass())]); - self::assertSame([], [...$provider->getListenersForEvent($this->createMock(ErrorEventInterface::class))]); + self::assertSame([], [...$provider->getListenersForEvent(self::createStub(ErrorEventInterface::class))]); self::assertSame([], [...$provider->getListenersForEvent(new \RuntimeException('Raw throwable'))]); self::assertSame([], $calls); } diff --git a/tests/Unit/EventDispatcher/ListenerProvider/ThrowableListenerProviderTest.php b/tests/Unit/EventDispatcher/ListenerProvider/ThrowableListenerProviderTest.php index a08a844..f6eafe3 100644 --- a/tests/Unit/EventDispatcher/ListenerProvider/ThrowableListenerProviderTest.php +++ b/tests/Unit/EventDispatcher/ListenerProvider/ThrowableListenerProviderTest.php @@ -19,25 +19,25 @@ use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\UsesClass; /** * Verifies Throwable selection, append order and lookup without listener execution. * * @internal - * - * @covers \CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider - * - * @uses \CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent */ +#[CoversClass(ThrowableListenerProvider::class)] +#[UsesClass(ErrorEvent::class)] final class ThrowableListenerProviderTest extends TestCase { /** * Both Exception and Error events retain constructor and appended listener order. * * @param bool $isError whether the event is an Error rather than an Exception - * - * @dataProvider providePreservesListenerIdentityAndAttachmentOrderCases */ + #[DataProvider('providePreservesListenerIdentityAndAttachmentOrderCases')] public function testPreservesListenerIdentityAndAttachmentOrder(bool $isError): void { $calls = []; diff --git a/tests/Unit/Exception/AggregateReportingExceptionTest.php b/tests/Unit/Exception/AggregateReportingExceptionTest.php index 7176838..99502b4 100644 --- a/tests/Unit/Exception/AggregateReportingExceptionTest.php +++ b/tests/Unit/Exception/AggregateReportingExceptionTest.php @@ -17,15 +17,15 @@ namespace CoiSA\ErrorHandler\Test\Unit\Exception; use CoiSA\ErrorHandler\Exception\AggregateReportingException; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; /** * Verifies original cause identity and the ordered, nonempty reporter failure list. * * @internal - * - * @covers \CoiSA\ErrorHandler\Exception\AggregateReportingException */ +#[CoversClass(AggregateReportingException::class)] final class AggregateReportingExceptionTest extends TestCase { /** Verifies a single failure retains the original as both explicit input and previous cause. */ @@ -78,7 +78,7 @@ public function testNamedVariadicFailuresBecomeAListWithoutChangingOrder(): void $construct = static fn( \Throwable $originalThrowable, \Throwable $failure, - \Throwable ...$failures + \Throwable ...$failures, ): AggregateReportingException => new AggregateReportingException($originalThrowable, $failure, ...$failures); $exception = \call_user_func_array($construct, [ diff --git a/tests/Unit/Exception/ErrorExceptionTest.php b/tests/Unit/Exception/ErrorExceptionTest.php index b96ca6f..3752520 100644 --- a/tests/Unit/Exception/ErrorExceptionTest.php +++ b/tests/Unit/Exception/ErrorExceptionTest.php @@ -18,14 +18,14 @@ use CoiSA\ErrorHandler\Exception\ErrorException; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; /** * Verifies PHP-error metadata and original previous-throwable identity. * * @internal - * - * @covers \CoiSA\ErrorHandler\Exception\ErrorException */ +#[CoversClass(ErrorException::class)] final class ErrorExceptionTest extends TestCase { /** All supplied metadata and the original causal object survive conversion. */ diff --git a/tests/Unit/Handler/BestEffortThrowableHandlerAggregateTest.php b/tests/Unit/Handler/BestEffortThrowableHandlerAggregateTest.php index f78ab61..57f32ce 100644 --- a/tests/Unit/Handler/BestEffortThrowableHandlerAggregateTest.php +++ b/tests/Unit/Handler/BestEffortThrowableHandlerAggregateTest.php @@ -20,15 +20,19 @@ use CoiSA\ErrorHandler\Handler\BestEffortThrowableHandlerAggregate; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; /** * Verifies ordered, single-pass reporting and complete failure preservation. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\BestEffortThrowableHandlerAggregate */ +#[CoversClass(BestEffortThrowableHandlerAggregate::class)] +#[UsesClass(AggregateReportingException::class)] +#[UsesClass(CallableThrowableHandler::class)] final class BestEffortThrowableHandlerAggregateTest extends TestCase { /** Requires the aggregate to leave diagnostic output to its delegates. */ @@ -43,16 +47,15 @@ public function testConstructionIsInert(): void $reporter = $this->createMock(ThrowableHandlerInterface::class); $reporter->expects(self::never())->method('handleThrowable'); - new BestEffortThrowableHandlerAggregate($reporter); + self::assertInstanceOf(ThrowableHandlerInterface::class, new BestEffortThrowableHandlerAggregate($reporter)); } /** * Verifies zero, one and many successful reporters return normally in order. * * @param int $count the number of independently recorded slots - * - * @dataProvider provideSuccessfulSlotsReceiveTheOriginalOnceInOrderCases */ + #[DataProvider('provideSuccessfulSlotsReceiveTheOriginalOnceInOrderCases')] public function testSuccessfulSlotsReceiveTheOriginalOnceInOrder(int $count): void { $original = new \Error('original'); @@ -93,9 +96,8 @@ public static function provideSuccessfulSlotsReceiveTheOriginalOnceInOrderCases( * Verifies every slot is attempted before any ordered aggregate failure escapes. * * @param list $failing which configured slots throw their distinct failure - * - * @dataProvider provideAttemptsEverySlotAndPreservesAllFailuresCases */ + #[DataProvider('provideAttemptsEverySlotAndPreservesAllFailuresCases')] public function testAttemptsEverySlotAndPreservesAllFailures(array $failing): void { $original = new \RuntimeException('original'); diff --git a/tests/Unit/Handler/CallablePhpErrorHandlerTest.php b/tests/Unit/Handler/CallablePhpErrorHandlerTest.php index 4cc4743..3848191 100644 --- a/tests/Unit/Handler/CallablePhpErrorHandlerTest.php +++ b/tests/Unit/Handler/CallablePhpErrorHandlerTest.php @@ -18,23 +18,23 @@ use CoiSA\ErrorHandler\Handler\CallablePhpErrorHandler; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; /** * Verifies inert construction, exact delivery and application-owned policy/failures. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\CallablePhpErrorHandler */ +#[CoversClass(CallablePhpErrorHandler::class)] final class CallablePhpErrorHandlerTest extends TestCase { /** * Deliver precisely four original values once, ignoring false/null or another result. * * @param mixed $result the callback result discarded by the void interface - * - * @dataProvider providePreservesFourArgumentsAndIgnoresCallbackResultCases */ + #[DataProvider('providePreservesFourArgumentsAndIgnoresCallbackResultCases')] public function testPreservesFourArgumentsAndIgnoresCallbackResult($result): void { $calls = []; @@ -72,9 +72,8 @@ public static function providePreservesFourArgumentsAndIgnoresCallbackResultCase * Preserve an Error or Exception object without conversion, retry or suppression. * * @param class-string<\Throwable> $failureClass the original callback failure type - * - * @dataProvider providePropagatesTheSameCallbackFailureOnceCases */ + #[DataProvider('providePropagatesTheSameCallbackFailureOnceCases')] public function testPropagatesTheSameCallbackFailureOnce(string $failureClass): void { $failure = new $failureClass('Synthetic callback failure'); diff --git a/tests/Unit/Handler/CallableShutdownHandlerTest.php b/tests/Unit/Handler/CallableShutdownHandlerTest.php index 3522510..03ea20d 100644 --- a/tests/Unit/Handler/CallableShutdownHandlerTest.php +++ b/tests/Unit/Handler/CallableShutdownHandlerTest.php @@ -18,14 +18,14 @@ use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; /** * Verifies explicit zero-argument delivery and unchanged callback failures. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\CallableShutdownHandler */ +#[CoversClass(CallableShutdownHandler::class)] final class CallableShutdownHandlerTest extends TestCase { /** Construction is inert; each explicit shutdown call invokes the callback once. */ diff --git a/tests/Unit/Handler/CallableThrowableHandlerTest.php b/tests/Unit/Handler/CallableThrowableHandlerTest.php index 683e124..983e372 100644 --- a/tests/Unit/Handler/CallableThrowableHandlerTest.php +++ b/tests/Unit/Handler/CallableThrowableHandlerTest.php @@ -18,14 +18,14 @@ use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; /** * Verifies original-object delivery and failure identity through the callable adapter. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\CallableThrowableHandler */ +#[CoversClass(CallableThrowableHandler::class)] final class CallableThrowableHandlerTest extends TestCase { /** Each explicit invocation delivers the original object once and ignores callback results. */ diff --git a/tests/Unit/Handler/DispatchErrorEventThrowableHandlerTest.php b/tests/Unit/Handler/DispatchErrorEventThrowableHandlerTest.php index ede4aa7..12effa6 100644 --- a/tests/Unit/Handler/DispatchErrorEventThrowableHandlerTest.php +++ b/tests/Unit/Handler/DispatchErrorEventThrowableHandlerTest.php @@ -20,16 +20,16 @@ use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; /** * Verifies wrapper creation without losing the original throwable or dispatcher failure. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler - * - * @uses \CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent */ +#[CoversClass(DispatchErrorEventThrowableHandler::class)] +#[UsesClass(ErrorEvent::class)] final class DispatchErrorEventThrowableHandlerTest extends TestCase { /** Dispatch one concrete ErrorEvent wrapping the exact original throwable. */ diff --git a/tests/Unit/Handler/DispatchThrowableHandlerTest.php b/tests/Unit/Handler/DispatchThrowableHandlerTest.php index 7f8c576..13d4f45 100644 --- a/tests/Unit/Handler/DispatchThrowableHandlerTest.php +++ b/tests/Unit/Handler/DispatchThrowableHandlerTest.php @@ -19,14 +19,14 @@ use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; +use PHPUnit\Framework\Attributes\CoversClass; /** * Verifies raw-event identity and dispatcher failure propagation. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\DispatchThrowableHandler */ +#[CoversClass(DispatchThrowableHandler::class)] final class DispatchThrowableHandlerTest extends TestCase { /** Dispatch the original throwable once even when the dispatcher returns another object. */ diff --git a/tests/Unit/Handler/FilteringThrowableHandlerTest.php b/tests/Unit/Handler/FilteringThrowableHandlerTest.php index 069fc82..6478dda 100644 --- a/tests/Unit/Handler/FilteringThrowableHandlerTest.php +++ b/tests/Unit/Handler/FilteringThrowableHandlerTest.php @@ -20,25 +20,25 @@ use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\UsesClass; /** * Verifies selection, strict boolean results and unchanged collaborator failures. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\FilteringThrowableHandler - * - * @uses \CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate */ +#[CoversClass(FilteringThrowableHandler::class)] +#[UsesClass(ThrowableHandlerAggregate::class)] final class FilteringThrowableHandlerTest extends TestCase { /** * Evaluate once per call, including repeated delivery of the same input object. * * @param bool $match the explicit inclusion decision - * - * @dataProvider provideSelectionIsPerInvocationAndPreservesIdentityCases */ + #[DataProvider('provideSelectionIsPerInvocationAndPreservesIdentityCases')] public function testSelectionIsPerInvocationAndPreservesIdentity(bool $match): void { $original = new \RuntimeException('Synthetic repeated input'); @@ -81,9 +81,8 @@ public static function provideSelectionIsPerInvocationAndPreservesIdentityCases( * Reject truthy and false-like values instead of coercing application policy. * * @param mixed $result the deliberately malformed predicate result - * - * @dataProvider provideRejectsNonBooleanResultBeforeDeliveryCases */ + #[DataProvider('provideRejectsNonBooleanResultBeforeDeliveryCases')] public function testRejectsNonBooleanResultBeforeDelivery($result): void { $delegate = $this->createMock(ThrowableHandlerInterface::class); @@ -92,8 +91,11 @@ public function testRejectsNonBooleanResultBeforeDelivery($result): void /** @return mixed the deliberately invalid result used to test the runtime boundary */ $predicate = static fn(\Throwable $received) => $result; - // Deliberately violate the documented callable contract to exercise PHP's runtime boundary. - /** @var callable(\Throwable): bool $predicate */ + /** + * Deliberately supply a non-boolean callback to verify the handler's strict runtime boundary. + * + * @phpstan-ignore argument.type (Negative input intentionally violates the documented predicate contract.) + */ $handler = new FilteringThrowableHandler($delegate, $predicate); $this->expectException(\TypeError::class); @@ -111,9 +113,8 @@ public static function provideRejectsNonBooleanResultBeforeDeliveryCases(): iter * * @param bool $predicateFails whether selection or delivery raises the failure * @param class-string<\Throwable> $failureClass the collaborator's failure type - * - * @dataProvider provideCollaboratorFailuresPropagateUnchangedCases */ + #[DataProvider('provideCollaboratorFailuresPropagateUnchangedCases')] public function testCollaboratorFailuresPropagateUnchanged(bool $predicateFails, string $failureClass): void { $failure = new $failureClass('Synthetic collaborator failure'); diff --git a/tests/Unit/Handler/LogThrowableHandlerTest.php b/tests/Unit/Handler/LogThrowableHandlerTest.php index 17dc4f9..dfd50b1 100644 --- a/tests/Unit/Handler/LogThrowableHandlerTest.php +++ b/tests/Unit/Handler/LogThrowableHandlerTest.php @@ -17,18 +17,20 @@ namespace CoiSA\ErrorHandler\Test\Unit\Handler; use CoiSA\ErrorHandler\Handler\LogThrowableHandler; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use PHPUnit\Framework\TestCase; use Psr\Log\InvalidArgumentException; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; /** * Verifies direct logger delivery, context identity and unchanged failure propagation. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\LogThrowableHandler */ +#[CoversClass(LogThrowableHandler::class)] final class LogThrowableHandlerTest extends TestCase { /** @@ -45,9 +47,8 @@ protected function setUp(): void * @param string $message the exact synthetic message, including an empty boundary * @param int $code the error code that must not determine the log level * @param bool $isError whether to report an Error instead of an Exception - * - * @dataProvider provideLogsOnceAtErrorWithTheOriginalThrowableCases */ + #[DataProvider('provideLogsOnceAtErrorWithTheOriginalThrowableCases')] public function testLogsOnceAtErrorWithTheOriginalThrowable(string $message, int $code, bool $isError): void { $throwable = $isError ? new \Error($message, $code) : new \RuntimeException($message, $code); @@ -142,7 +143,7 @@ public function testPropagatesTheLoggersLevelRejectionWithoutRetry(): void { $failure = new InvalidArgumentException('Unsupported application level'); $logger = $this->createMock(LoggerInterface::class); - $logger->expects(self::once())->method('log')->with('application-level', '', self::isType('array')) + $logger->expects(self::once())->method('log')->with('application-level', '', self::isArray()) ->willThrowException($failure) ; $handler = new LogThrowableHandler($logger, 'application-level'); @@ -183,6 +184,6 @@ public function testConstructionDoesNotLog(): void $logger = $this->createMock(LoggerInterface::class); $logger->expects(self::never())->method('log'); - new LogThrowableHandler($logger); + self::assertInstanceOf(ThrowableHandlerInterface::class, new LogThrowableHandler($logger)); } } diff --git a/tests/Unit/Handler/PhpLastErrorShutdownHandlerTest.php b/tests/Unit/Handler/PhpLastErrorShutdownHandlerTest.php index dd824b6..1cd700f 100644 --- a/tests/Unit/Handler/PhpLastErrorShutdownHandlerTest.php +++ b/tests/Unit/Handler/PhpLastErrorShutdownHandlerTest.php @@ -19,21 +19,21 @@ use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\PhpLastErrorShutdownHandler; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; /** * Verifies real absent/nonfatal last-error states without faking native PHP functions. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\PhpLastErrorShutdownHandler */ +#[CoversClass(PhpLastErrorShutdownHandler::class)] final class PhpLastErrorShutdownHandlerTest extends TestCase { /** * No last error means no shutdown delivery. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testDoesNothingWhenNoLastErrorExists(): void { error_clear_last(); @@ -48,9 +48,8 @@ public function testDoesNothingWhenNoLastErrorExists(): void /** * A real nonfatal last warning is ignored; restore callbacks/mask and clear fixture state. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testDoesNotReportARealNonfatalLastError(): void { $delegate = $this->createMock(PhpErrorHandlerInterface::class); diff --git a/tests/Unit/Handler/SymfonyCliThrowableHandlerTest.php b/tests/Unit/Handler/SymfonyCliThrowableHandlerTest.php new file mode 100644 index 0000000..9882329 --- /dev/null +++ b/tests/Unit/Handler/SymfonyCliThrowableHandlerTest.php @@ -0,0 +1,225 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Unit\Handler; + +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +/** + * Verifies original terminal diagnostics, raw Console delivery and native stderr ownership. + * + * @internal + */ +#[CoversClass(SymfonyCliThrowableHandler::class)] +final class SymfonyCliThrowableHandlerTest extends TestCase +{ + /** Every diagnostic must use its selected destination rather than ambient stdout. */ + protected function setUp(): void + { + $this->expectOutputString(''); + } + + /** Real BufferedOutput preserves diagnostic markup and leaves the original throwable unchanged. */ + public function testBufferedOutputReceivesTheOriginalDiagnosticAsRawText(): void + { + $failure = $this->createDiagnostic(); + $trace = $failure->getTrace(); + $previous = $failure->getPrevious(); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, true); + $before = self::callbacks(); + $handler = new SymfonyCliThrowableHandler($output); + self::assertSame('', $output->fetch()); + self::assertSame($before, self::callbacks()); + + $handler->handleThrowable($failure); + $text = $output->fetch(); + + self::assertStringContainsString('CLI synthetic failure', $text); + self::assertStringContainsString('CLI previous cause', $text); + self::assertStringContainsString('RuntimeException', $text); + self::assertStringContainsString('createDiagnostic', $text); + self::assertStringContainsString(basename($failure->getFile()), $text); + self::assertSame($trace, $failure->getTrace()); + self::assertSame($previous, $failure->getPrevious()); + self::assertSame(503, $failure->getCode()); + self::assertSame($before, self::callbacks()); + } + + /** ConsoleOutput's separate error channel receives one write while stdout remains untouched. */ + public function testConsoleOutputUsesItsErrorChannelWithoutParsingMarkup(): void + { + $errorOutput = $this->createMock(OutputInterface::class); + $errorOutput->expects(self::once())->method('write')->with( + self::stringContains('Console channel failure'), + false, + OutputInterface::OUTPUT_RAW, + ); + $errorOutput->expects(self::never())->method('writeln'); + $console = $this->createMock(ConsoleOutputInterface::class); + $console->expects(self::once())->method('getErrorOutput')->willReturn($errorOutput); + $console->expects(self::never())->method('write'); + $console->expects(self::never())->method('writeln'); + + (new SymfonyCliThrowableHandler($console))->handleThrowable(new \Error('Console channel failure')); + } + + /** Output failures propagate unchanged without a retry or native callback mutation. */ + public function testOutputFailureRemainsIdentical(): void + { + $failure = new \LogicException('Console transport failed'); + $output = $this->createMock(OutputInterface::class); + $output->expects(self::once())->method('write')->with( + self::stringContains('Original terminal diagnostic'), + false, + OutputInterface::OUTPUT_RAW, + )->willThrowException($failure); + $output->expects(self::never())->method('writeln'); + $before = self::callbacks(); + + try { + (new SymfonyCliThrowableHandler($output))->handleThrowable( + new \RuntimeException('Original terminal diagnostic'), + ); + self::fail('The application output failure must propagate.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame($before, self::callbacks()); + } + + /** A failed error-channel lookup cannot fall back to standard output or native stderr. */ + public function testErrorChannelFailureRemainsIdentical(): void + { + $failure = new \LogicException('Error channel unavailable'); + $console = $this->createMock(ConsoleOutputInterface::class); + $console->expects(self::once())->method('getErrorOutput')->willThrowException($failure); + $console->expects(self::never())->method('write'); + $console->expects(self::never())->method('writeln'); + + try { + (new SymfonyCliThrowableHandler($console))->handleThrowable( + new \RuntimeException('Original terminal diagnostic'), + ); + self::fail('The error-channel failure must propagate.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + } + + /** Sequential calls create current diagnostics without retaining the previous Throwable or request. */ + public function testSequentialDiagnosticsDoNotRetainEarlierFailures(): void + { + $output = new BufferedOutput(); + $handler = new SymfonyCliThrowableHandler($output); + $handler->handleThrowable(new \RuntimeException('FIRST_CLI_DIAGNOSTIC')); + $first = $output->fetch(); + $handler->handleThrowable(new \Error('SECOND_CLI_DIAGNOSTIC')); + $second = $output->fetch(); + + self::assertStringContainsString('FIRST_CLI_DIAGNOSTIC', $first); + self::assertStringContainsString('SECOND_CLI_DIAGNOSTIC', $second); + self::assertStringNotContainsString('FIRST_CLI_DIAGNOSTIC', $second); + } + + /** Native stderr delivery works without loading the optional Console package or touching stdout. */ + public function testNativeStderrDoesNotRequireConsoleOrTerminateTheCaller(): void + { + $autoload = \dirname(__DIR__, 3) . '/vendor/autoload.php'; + $code = <<<'PHP' + $loader = require $argv[1]; + $loader->setPsr4('Symfony\\Component\\Console\\', []); + spl_autoload_register(static function (string $class): void { + if (str_starts_with($class, 'Symfony\\Component\\Console\\')) { + throw new LogicException('Optional Console must not be loaded for native stderr.'); + } + }, true, true); + $handler = new CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler(); + $handler->handleThrowable(new RuntimeException('NATIVE_CLI_DIAGNOSTIC')); + $handler->handleThrowable(new Error('SECOND_NATIVE_CLI_DIAGNOSTIC')); + echo "caller continued\n"; + PHP; + $environment = array_fill_keys(array_keys(getenv()), false); + $process = new Process([ + PHP_BINARY, '-d', 'memory_limit=64M', '-d', 'display_errors=stderr', + '-d', 'log_errors=0', '-d', 'error_reporting=-1', '-r', $code, $autoload, + ], null, $environment, null, 2.0); + $bytes = 0; + + try { + $process->run( + /** + * Retains the existing native-process output bound without storing ambient HTML or environment data. + * + * @param string $type the stdout or stderr pipe + * @param string $chunk newly received diagnostic bytes + * + * @throws \LengthException when the synthetic fixture exceeds 64 KiB + */ + static function (string $type, string $chunk) use (&$bytes): void { + $bytes += \strlen($chunk); + if ($bytes > 65536) { + throw new \LengthException('CLI diagnostic fixture exceeded 64 KiB.'); + } + }, + ); + } finally { + $process->stop(0); + } + + self::assertFalse($process->isRunning()); + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + self::assertSame("caller continued\n", $process->getOutput()); + self::assertStringContainsString('NATIVE_CLI_DIAGNOSTIC', $process->getErrorOutput()); + self::assertStringContainsString('SECOND_NATIVE_CLI_DIAGNOSTIC', $process->getErrorOutput()); + self::assertStringNotContainsString('Optional Console must not be loaded', $process->getErrorOutput()); + self::assertStringNotContainsString('Warning:', $process->getErrorOutput()); + } + + /** @return \RuntimeException a real named frame and previous cause without application credentials */ + private function createDiagnostic(): \RuntimeException + { + return new \RuntimeException( + 'CLI synthetic failure', + 503, + new \RuntimeException('CLI previous cause'), + ); + } + + /** + * Reads active native callbacks while restoring both temporary probe stacks. + * + * @return array{null|callable, null|callable} current error and exception handlers + */ + private static function callbacks(): array + { + $error = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $exception = set_exception_handler( + /** @param \Throwable $event the unused native probe input */ + static function (\Throwable $event): void { + }, + ); + restore_exception_handler(); + + return [$error, $exception]; + } +} diff --git a/tests/Unit/Handler/ThrowErrorExceptionPhpErrorHandlerTest.php b/tests/Unit/Handler/ThrowErrorExceptionPhpErrorHandlerTest.php index 0ddb866..9ab551e 100644 --- a/tests/Unit/Handler/ThrowErrorExceptionPhpErrorHandlerTest.php +++ b/tests/Unit/Handler/ThrowErrorExceptionPhpErrorHandlerTest.php @@ -19,16 +19,17 @@ use CoiSA\ErrorHandler\Exception\ErrorException; use CoiSA\ErrorHandler\Handler\ThrowErrorExceptionPhpErrorHandler; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\UsesClass; /** * Verifies enabled-error metadata, disabled levels and actual @ suppression. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\ThrowErrorExceptionPhpErrorHandler - * - * @uses \CoiSA\ErrorHandler\Exception\ErrorException */ +#[CoversClass(ThrowErrorExceptionPhpErrorHandler::class)] +#[UsesClass(ErrorException::class)] final class ThrowErrorExceptionPhpErrorHandlerTest extends TestCase { /** Enabled PHP errors retain their message, severity and supplied source location. */ @@ -63,9 +64,8 @@ public function testConvertsEnabledErrorsWithoutLosingMetadata(): void * A disabled error level returns normally without changing the application's mask. * * @param int $mask the application reporting mask that excludes user warnings - * - * @dataProvider provideDoesNotConvertDisabledErrorLevelsCases */ + #[DataProvider('provideDoesNotConvertDisabledErrorLevelsCases')] public function testDoesNotConvertDisabledErrorLevels(int $mask): void { $previous = error_reporting($mask); diff --git a/tests/Unit/Handler/ThrowableHandlerAggregateTest.php b/tests/Unit/Handler/ThrowableHandlerAggregateTest.php index a2e0b7b..08c5624 100644 --- a/tests/Unit/Handler/ThrowableHandlerAggregateTest.php +++ b/tests/Unit/Handler/ThrowableHandlerAggregateTest.php @@ -19,16 +19,16 @@ use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; /** * Verifies ordered reporting and throwable identity across aggregate composition. * * @internal - * - * @covers \CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate - * - * @uses \CoiSA\ErrorHandler\Handler\CallableThrowableHandler */ +#[CoversClass(ThrowableHandlerAggregate::class)] +#[UsesClass(CallableThrowableHandler::class)] final class ThrowableHandlerAggregateTest extends TestCase { /** @@ -70,4 +70,62 @@ static function (\Throwable $received) use ($name, &$calls): void { ['attached', $throwable], ], $calls); } + + /** A failed slot stops its pass without replacing the failure or poisoning later invocations. */ + public function testFailureStopsDeliveryAndAnIndependentPassCanSucceed(): void + { + $original = new \RuntimeException('First diagnostic'); + $independent = new \Error('Independent diagnostic'); + $failure = new \Error('Second reporter failed'); + $calls = []; + $attempts = 0; + $aggregate = new ThrowableHandlerAggregate( + new CallableThrowableHandler( + /** @param \Throwable $received the unchanged diagnostic supplied to the first slot */ + static function (\Throwable $received) use (&$calls): void { + $calls[] = ['first', $received]; + }, + ), + new CallableThrowableHandler( + /** + * Fail the first pass after recording the original diagnostic. + * + * @param \Throwable $received the unchanged diagnostic supplied to the failing slot + * + * @throws \Error the identical first-pass reporter failure + */ + static function (\Throwable $received) use (&$calls, &$attempts, $failure): void { + $calls[] = ['second', $received]; + if (1 === ++$attempts) { + throw $failure; + } + }, + ), + new CallableThrowableHandler( + /** @param \Throwable $received the unchanged diagnostic delivered only after earlier slots succeed */ + static function (\Throwable $received) use (&$calls): void { + $calls[] = ['third', $received]; + }, + ), + ); + $this->expectOutputString(''); + + try { + $aggregate->handleThrowable($original); + self::fail('The failing reporter must stop the current pass.'); + } catch (\Error $caught) { + self::assertSame($failure, $caught); + } + self::assertSame([['first', $original], ['second', $original]], $calls); + + $aggregate->handleThrowable($independent); + + self::assertSame([ + ['first', $original], + ['second', $original], + ['first', $independent], + ['second', $independent], + ['third', $independent], + ], $calls); + } } diff --git a/tests/Unit/Http/Message/GenericThrowableStreamFactoryTest.php b/tests/Unit/Http/Message/GenericThrowableStreamFactoryTest.php new file mode 100644 index 0000000..a2f9495 --- /dev/null +++ b/tests/Unit/Http/Message/GenericThrowableStreamFactoryTest.php @@ -0,0 +1,80 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Unit\Http\Message; + +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; +use Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\StreamFactoryInterface; + +/** + * Verifies the provider's generic renderer delegates only the fixed public body. + * + * @internal + */ +#[CoversClass(GenericThrowableStreamFactory::class)] +final class GenericThrowableStreamFactoryTest extends TestCase +{ + /** + * Preserves the exact returned stream without consulting diagnostic text or emitting output. + * + * @param class-string<\Throwable> $class the real throwable category + */ + #[DataProvider('provideGenericBodyIgnoresDiagnosticDetailsCases')] + public function testGenericBodyIgnoresDiagnosticDetails(string $class): void + { + $this->expectOutputString(''); + $original = new $class("private detail\r\nX-Injected: no", 503); + $stream = (new Psr17Factory())->createStream('Internal Server Error'); + $streams = $this->createMock(StreamFactoryInterface::class); + $streams->expects(self::once())->method('createStream') + ->with('Internal Server Error')->willReturn($stream) + ; + + $result = (new GenericThrowableStreamFactory($streams))->createStreamFromThrowable($original); + + self::assertSame($stream, $result); + self::assertSame('Internal Server Error', (string) $result); + self::assertSame("private detail\r\nX-Injected: no", $original->getMessage()); + } + + /** @return array}> exception and engine-error diagnostics */ + public static function provideGenericBodyIgnoresDiagnosticDetailsCases(): iterable + { + return ['exception' => [\RuntimeException::class], 'engine error' => [\Error::class]]; + } + + /** Preserves the original stream-backend failure after one attempt without output or retry. */ + public function testStreamFailurePropagatesWithoutRetry(): void + { + $this->expectOutputString(''); + $primary = new \LogicException('stream backend failure'); + $streams = $this->createMock(StreamFactoryInterface::class); + $streams->expects(self::once())->method('createStream') + ->with('Internal Server Error')->willThrowException($primary) + ; + + try { + (new GenericThrowableStreamFactory($streams))->createStreamFromThrowable(new \Error('private')); + self::fail('The backend failure must propagate.'); + } catch (\LogicException $caught) { + self::assertSame($primary, $caught); + } + } +} diff --git a/tests/Unit/Http/Message/HtmlThrowableResponseFactoryTest.php b/tests/Unit/Http/Message/HtmlThrowableResponseFactoryTest.php new file mode 100644 index 0000000..43b4822 --- /dev/null +++ b/tests/Unit/Http/Message/HtmlThrowableResponseFactoryTest.php @@ -0,0 +1,328 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Unit\Http\Message; + +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; + +/** + * Verifies public HTML, trusted diagnostics and the pure Symfony/PSR-17 boundary. + * + * @internal + */ +#[CoversClass(HtmlThrowableResponseFactory::class)] +final class HtmlThrowableResponseFactoryTest extends TestCase +{ + /** Requires both rendering modes to return bytes without emitting them. */ + protected function setUp(): void + { + $this->expectOutputString(''); + } + + /** + * Preserves integer HTTP error codes without disclosing the original diagnostic. + * + * @param int|string $code the native error code or a PDO-style string + * @param int $status the library's expected HTTP status + */ + #[DataProvider('providePublicHtmlPreservesStatusesAndDoesNotDiscloseDiagnosticsCases')] + public function testPublicHtmlPreservesStatusesAndDoesNotDiscloseDiagnostics(int|string $code, int $status): void + { + $failure = new class ($code) extends \PDOException { + /** @param int|string $code the driver-free native exception code */ + public function __construct(int|string $code) + { + parent::__construct('PRIVATE_HTML_DIAGNOSTIC', 0, new \RuntimeException('PRIVATE_PREVIOUS_CAUSE')); + $this->code = $code; + } + }; + $trace = $failure->getTrace(); + $messages = new Psr17Factory(); + $request = (new ServerRequest('GET', 'https://example.test/private')) + ->withHeader('Accept', 'text/html') + ->withHeader('X-Debug', '1') + ->withAttribute('debug', true) + ; + $response = (new HtmlThrowableResponseFactory($messages, $messages)) + ->createResponseFromThrowable($failure, $request) + ; + $body = (string) $response->getBody(); + + self::assertSame($status, $response->getStatusCode()); + self::assertSame($messages->createResponse($status)->getReasonPhrase(), $response->getReasonPhrase()); + self::assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type')); + self::assertSame('', $response->getHeaderLine('Cache-Control')); + self::assertFalse($response->hasHeader('X-Debug-Exception')); + self::assertFalse($response->hasHeader('X-Debug-Exception-File')); + self::assertStringStartsWith('', $body); + self::assertStringContainsString('getFile(), $body); + self::assertSame($trace, $failure->getTrace()); + self::assertSame($code, $failure->getCode()); + self::assertSame('text/html', $request->getHeaderLine('Accept')); + self::assertTrue($request->getAttribute('debug')); + } + + /** @return array real HTTP boundaries and type-sensitive fallbacks */ + public static function providePublicHtmlPreservesStatusesAndDoesNotDiscloseDiagnosticsCases(): iterable + { + return [ + 'below range' => [399, 500], + 'lower boundary' => [400, 400], + 'not found' => [404, 404], + 'nonstandard client status' => [499, 499], + 'server boundary' => [500, 500], + 'service unavailable' => [503, 503], + 'upper boundary' => [599, 599], + 'above range' => [600, 500], + 'default' => [0, 500], + 'negative' => [-1, 500], + 'HTTP-looking string' => ['404', 500], + 'numeric SQLSTATE' => ['23000', 500], + 'alphanumeric SQLSTATE' => ['HY000', 500], + ]; + } + + /** Diagnostic HTML is a complete document with the original trace and correct displayed status. */ + public function testExplicitDebugIncludesTheOriginalDiagnosticAndTraceInACompleteDocument(): void + { + $failure = $this->createDiagnosticThrowable(); + $trace = $failure->getTrace(); + $messages = new Psr17Factory(); + $request = (new ServerRequest('POST', 'https://example.test/debug')) + ->withHeader('Accept', 'application/json') + ->withAttribute('debug', false) + ; + + $response = (new HtmlThrowableResponseFactory($messages, $messages, true)) + ->createResponseFromThrowable($failure, $request) + ; + $body = (string) $response->getBody(); + + self::assertSame(503, $response->getStatusCode()); + self::assertSame('Service Unavailable', $response->getReasonPhrase()); + self::assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type')); + self::assertSame('no-store', $response->getHeaderLine('Cache-Control')); + self::assertStringStartsWith('', $body); + self::assertStringContainsString('', $body); + self::assertStringContainsString('', $body); + self::assertStringContainsString('503 Service Unavailable', $body); + self::assertStringContainsString('