..., 'status' => ...)` * shim). The key is therefore never serialized onto the * wire response. * * Resolution policy: * * - Every collaborator is optional; omitting one causes the * provider to construct the production default during * register(). Tests inject synthetic collaborators one axis * at a time via the constructor overrides. * * - Resolution is eager at register() time so a misconfigured * bootstrap pipeline (Persistence, Render, or REST provider * missing from the order above) surfaces during * {@see ServiceProviderRegistry::registerAll()} rather than * at the first REST request. * * - The composed controller is cached on this provider so the * route-callback closures can capture the same instance * without re-resolving the registry binding. * * @package Affilun\Presentation\Rest */ declare(strict_types=1); namespace Affilun\Presentation\Rest; use Affilun\Application\Editor\UseCase\ListAvailableProvidersUseCase; use Affilun\Application\Editor\UseCase\ListAvailableTemplatesUseCase; use Affilun\Application\Editor\UseCase\PreviewProductCardUseCase; use Affilun\Application\Provider\ProviderRegistry; use Affilun\Application\Render\ProductCardRenderer; use Affilun\Application\Render\RenderServiceProvider; use Affilun\Application\Render\ViewModel\ProductCardViewModelFactory; use Affilun\Application\Render\ViewModelArrayDumper; use Affilun\Bootstrap\HookRegistrar; use Affilun\Bootstrap\ServiceProvider; use Affilun\Bootstrap\ServiceProviderRegistry; use Affilun\Infrastructure\Persistence\PersistenceServiceProvider; use Affilun\Infrastructure\Persistence\ProductRepository; use Affilun\Presentation\Block\BlockServiceProvider; use Affilun\Presentation\Block\ProductCardBlockRenderer; use Affilun\Presentation\Rest\Controller\EditorController; use Affilun\Presentation\Rest\Controller\RestRequestAdapter; use Affilun\Presentation\Rest\Permission\CapabilityPolicy; use Affilun\Presentation\Rest\Response\ProviderExceptionMapper; use Affilun\Presentation\Rest\Routing\RouteDefinition; use Affilun\Presentation\Rest\Routing\RouteRegistrar; use Affilun\Presentation\Rest\Validation\RequestValidator; use Closure; use LogicException; /** * Binds the three §10.1-§10.3 editor use cases plus the composing * {@see EditorController}, then appends three * {@see RouteDefinition} values to the bound {@see RouteRegistrar} * so the §6a `rest_api_init` driver forwards them to WordPress. * * See the file-level docblock for the bootstrap-pipeline ordering * requirement, the Phase-6 minimum behaviors (empty provider * registry for §10.3, route-level permission callback, * synthetic `_http_status` extraction), and the resolution policy. */ final class EditorServiceProvider implements ServiceProvider { /** * Registry key under which the §10.1 * {@see PreviewProductCardUseCase} is bound. */ public const BINDING_PREVIEW_USE_CASE = 'affilun.rest.editor.preview_use_case'; /** * Registry key under which the §10.2 * {@see ListAvailableTemplatesUseCase} is bound. */ public const BINDING_TEMPLATES_USE_CASE = 'affilun.rest.editor.templates_use_case'; /** * Registry key under which the §10.3 * {@see ListAvailableProvidersUseCase} is bound. */ public const BINDING_PROVIDERS_USE_CASE = 'affilun.rest.editor.providers_use_case'; /** * Registry key under which the composed * {@see EditorController} is bound. */ public const BINDING_CONTROLLER = 'affilun.rest.editor_controller'; /** * §10.1 preview use case override. */ private ?PreviewProductCardUseCase $previewUseCaseOverride; /** * §10.2 templates use case override. */ private ?ListAvailableTemplatesUseCase $templatesUseCaseOverride; /** * §10.3 providers use case override. */ private ?ListAvailableProvidersUseCase $providersUseCaseOverride; /** * Composed controller override. */ private ?EditorController $controllerOverride; /** * Cached {@see EditorController} bound into the registry. * * Populated during register() so the route-callback closures * can capture the same instance without re-resolving the * registry binding. */ private ?EditorController $resolvedController = null; /** * Construct an editor service provider with optional overrides * for each collaborator. * * Empty / no-arg construction is the production default; tests * pass overrides one axis at a time so the controller graph * can be exercised against synthetic use cases without standing * up the persistence or render stack. * * @param PreviewProductCardUseCase|null $previewUseCaseOverride §10.1 use case override. * @param ListAvailableTemplatesUseCase|null $templatesUseCaseOverride §10.2 use case override. * @param ListAvailableProvidersUseCase|null $providersUseCaseOverride §10.3 use case override. * @param EditorController|null $controllerOverride Composed controller override. */ public function __construct( ?PreviewProductCardUseCase $previewUseCaseOverride = null, ?ListAvailableTemplatesUseCase $templatesUseCaseOverride = null, ?ListAvailableProvidersUseCase $providersUseCaseOverride = null, ?EditorController $controllerOverride = null ) { $this->previewUseCaseOverride = $previewUseCaseOverride; $this->templatesUseCaseOverride = $templatesUseCaseOverride; $this->providersUseCaseOverride = $providersUseCaseOverride; $this->controllerOverride = $controllerOverride; } /** * Bind the three §10.1-§10.3 use cases plus the composed * {@see EditorController}, then append three * {@see RouteDefinition} values to the bound * {@see RouteRegistrar}. * * Resolution order: * * 1. Resolve {@see ProductRepository} via * {@see PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY}. * Missing binding raises {@see LogicException} so a * misordered bootstrap fails loudly here rather than at * the first REST request. * * 2. Resolve {@see ProductCardViewModelFactory}, * {@see ProductCardRenderer}, and * {@see ViewModelArrayDumper} via the corresponding * {@see RenderServiceProvider} `BINDING_*` keys. Missing * bindings each raise {@see LogicException}. * * 3. Build the three use cases (using the constructor * overrides when supplied, else default-construct against * the resolved render collaborators and the empty * provider registry described on the file-level * docblock). * * 4. Build the {@see EditorController} (using the * constructor override when supplied, else default- * construct with fresh validator / adapter / capability * policy / exception mapper; the controller's own null * defaults supply the user-id and capability providers). * * 5. Bind the use cases and the controller under the * `BINDING_*` keys and cache the controller on this * provider so the route-callback closures can capture it. * * 6. Resolve {@see RouteRegistrar} via * {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}. * Missing binding raises {@see LogicException} for the * same reason as step 1. * * 7. Append three {@see RouteDefinition} values matching * `rest-api-spec.md` §10.1-§10.3 verbatim, each wrapping * the controller method in a thin closure that extracts * and strips the synthetic * {@see EditorController::META_HTTP_STATUS} key from * the returned envelope and stamps it onto a * {@see \WP_REST_Response} (or the pure-unit-mode array * shim). * * @param ServiceProviderRegistry $registry Registry driving * this bootstrap pass. * * @throws LogicException When the persistence, render, or REST * provider has not registered before * this one. */ public function register( ServiceProviderRegistry $registry ): void { $repository = $this->resolveProductRepository( $registry ); $factory = $this->resolveViewModelFactory( $registry ); $renderer = $this->resolveRenderer( $registry ); $arrayDumper = $this->resolveArrayDumper( $registry ); $previewUseCase = $this->previewUseCaseOverride ?? new PreviewProductCardUseCase( $repository, $factory, $renderer, $arrayDumper ); $templatesUseCase = $this->templatesUseCaseOverride ?? new ListAvailableTemplatesUseCase(); $providersUseCase = $this->providersUseCaseOverride ?? new ListAvailableProvidersUseCase( $this->resolveProviderRegistry( $registry )->all() ); $blockPreviewRenderer = $this->resolveOptionalBlockPreviewRenderer( $registry ); $controller = $this->controllerOverride ?? new EditorController( $previewUseCase, $templatesUseCase, $providersUseCase, new ProviderExceptionMapper(), new CapabilityPolicy(), new RequestValidator(), new RestRequestAdapter(), null, null, $blockPreviewRenderer ); $this->resolvedController = $controller; $registry->bind( self::BINDING_PREVIEW_USE_CASE, $previewUseCase ); $registry->bind( self::BINDING_TEMPLATES_USE_CASE, $templatesUseCase ); $registry->bind( self::BINDING_PROVIDERS_USE_CASE, $providersUseCase ); $registry->bind( self::BINDING_CONTROLLER, $controller ); $registrar = $this->resolveRouteRegistrar( $registry ); $this->appendRoutes( $registrar ); } /** * No-op — routes are registered via * {@see RestServiceProvider}'s `rest_api_init` driver, which * forwards every accumulated {@see RouteDefinition} to * WordPress at the canonical registration moment. * * @param HookRegistrar $hooks Shared hook registrar. */ public function boot( HookRegistrar $hooks ): void { unset( $hooks ); } /** * Resolve {@see ProductRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductRepository}. */ private function resolveProductRepository( ServiceProviderRegistry $registry ): ProductRepository { $key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'EditorServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing ProductRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $repository = $registry->get( $key ); if ( ! $repository instanceof ProductRepository ) { $message = sprintf( 'EditorServiceProvider expected ProductRepository under "%s"; got %s.', $key, is_object( $repository ) ? get_class( $repository ) : gettype( $repository ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repository; } /** * Resolve the optional canonical block-preview adapter. * * When {@see BlockServiceProvider} has already registered, the * editor-preview endpoint delegates HTML generation to those same * renderer instances so Gutenberg previews and front-end dynamic * blocks share one rendering path. Older tests and custom bootstraps * that do not bind block renderers fall back to the legacy * {@see PreviewProductCardUseCase} path by returning null here. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When either block-renderer binding exists * but carries an unexpected type. */ private function resolveOptionalBlockPreviewRenderer( ServiceProviderRegistry $registry ): ?BlockPreviewRenderer { $cardKey = BlockServiceProvider::BINDING_PRODUCT_CARD_BLOCK_RENDERER; if ( ! $registry->has( $cardKey ) ) { return null; } $cardRenderer = $registry->get( $cardKey ); if ( ! $cardRenderer instanceof ProductCardBlockRenderer ) { $message = sprintf( 'EditorServiceProvider expected ProductCardBlockRenderer under "%s"; got %s.', $cardKey, is_object( $cardRenderer ) ? get_class( $cardRenderer ) : gettype( $cardRenderer ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return new BlockPreviewRenderer( $cardRenderer ); } /** * Resolve {@see ProviderRegistry} from the registry. * * Phase 12 slice 12c centralizes the §10.3 provider list under * {@see ProviderServiceProvider::BINDING_PROVIDER_REGISTRY}. * When the binding is missing — typically in tests that * pre-seed the editor collaborators without standing up the * provider service provider — this method returns a fresh * empty {@see ProviderRegistry} so the §10.3 enumeration * answers `items: []` rather than failing loudly. That * fallback mirrors the Phase-6 minimum behavior the * {@see ListAvailableProvidersUseCase} constructor already * documents on the empty-registry case. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding exists but is not a * {@see ProviderRegistry}. */ private function resolveProviderRegistry( ServiceProviderRegistry $registry ): ProviderRegistry { $key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY; if ( ! $registry->has( $key ) ) { return new ProviderRegistry( array() ); } $value = $registry->get( $key ); if ( ! $value instanceof ProviderRegistry ) { $message = sprintf( 'EditorServiceProvider expected ProviderRegistry under "%s"; got %s.', $key, is_object( $value ) ? get_class( $value ) : gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $value; } /** * Resolve {@see ProductCardViewModelFactory} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductCardViewModelFactory}. */ private function resolveViewModelFactory( ServiceProviderRegistry $registry ): ProductCardViewModelFactory { $key = RenderServiceProvider::BINDING_PRODUCT_CARD_VIEW_MODEL_FACTORY; if ( ! $registry->has( $key ) ) { $message = 'EditorServiceProvider requires RenderServiceProvider to register before it' . ' (missing ProductCardViewModelFactory binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $factory = $registry->get( $key ); if ( ! $factory instanceof ProductCardViewModelFactory ) { $message = sprintf( 'EditorServiceProvider expected ProductCardViewModelFactory under "%s"; got %s.', $key, is_object( $factory ) ? get_class( $factory ) : gettype( $factory ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $factory; } /** * Resolve {@see ProductCardRenderer} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductCardRenderer}. */ private function resolveRenderer( ServiceProviderRegistry $registry ): ProductCardRenderer { $key = RenderServiceProvider::BINDING_PRODUCT_CARD_RENDERER; if ( ! $registry->has( $key ) ) { $message = 'EditorServiceProvider requires RenderServiceProvider to register before it' . ' (missing ProductCardRenderer binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $renderer = $registry->get( $key ); if ( ! $renderer instanceof ProductCardRenderer ) { $message = sprintf( 'EditorServiceProvider expected ProductCardRenderer under "%s"; got %s.', $key, is_object( $renderer ) ? get_class( $renderer ) : gettype( $renderer ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $renderer; } /** * Resolve {@see ViewModelArrayDumper} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ViewModelArrayDumper}. */ private function resolveArrayDumper( ServiceProviderRegistry $registry ): ViewModelArrayDumper { $key = RenderServiceProvider::BINDING_VIEW_MODEL_ARRAY_DUMPER; if ( ! $registry->has( $key ) ) { $message = 'EditorServiceProvider requires RenderServiceProvider to register before it' . ' (missing ViewModelArrayDumper binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $dumper = $registry->get( $key ); if ( ! $dumper instanceof ViewModelArrayDumper ) { $message = sprintf( 'EditorServiceProvider expected ViewModelArrayDumper under "%s"; got %s.', $key, is_object( $dumper ) ? get_class( $dumper ) : gettype( $dumper ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $dumper; } /** * Resolve {@see RouteRegistrar} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see RouteRegistrar}. */ private function resolveRouteRegistrar( ServiceProviderRegistry $registry ): RouteRegistrar { $key = RestServiceProvider::BINDING_ROUTE_REGISTRAR; if ( ! $registry->has( $key ) ) { $message = 'EditorServiceProvider requires RestServiceProvider to register before it' . ' (missing RouteRegistrar binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $registrar = $registry->get( $key ); if ( ! $registrar instanceof RouteRegistrar ) { $message = sprintf( 'EditorServiceProvider expected RouteRegistrar under "%s"; got %s.', $key, is_object( $registrar ) ? get_class( $registrar ) : gettype( $registrar ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $registrar; } /** * Append the three §10.1-§10.3 {@see RouteDefinition} values * to the supplied registrar. * * Each definition wraps the corresponding controller method in * a thin {@see Closure} built by * {@see self::buildRouteCallback()} that extracts and strips * the synthetic {@see EditorController::META_HTTP_STATUS} * key from the returned envelope. * * @param RouteRegistrar $registrar Bound registrar. */ private function appendRoutes( RouteRegistrar $registrar ): void { $permissionCallback = static function ( $request = null ): bool { return $request !== false; }; $registrar->add( new RouteDefinition( RestNamespace::FULL, '/editor/preview', RouteDefinition::METHOD_POST, EditorController::ROUTE_TOKEN_PREVIEW, array(), $this->buildRouteCallback( 'preview' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/editor/templates', RouteDefinition::METHOD_GET, EditorController::ROUTE_TOKEN_TEMPLATES, array(), $this->buildRouteCallback( 'templates' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/editor/providers', RouteDefinition::METHOD_GET, EditorController::ROUTE_TOKEN_PROVIDERS, array(), $this->buildRouteCallback( 'providers' ), $permissionCallback ) ); } /** * Build a route-callback {@see Closure} that dispatches to the * named controller method and adapts the returned envelope * onto a {@see \WP_REST_Response} (or the pure-unit-mode * array shim). * * The closure captures `$this` so the cached * {@see self::$resolvedController} is read at invocation time; * the three method names are resolved through a `match` * expression rather than dynamic-dispatch (`->{$method}()`) * so PHPStan can verify each call site against the controller's * static type. The match's `default` arm raises * {@see LogicException} as a defensive guard against a future * caller passing an unknown method name. * * Synthetic key extraction: * * - The controller returns * `Envelope::toArray() + array(META_HTTP_STATUS => int)`. * * - The closure reads the int (defaulting to 200 when * absent), strips the key from the array, and passes the * stripped envelope as the response body. * * - When `WP_REST_Response` is loadable (production path), * the closure returns `new \WP_REST_Response($body, $status)`. * Otherwise (pure-unit harness), it returns the * `array('data' => $body, 'status' => $status)` shim. * * @param string $method One of `'preview'`, `'templates'`, `'providers'`. */ private function buildRouteCallback( string $method ): Closure { return function ( mixed $request ) use ( $method ): mixed { $controller = $this->resolvedController; if ( $controller === null ) { $message = 'EditorServiceProvider route callback invoked before register() ran.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $envelope = match ( $method ) { 'preview' => $controller->preview( $request ), 'templates' => $controller->templates( $request ), 'providers' => $controller->providers( $request ), default => $this->raiseUnknownMethod( $method ), }; $status = isset( $envelope[ EditorController::META_HTTP_STATUS ] ) && is_int( $envelope[ EditorController::META_HTTP_STATUS ] ) ? $envelope[ EditorController::META_HTTP_STATUS ] : 200; unset( $envelope[ EditorController::META_HTTP_STATUS ] ); if ( class_exists( '\\WP_REST_Response' ) ) { return new \WP_REST_Response( $envelope, $status ); } return array( 'data' => $envelope, 'status' => $status, ); }; } /** * Raise a {@see LogicException} for an unknown route-callback * method name. * * Extracted as a helper so the {@see self::buildRouteCallback()} * `match` expression's `default` arm can call it as an * expression (the `match` arm grammar requires an expression, * not a statement). * * @param string $method Offending method name. * * @return never * * @throws LogicException Always. */ private function raiseUnknownMethod( string $method ): array { $message = sprintf( 'EditorServiceProvider route callback received unknown method "%s".', $method ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } }