..., 'status' => ...)` shim). * * 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 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 closure 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\Sync\SyncDispatcher; use Affilun\Application\Sync\UseCase\EnqueueProductSyncUseCase; use Affilun\Bootstrap\HookRegistrar; use Affilun\Bootstrap\ServiceProvider; use Affilun\Bootstrap\ServiceProviderRegistry; use Affilun\Domain\Clock; use Affilun\Domain\Sync\RetryPolicy; use Affilun\Infrastructure\Clock\SystemClock; use Affilun\Infrastructure\Persistence\PersistenceServiceProvider; use Affilun\Infrastructure\Persistence\ProductRepository; use Affilun\Infrastructure\Persistence\SyncJobRepository; use Affilun\Presentation\Rest\Controller\RestRequestAdapter; use Affilun\Presentation\Rest\Controller\SyncController; 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 §11.2 use case plus the composing * {@see SyncController}, then appends one * {@see RouteDefinition} value to the bound {@see RouteRegistrar} * so the §6a `rest_api_init` driver forwards it to WordPress. * * See the file-level docblock for the bootstrap-pipeline ordering * requirement, the Phase-9 minimum behaviors (default-constructed * Clock, route-level permission callback, synthetic * `_http_status` extraction), and the resolution policy. */ final class SyncServiceProvider implements ServiceProvider { /** * Registry key under which the §11.2 * {@see EnqueueProductSyncUseCase} is bound. */ public const BINDING_ENQUEUE_USE_CASE = 'affilun.rest.sync.enqueue_use_case'; /** * Registry key under which the composed * {@see SyncController} is bound. */ public const BINDING_CONTROLLER = 'affilun.rest.sync_controller'; /** * §11.2 use case override. */ private ?EnqueueProductSyncUseCase $enqueueUseCaseOverride; /** * Composed controller override. */ private ?SyncController $controllerOverride; /** * Cached {@see SyncController} bound into the registry. * * Populated during register() so the route-callback closure * can capture the same instance without re-resolving the * registry binding. */ private ?SyncController $resolvedController = null; /** * Construct a sync 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 stack. * * @param EnqueueProductSyncUseCase|null $enqueueUseCaseOverride §11.2 use case override. * @param SyncController|null $controllerOverride Composed controller override. */ public function __construct( ?EnqueueProductSyncUseCase $enqueueUseCaseOverride = null, ?SyncController $controllerOverride = null ) { $this->enqueueUseCaseOverride = $enqueueUseCaseOverride; $this->controllerOverride = $controllerOverride; } /** * Bind the §11.2 use case plus the composed * {@see SyncController}, then append one * {@see RouteDefinition} to the bound {@see RouteRegistrar}. * * Resolution order: * * 1. Resolve {@see ProductRepository} via * {@see PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY} * and {@see SyncJobRepository} via * {@see PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY}. * Either missing binding raises {@see LogicException} so * a misordered bootstrap fails loudly here rather than at * the first REST request. * * 2. Default-construct {@see SystemClock} (no Clock binding * is currently published; see file-level docblock). * * 3. Build the §12.4 default * {@see RetryPolicy::forJobType('sync_product')} policy. * * 4. Build the {@see SyncDispatcher} from those collaborators. * * 5. Build the §11.2 {@see EnqueueProductSyncUseCase} (using * the constructor override when supplied, else default- * construct against the dispatcher + product repository). * * 6. Build the {@see SyncController} (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). * * 7. Bind the use case and the controller under the * `BINDING_*` keys and cache the controller on this * provider so the route-callback closure can capture it. * * 8. Resolve {@see RouteRegistrar} via * {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}. * Missing binding raises {@see LogicException} for the * same reason as step 1. * * 9. Append one {@see RouteDefinition} matching * `rest-api-spec.md` §11.2 verbatim, wrapping the * controller method in a thin closure that extracts and * strips the synthetic * {@see SyncController::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 or REST provider * has not registered before this one. */ public function register( ServiceProviderRegistry $registry ): void { $products = $this->resolveProductRepository( $registry ); $jobs = $this->resolveSyncJobRepository( $registry ); $clock = $this->resolveClock(); $retryPolicy = RetryPolicy::forJobType( RetryPolicy::JOB_SYNC_PRODUCT ); $dispatcher = new SyncDispatcher( $jobs, $clock, $retryPolicy ); $enqueueUseCase = $this->enqueueUseCaseOverride ?? new EnqueueProductSyncUseCase( $dispatcher, $products ); $controller = $this->controllerOverride ?? new SyncController( $enqueueUseCase, new ProviderExceptionMapper(), new CapabilityPolicy(), new RequestValidator(), new RestRequestAdapter() ); $this->resolvedController = $controller; $registry->bind( self::BINDING_ENQUEUE_USE_CASE, $enqueueUseCase ); $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 = 'SyncServiceProvider 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( 'SyncServiceProvider 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 {@see SyncJobRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see SyncJobRepository}. */ private function resolveSyncJobRepository( ServiceProviderRegistry $registry ): SyncJobRepository { $key = PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'SyncServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing SyncJobRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $jobs = $registry->get( $key ); if ( ! $jobs instanceof SyncJobRepository ) { $message = sprintf( 'SyncServiceProvider expected SyncJobRepository under "%s"; got %s.', $key, is_object( $jobs ) ? get_class( $jobs ) : gettype( $jobs ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $jobs; } /** * Resolve a {@see Clock} for the §10.2 dispatcher. * * No Clock binding is currently published by * {@see PersistenceServiceProvider}, so the Phase-9 minimum * default-constructs a {@see SystemClock}. A future slice that * publishes a Clock binding can wire it through the existing * `$enqueueUseCaseOverride` constructor parameter. */ private function resolveClock(): Clock { return new SystemClock(); } /** * 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 = 'SyncServiceProvider 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( 'SyncServiceProvider 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 §11.2 {@see RouteDefinition} to the supplied * registrar. * * The definition wraps the controller method in a thin * {@see Closure} built by {@see self::buildRouteCallback()} * that extracts and strips the synthetic * {@see SyncController::META_HTTP_STATUS} key from the * returned envelope. * * The `id` route token is constrained to a positive decimal * integer via the WordPress route regex `(?P\d+)`, mirroring * the §8.3 `GET /affilun/v1/products/{id}` registration pattern. * * @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, '/sync/products/(?P\d+)', RouteDefinition::METHOD_POST, SyncController::ROUTE_TOKEN_PRODUCT_SYNC, array(), $this->buildRouteCallback(), $permissionCallback ) ); } /** * Build a route-callback {@see Closure} that dispatches to * {@see SyncController::enqueue()} 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. * * 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. */ private function buildRouteCallback(): Closure { return function ( mixed $request ): mixed { $controller = $this->resolvedController; if ( $controller === null ) { $message = 'SyncServiceProvider route callback invoked before register() ran.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $envelope = $controller->enqueue( $request ); $status = isset( $envelope[ SyncController::META_HTTP_STATUS ] ) && is_int( $envelope[ SyncController::META_HTTP_STATUS ] ) ? $envelope[ SyncController::META_HTTP_STATUS ] : 200; unset( $envelope[ SyncController::META_HTTP_STATUS ] ); if ( class_exists( '\\WP_REST_Response' ) ) { return new \WP_REST_Response( $envelope, $status ); } return array( 'data' => $envelope, 'status' => $status, ); }; } }