..., '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\Settings\OptionStoreSettingsRepository; use Affilun\Application\Settings\SettingsRepository; use Affilun\Application\Settings\UseCase\GetPublicSettingsUseCase; use Affilun\Bootstrap\HookRegistrar; use Affilun\Bootstrap\ServiceProvider; use Affilun\Bootstrap\ServiceProviderRegistry; use Affilun\Infrastructure\Persistence\OptionStore; use Affilun\Infrastructure\Persistence\PersistenceServiceProvider; use Affilun\Presentation\Rest\Controller\RestRequestAdapter; use Affilun\Presentation\Rest\Controller\SettingsController; use Affilun\Presentation\Rest\Mapper\PublicSettingsMapper; 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 §13.1 settings repository, use case, response * mapper, and the composing {@see SettingsController}, then * appends a single {@see RouteDefinition} 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-8 minimum behaviors * (route-level permission callback, synthetic * `_http_status` extraction), and the resolution policy. */ final class SettingsServiceProvider implements ServiceProvider { /** * Registry key under which the {@see SettingsRepository} is * bound. */ public const BINDING_SETTINGS_REPOSITORY = 'affilun.settings.repository'; /** * Registry key under which the §13.1 * {@see GetPublicSettingsUseCase} is bound. */ public const BINDING_GET_PUBLIC_SETTINGS_USE_CASE = 'affilun.settings.get_public_use_case'; /** * Registry key under which the §13.1 * {@see PublicSettingsMapper} is bound. */ public const BINDING_PUBLIC_SETTINGS_MAPPER = 'affilun.settings.public_mapper'; /** * Registry key under which the composed * {@see SettingsController} is bound. */ public const BINDING_CONTROLLER = 'affilun.settings.controller'; /** * §13 repository override. */ private ?SettingsRepository $repositoryOverride; /** * §13.1 use case override. */ private ?GetPublicSettingsUseCase $useCaseOverride; /** * Composed controller override. */ private ?SettingsController $controllerOverride; /** * Cached {@see SettingsController} bound into the registry. * * Populated during register() so the route-callback closure * can capture the same instance without re-resolving the * registry binding. */ private ?SettingsController $resolvedController = null; /** * Construct a settings 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 collaborators * without standing up the persistence or REST stack. * * @param SettingsRepository|null $repositoryOverride §13 repository override. * @param GetPublicSettingsUseCase|null $useCaseOverride §13.1 use case override. * @param SettingsController|null $controllerOverride Composed controller override. */ public function __construct( ?SettingsRepository $repositoryOverride = null, ?GetPublicSettingsUseCase $useCaseOverride = null, ?SettingsController $controllerOverride = null ) { $this->repositoryOverride = $repositoryOverride; $this->useCaseOverride = $useCaseOverride; $this->controllerOverride = $controllerOverride; } /** * Bind the §13 repository, §13.1 use case, response mapper, * and composed {@see SettingsController}, then append the * §13.1 {@see RouteDefinition} to the bound * {@see RouteRegistrar}. * * Resolution order: * * 1. Resolve {@see OptionStore} via * {@see PersistenceServiceProvider::BINDING_OPTION_STORE}. * Missing binding raises {@see LogicException} so a * misordered bootstrap fails loudly here rather than at * the first REST request. * * 2. Build the {@see SettingsRepository} (using the * constructor override when supplied, else default- * construct an {@see OptionStoreSettingsRepository} * against the resolved {@see OptionStore}). * * 3. Build the {@see GetPublicSettingsUseCase} (using the * constructor override when supplied, else default- * construct against the resolved repository). * * 4. Build a fresh {@see PublicSettingsMapper}. * * 5. Build the {@see SettingsController} (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). * * 6. Bind all four under the `BINDING_*` keys and cache * the controller on this provider so the route-callback * closure can capture it. * * 7. Resolve {@see RouteRegistrar} via * {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}. * Missing binding raises {@see LogicException}. * * 8. Append the §13.1 {@see RouteDefinition} to the * registrar, wrapping * {@see SettingsController::readPublic()} in a thin * closure that extracts and strips the synthetic * {@see SettingsController::META_HTTP_STATUS} key. * * @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 { $optionStore = $this->resolveOptionStore( $registry ); $repository = $this->repositoryOverride ?? new OptionStoreSettingsRepository( $optionStore ); $useCase = $this->useCaseOverride ?? new GetPublicSettingsUseCase( $repository ); $mapper = new PublicSettingsMapper(); $controller = $this->controllerOverride ?? new SettingsController( $useCase, $mapper, new ProviderExceptionMapper(), new CapabilityPolicy(), new RequestValidator(), new RestRequestAdapter(), null, null, $repository ); $this->resolvedController = $controller; $registry->bind( self::BINDING_SETTINGS_REPOSITORY, $repository ); $registry->bind( self::BINDING_GET_PUBLIC_SETTINGS_USE_CASE, $useCase ); $registry->bind( self::BINDING_PUBLIC_SETTINGS_MAPPER, $mapper ); $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 OptionStore} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not an * {@see OptionStore}. */ private function resolveOptionStore( ServiceProviderRegistry $registry ): OptionStore { $key = PersistenceServiceProvider::BINDING_OPTION_STORE; if ( ! $registry->has( $key ) ) { $message = 'SettingsServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing OptionStore binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $optionStore = $registry->get( $key ); if ( ! $optionStore instanceof OptionStore ) { $message = sprintf( 'SettingsServiceProvider expected OptionStore under "%s"; got %s.', $key, is_object( $optionStore ) ? get_class( $optionStore ) : gettype( $optionStore ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $optionStore; } /** * 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 = 'SettingsServiceProvider 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( 'SettingsServiceProvider 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 §13.1 {@see RouteDefinition} to the supplied * registrar. * * The definition wraps {@see SettingsController::readPublic()} * in a thin {@see Closure} built by * {@see self::buildRouteCallback()} that extracts and strips * the synthetic {@see SettingsController::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, '/settings/public', RouteDefinition::METHOD_GET, SettingsController::ROUTE_TOKEN_PUBLIC_GET, array(), $this->buildRouteCallback( 'readPublic' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/settings/cloud/plans', RouteDefinition::METHOD_GET, SettingsController::ROUTE_TOKEN_CLOUD_PLANS, array(), $this->buildRouteCallback( 'cloudPlans' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/settings/cloud/credentials', RouteDefinition::METHOD_POST, SettingsController::ROUTE_TOKEN_CLOUD_APPLY_CREDENTIALS, array(), $this->buildRouteCallback( 'applyCloudCredentials' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/settings/cloud/checkout-session', RouteDefinition::METHOD_POST, SettingsController::ROUTE_TOKEN_CLOUD_CHECKOUT_SESSION, array(), $this->buildRouteCallback( 'createCloudCheckoutSession' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/settings/cloud/provision-site', RouteDefinition::METHOD_POST, SettingsController::ROUTE_TOKEN_CLOUD_PROVISION_SITE, array(), $this->buildRouteCallback( 'provisionCloudSite' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/settings/cloud/connection-email', RouteDefinition::METHOD_POST, SettingsController::ROUTE_TOKEN_CLOUD_CONNECTION_EMAIL, array(), $this->buildRouteCallback( 'sendCloudConnectionEmail' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/settings/cloud/status', RouteDefinition::METHOD_POST, SettingsController::ROUTE_TOKEN_CLOUD_STATUS, array(), $this->buildRouteCallback( 'readCloudStatus' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/settings/cloud/connection-token', RouteDefinition::METHOD_POST, SettingsController::ROUTE_TOKEN_CLOUD_CONNECTION_TOKEN, array(), $this->buildRouteCallback( 'exchangeCloudConnectionToken' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/settings/ai/extract-keywords', RouteDefinition::METHOD_POST, SettingsController::ROUTE_TOKEN_AI_EXTRACT_KEYWORDS, array(), $this->buildRouteCallback( 'extractAiKeywords' ), $permissionCallback ) ); } /** * Build a route-callback {@see Closure} that dispatches to * {@see SettingsController::readPublic()} 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. * * @param string $method Controller method token. * * @phpstan-return Closure(mixed): mixed */ private function buildRouteCallback( string $method ): Closure { return function ( mixed $request ) use ( $method ): mixed { $controller = $this->resolvedController; if ( $controller === null ) { $message = 'SettingsServiceProvider 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 ) { 'readPublic' => $controller->readPublic( $request ), 'cloudPlans' => $controller->cloudPlans( $request ), 'applyCloudCredentials' => $controller->applyCloudCredentials( $request ), 'createCloudCheckoutSession' => $controller->createCloudCheckoutSession( $request ), 'provisionCloudSite' => $controller->provisionCloudSite( $request ), 'sendCloudConnectionEmail' => $controller->sendCloudConnectionEmail( $request ), 'readCloudStatus' => $controller->readCloudStatus( $request ), 'exchangeCloudConnectionToken' => $controller->exchangeCloudConnectionToken( $request ), 'extractAiKeywords' => $controller->extractAiKeywords( $request ), default => throw new LogicException( 'Unknown settings controller route method.' ), }; $status = isset( $envelope[ SettingsController::META_HTTP_STATUS ] ) && is_int( $envelope[ SettingsController::META_HTTP_STATUS ] ) ? $envelope[ SettingsController::META_HTTP_STATUS ] : 200; unset( $envelope[ SettingsController::META_HTTP_STATUS ] ); if ( class_exists( '\\WP_REST_Response' ) ) { return new \WP_REST_Response( $envelope, $status ); } return array( 'data' => $envelope, 'status' => $status, ); }; } }