int)`. * The slice-6b-2B route registration layer extracts and strips * the {@see self::META_HTTP_STATUS} key when stamping the * `WP_REST_Response` HTTP status. The key is never serialized * onto the wire response — the registration layer is the sole * consumer. * * Permission resolution flow: * * 1. Each method names a stable route token (one of * {@see self::ROUTE_TOKEN_SEARCH} / `LIST` / `READ` / `WRITE`). * 2. {@see CapabilityPolicy::requiredForRead()} (or * `requiredForWrite()`) yields the OR-list of required caps. * 3. The controller probes each required cap via the injected * `$capabilityProvider` callable, populates a * {@see RequestContext} caps map with the held tokens, and * delegates to {@see CapabilityPolicy::isAuthorized()}. * 4. A failed authorization check short-circuits to a 403 * `affilun_forbidden` error envelope; the §8.x success path runs * otherwise. * * Exception mapping order in {@see self::mapException()}: * * 1. {@see ProductNotFoundException} (Application layer) → 404 * `affilun_product_not_found`. * 2. {@see ProviderException} (any §10.2 subclass) → routed * through {@see ProviderExceptionMapper::map()} which yields * the §15.2 envelope and HTTP status pair. * 3. {@see \InvalidArgumentException} → 422 * `affilun_validation_failed` carrying the exception message in * `details.exception` (developer-facing diagnosis hook for * VO-construction failures the request validator did not * catch). * 4. Catch-all {@see \Throwable} → 500 `affilun_internal_error` with * the literal message `'Internal server error.'`. The * original exception's class and message are NOT forwarded so * the §10.1 "no internal detail leakage" posture is preserved. * * Phase-6 deferrals (documented in code, never silently dropped): * * - §8.1 `is_saved` / saved-id resolution: the controller passes * `null` to {@see SearchResultItemMapper::map()} for every * snapshot. Probing the local `affilun_products` table for a * `(provider, provider_item_id)` hit is a future enhancement; * the §6.3 `is_saved` field therefore reads `false` on every * search-result item in this slice. * * - §8.4 `If-Match` conflict handling: the controller does NOT * consult the `If-Match` header. The §8.4 contract permits * this — "`If-Match` 未指定時は常に upsert を優先し、409 は * 発生させない" is the canonical posture; this slice extends * the same posture to `If-Match` clients pending a future * optimistic-locking slice. * * - §6.3 `provider_meta` enrichment: empty array via * {@see SearchResultItemMapper::map()} per its Phase-6 * contract; per-provider extraction lands in Phase 9+. * * - §6.1 / §6.2 image-hint resolution: the controller passes * `null` `ImageHints` to both summary and detail mappers, so * `image_primary_url` reads `null` and `images.gallery` reads * `[]`. Wiring `ProductMetaRepository` is a future slice. * * - §6.2 `usage` enrichment: the controller passes `null` * `UsageHints`, so `usage.embed_count` reads `0` and * `usage.post_ids` reads `[]`. Wiring lands in Phase 9. * * @package Affilun\Presentation\Rest\Controller */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Controller; use Affilun\Application\Product\Exception\ProductNotFoundException; use Affilun\Application\Product\ProductUrlDraft; use Affilun\Application\Product\UseCase\GetProductUseCase; use Affilun\Application\Product\UseCase\ListProductsUseCase; use Affilun\Application\Product\UseCase\SaveProductUseCase; use Affilun\Application\Product\UseCase\SearchProductsUseCase; use Affilun\Domain\Pricing\AvailabilityInfo; use Affilun\Domain\Pricing\PriceInfo; use Affilun\Domain\Product\ProductSnapshot; use Affilun\Domain\Provider\Exception\ProviderException; use Affilun\Domain\Provider\ProviderProductId; use Affilun\Domain\Search\SearchQuery; use Affilun\Infrastructure\Persistence\ProductListQuery; use Affilun\Infrastructure\Persistence\ProductRecord; use Affilun\Presentation\Rest\Mapper\ImageHints; use Affilun\Presentation\Rest\Mapper\ProductDetailMapper; use Affilun\Presentation\Rest\Mapper\ProductSummaryMapper; use Affilun\Presentation\Rest\Mapper\SearchResultItemMapper; use Affilun\Presentation\Rest\Permission\CapabilityPolicy; use Affilun\Presentation\Rest\RequestContext; use Affilun\Presentation\Rest\Response\ErrorEnvelope; use Affilun\Presentation\Rest\Response\ProviderExceptionMapper; use Affilun\Presentation\Rest\Response\SuccessEnvelope; use Affilun\Presentation\Rest\Validation\RequestValidator; use Affilun\Presentation\Rest\Validation\ValidationResult; use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; use Throwable; /** * REST controller for the §8.1-§8.4 product endpoints. * * See the file-level docblock for the synthetic * {@see self::META_HTTP_STATUS} envelope key, the permission * resolution flow, the exception mapping order, and the Phase-6 * deferrals. * * ### Nonce verification (§3.1) * * Per spec §3.1, all admin / block-editor REST calls authenticate via * the WordPress logged-in cookie plus the canonical `wp_rest` nonce * header (`X-WP-Nonce`), validated automatically by the WordPress REST * framework. The REST `permission_callback` checks capabilities at * route level, and this controller repeats the same authorization * (§3.2 / §3.3) via the injected `CapabilityPolicy` + capability provider; nonce * remains the WP framework's responsibility. */ final class ProductsController { /** * Stable route token for §8.1 `GET /affilun/v1/products/search`. */ public const ROUTE_TOKEN_SEARCH = 'products.search'; /** * Stable route token for §8.2 `GET /affilun/v1/products`. */ public const ROUTE_TOKEN_LIST = 'products.list'; /** * Stable route token for §8.3 `GET /affilun/v1/products/{id}`. */ public const ROUTE_TOKEN_READ = 'products.read'; /** * Stable route token for §8.4 `POST /affilun/v1/products`. */ public const ROUTE_TOKEN_WRITE = 'products.write'; /** * Synthetic envelope key carrying the desired HTTP status. * * The slice-6b-2B route registration layer extracts and strips * this key when stamping the `WP_REST_Response` status; the * key is never serialized onto the wire response. */ public const META_HTTP_STATUS = '_http_status'; /** * Closed-set tokens for the §8.1 `provider` query parameter. */ private const SEARCH_PROVIDER_TOKENS = array( 'amazon', 'rakuten', 'yahoo_shopping', 'mercari', 'all' ); /** * Maximum search-keyword length for ordinary provider searches. */ private const SEARCH_KEYWORDS_MAX_LENGTH = 200; /** * Amazon product URLs can include long localized slugs and * attribution parameters before the provider extracts the ASIN. */ private const AMAZON_SEARCH_INPUT_MAX_LENGTH = 2048; /** * Closed-set tokens for the §8.4 `provider` body field. */ private const WRITE_PROVIDER_TOKENS = array( 'amazon', 'rakuten', 'yahoo_shopping', 'mercari' ); /** * §8.1 search use case. */ private SearchProductsUseCase $searchUseCase; /** * §8.2 list use case. */ private ListProductsUseCase $listUseCase; /** * §8.3 single-product read use case. */ private GetProductUseCase $getUseCase; /** * §8.4 upsert use case. */ private SaveProductUseCase $saveUseCase; /** * §6.1 ProductSummary mapper. */ private ProductSummaryMapper $summaryMapper; /** * §6.2 ProductDetail mapper. */ private ProductDetailMapper $detailMapper; /** * §6.3 SearchResultItem mapper. */ private SearchResultItemMapper $searchItemMapper; /** * §15.2 provider exception → error envelope mapper. */ private ProviderExceptionMapper $exceptionMapper; /** * §3.2 / §3.3 capability policy. */ private CapabilityPolicy $capabilityPolicy; /** * §17.1 request input validator. */ private RequestValidator $requestValidator; /** * §3 request adapter (header / param / body extraction). */ private RestRequestAdapter $requestAdapter; /** * Resolver for the current WordPress user id. * * Defaults to a static closure that calls * {@see \get_current_user_id()} when available and returns null * otherwise (so unit tests run without booting WordPress). * * @var callable(): ?int */ private $userIdProvider; /** * Resolver for whether the current user holds a given capability. * * Defaults to a static closure that calls * {@see \current_user_can()} when available and returns false * otherwise (so unit tests run without booting WordPress). * * @var callable(string): bool */ private $capabilityProvider; /** * Construct the controller with explicit dependency injection. * * The optional `$userIdProvider` / `$capabilityProvider` * callables follow the established * {@see \Affilun\Application\Render\RenderServiceProvider} * pattern: when null they fall back to WordPress-aware static * closures that gracefully degrade to "anonymous / no caps" * when the WordPress runtime is not loaded (the unit-test * harness). * * @param SearchProductsUseCase $searchUseCase §8.1 search use case. * @param ListProductsUseCase $listUseCase §8.2 list use case. * @param GetProductUseCase $getUseCase §8.3 read use case. * @param SaveProductUseCase $saveUseCase §8.4 upsert use case. * @param ProductSummaryMapper $summaryMapper §6.1 mapper. * @param ProductDetailMapper $detailMapper §6.2 mapper. * @param SearchResultItemMapper $searchItemMapper §6.3 mapper. * @param ProviderExceptionMapper $exceptionMapper §15.2 mapper. * @param CapabilityPolicy $capabilityPolicy §3.2 / §3.3 policy. * @param RequestValidator $requestValidator §17.1 validator. * @param RestRequestAdapter $requestAdapter §3 request adapter. * @param callable|null $userIdProvider `(): ?int` resolver, or null to use the WP default. * @param callable|null $capabilityProvider `(string): bool` resolver, or null to use the WP default. */ public function __construct( SearchProductsUseCase $searchUseCase, ListProductsUseCase $listUseCase, GetProductUseCase $getUseCase, SaveProductUseCase $saveUseCase, ProductSummaryMapper $summaryMapper, ProductDetailMapper $detailMapper, SearchResultItemMapper $searchItemMapper, ProviderExceptionMapper $exceptionMapper, CapabilityPolicy $capabilityPolicy, RequestValidator $requestValidator, RestRequestAdapter $requestAdapter, ?callable $userIdProvider = null, ?callable $capabilityProvider = null ) { $this->searchUseCase = $searchUseCase; $this->listUseCase = $listUseCase; $this->getUseCase = $getUseCase; $this->saveUseCase = $saveUseCase; $this->summaryMapper = $summaryMapper; $this->detailMapper = $detailMapper; $this->searchItemMapper = $searchItemMapper; $this->exceptionMapper = $exceptionMapper; $this->capabilityPolicy = $capabilityPolicy; $this->requestValidator = $requestValidator; $this->requestAdapter = $requestAdapter; $this->userIdProvider = $userIdProvider ?? static function (): ?int { if ( ! function_exists( 'get_current_user_id' ) ) { return null; } $id = (int) get_current_user_id(); return $id !== 0 ? $id : null; }; $this->capabilityProvider = $capabilityProvider ?? static function ( string $cap ): bool { if ( ! function_exists( 'current_user_can' ) ) { return false; } return (bool) current_user_can( $cap ); }; } /** * §8.1 `GET /affilun/v1/products/search` dispatch. * * Validates `provider` / `keywords` / `page` / `per_page`, * builds a {@see SearchQuery}, drives * {@see SearchProductsUseCase}, and renders the §8.1 success * envelope. `meta.warnings` is populated when the bundle * carries any captured per-provider exceptions (the §8.1 * partial-success contract). * * Phase-6 deferrals: per-snapshot `savedId` is always null; * `is_saved` therefore reads `false` on every item. See the * file-level docblock for the rationale. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function search( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_SEARCH ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $provider = $this->requestAdapter->paramString( $request, 'provider' ); $keywords = $this->requestAdapter->paramString( $request, 'keywords' ); $page = $this->requestAdapter->paramInt( $request, 'page', 1 ); $perPage = $this->requestAdapter->paramInt( $request, 'per_page', 20 ); $sort = $this->requestAdapter->paramString( $request, 'sort' ); $keywordsMaxLength = $provider === 'amazon' ? self::AMAZON_SEARCH_INPUT_MAX_LENGTH : self::SEARCH_KEYWORDS_MAX_LENGTH; $result = ValidationResult::valid(); $result = $this->requestValidator->requireOneOf( $result, 'provider', $provider, self::SEARCH_PROVIDER_TOKENS ); $result = $this->requestValidator->requireString( $result, 'keywords', $keywords, 1, $keywordsMaxLength ); $result = $this->requestValidator->requireInt( $result, 'page', $page, 1, PHP_INT_MAX ); $result = $this->requestValidator->requireInt( $result, 'per_page', $perPage, 1, 50 ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } // Validation has guaranteed these are non-null with the // expected types; refine for the static analyzer. $providerToken = (string) $provider; $keywordValue = (string) $keywords; $pageValue = (int) $page; $perPageValue = (int) $perPage; try { $query = new SearchQuery( $keywordValue, $pageValue, $perPageValue, $sort, null, null, null, array(), null, null, null ); $bundle = $this->searchUseCase->execute( $query, $providerToken ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } /** @var list> $items */ $items = array(); $totalItems = 0; foreach ( $bundle->results() as $perProviderResult ) { foreach ( $perProviderResult->items() as $snapshot ) { $items[] = $this->searchItemMapper->map( $snapshot, null ); } $total = $perProviderResult->total(); $totalItems += $total ?? $perProviderResult->count(); } $totalPages = $totalItems === 0 ? 0 : (int) max( 1, (int) ceil( $totalItems / $perPageValue ) ); $pagination = array( 'page' => $pageValue, 'per_page' => $perPageValue, 'total_items' => $totalItems, 'total_pages' => $totalPages, ); $warnings = $this->buildWarnings( $bundle->providerWarnings(), $ctx ); $extraMeta = array(); if ( $warnings !== array() ) { $extraMeta['warnings'] = $warnings; } $envelope = SuccessEnvelope::fromContext( array( 'items' => $items, 'pagination' => $pagination, ), $ctx, $extraMeta ); return $this->successResponse( $envelope, 200 ); } /** * §8.2 `GET /affilun/v1/products` dispatch. * * Validates the §5.1 / §5.2 / §5.3 query parameters and the * closed-set filter tokens, builds a {@see ProductListQuery} * (catching its construction-time validation), and drives * {@see ListProductsUseCase}. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.2 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function list( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_LIST ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $page = $this->requestAdapter->paramInt( $request, 'page', 1 ); $perPage = $this->requestAdapter->paramInt( $request, 'per_page', 20 ); $provider = $this->requestAdapter->paramString( $request, 'provider' ); $search = $this->requestAdapter->paramString( $request, 'search' ); $syncState = $this->requestAdapter->paramString( $request, 'sync_state' ); $usedInPostId = $this->requestAdapter->paramInt( $request, 'used_in_post_id' ); $lifecycleState = $this->requestAdapter->paramString( $request, 'lifecycle_state' ); $sort = $this->requestAdapter->paramString( $request, 'sort' ); $order = $this->requestAdapter->paramString( $request, 'order' ); $result = ValidationResult::valid(); $result = $this->requestValidator->requireInt( $result, 'page', $page, 1, PHP_INT_MAX ); $result = $this->requestValidator->requireInt( $result, 'per_page', $perPage, 1, 50 ); $result = $this->requestValidator->optionalString( $result, 'provider', $provider, 1, 64 ); $result = $this->requestValidator->optionalString( $result, 'search', $search, 1, 200 ); $result = $this->requestValidator->optionalString( $result, 'sync_state', $syncState, 1, 64 ); $result = $this->requestValidator->optionalInt( $result, 'used_in_post_id', $usedInPostId, 1, PHP_INT_MAX ); $result = $this->requestValidator->optionalString( $result, 'lifecycle_state', $lifecycleState, 1, 64 ); $result = $this->requestValidator->optionalString( $result, 'sort', $sort, 1, 64 ); $result = $this->requestValidator->optionalString( $result, 'order', $order, 1, 8 ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $query = new ProductListQuery( (int) $page, (int) $perPage, $provider, $search, $syncState, $usedInPostId, $lifecycleState ?? ProductListQuery::LIFECYCLE_STATE_ACTIVE, $sort ?? ProductListQuery::SORT_KEY_UPDATED_AT, $order ?? ProductListQuery::SORT_ORDER_DESC ); $listResult = $this->listUseCase->execute( $query ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $items = array(); foreach ( $listResult->items() as $record ) { /** @var ProductRecord $record */ $items[] = $this->summaryMapper->map( $record, $this->imageHintsFromRecord( $record ) ); } $pagination = array( 'page' => $listResult->page(), 'per_page' => $listResult->perPage(), 'total_items' => $listResult->totalItems(), 'total_pages' => $listResult->totalPages(), ); $envelope = SuccessEnvelope::list( $items, $pagination, $ctx ); return $this->successResponse( $envelope, 200 ); } /** * Build mapper image hints from the persisted product snapshot. * * Product rows already carry the primary image URL saved from * provider search results. Older rows or manually imported rows may * have no image, and malformed legacy URLs are treated as missing so * one bad row does not break the whole products list endpoint. * * @param ProductRecord $record Persisted record. */ private function imageHintsFromRecord( ProductRecord $record ): ImageHints { $primaryUrl = $record->imagePrimaryUrl(); if ( $primaryUrl === null || $primaryUrl === '' ) { return ImageHints::none(); } try { return new ImageHints( $primaryUrl ); } catch ( InvalidArgumentException ) { return ImageHints::none(); } } /** * §8.3 `GET /affilun/v1/products/{id}` dispatch. * * Validates the path id, drives {@see GetProductUseCase}, and * renders §6.2 ProductDetail. {@see ProductNotFoundException} * surfaces as 404 via {@see self::mapException()}. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function read( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_READ ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $id = $this->requestAdapter->paramInt( $request, 'id' ); $result = ValidationResult::valid(); $result = $this->requestValidator->requireInt( $result, 'id', $id, 1, PHP_INT_MAX ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $record = $this->getUseCase->execute( (int) $id ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( $this->detailMapper->map( $record, null, null ), $ctx ); return $this->successResponse( $envelope, 200 ); } /** * §8.4 `POST /affilun/v1/products` dispatch. * * Validates `provider` / `provider_item_id` / * `snapshot.title` / `snapshot.detail_url`, builds a * {@see ProductSnapshot} (catching VO-construction failures as * 422), drives {@see SaveProductUseCase}, and renders §8.4 * with HTTP status 201 on insert and 200 on update. * * Phase-6 deferral: the controller does NOT consult the * `If-Match` header. The §8.4 contract permits unconditional * upsert when `If-Match` is absent; this slice extends that * posture across the board pending a future * optimistic-locking slice. * * Snapshot fields not derivable from the request body are * assigned safe defaults: `availability` → * {@see AvailabilityInfo::unknown()}, `affiliateUrl` / `rating` * / `reviewCount` → null, `categories` / `attributes` / * `rawMeta` → empty arrays, `fetchedAt` → "now" in UTC. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §8.4 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function write( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_WRITE ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $provider = $this->extractStringField( $body, 'provider' ); $providerItemId = $this->extractStringField( $body, 'provider_item_id' ); $snapshotInput = array_key_exists( 'snapshot', $body ) ? $body['snapshot'] : null; $snapshotIsMap = is_array( $snapshotInput ) && ( $snapshotInput === array() || ! array_is_list( $snapshotInput ) ); $snapshotMap = $snapshotIsMap ? $snapshotInput : array(); $title = $this->extractStringField( $snapshotMap, 'title' ); $detailUrl = $this->extractStringField( $snapshotMap, 'detail_url' ); $affiliateUrl = $this->extractStringField( $snapshotMap, 'affiliate_url' ); $brand = $this->extractStringField( $snapshotMap, 'brand' ); $description = $this->extractStringField( $snapshotMap, 'description' ); $primaryUrl = $this->extractStringField( $snapshotMap, 'image_primary_url' ); $result = ValidationResult::valid(); $result = $this->requestValidator->requireOneOf( $result, 'provider', $provider, self::WRITE_PROVIDER_TOKENS ); $result = $this->requestValidator->requireString( $result, 'provider_item_id', $providerItemId, 1, 128 ); if ( ! $snapshotIsMap ) { $result = $result->withField( 'snapshot', 'Must be an object.' ); } $result = $this->requestValidator->requireString( $result, 'title', $title, 1, 512 ); $result = $this->requireHttpUrl( $result, 'detail_url', $detailUrl ); if ( $primaryUrl !== null ) { $result = $this->requireHttpUrl( $result, 'image_primary_url', $primaryUrl ); } if ( $affiliateUrl !== null ) { $result = $this->requireHttpUrl( $result, 'affiliate_url', $affiliateUrl ); } $gallery = $this->validateGallery( $result, $snapshotMap ); $result = $gallery['result']; $priceFields = $this->validatePriceFields( $result, $snapshotMap ); $result = $priceFields['result']; if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); $price = $priceFields['price'] !== null ? new PriceInfo( $priceFields['price']['amount'], $priceFields['price']['currency'], $priceFields['price']['label'], $now, false, false, null ) : null; $snapshot = new ProductSnapshot( (string) $provider, new ProviderProductId( (string) $provider, (string) $providerItemId ), (string) $title, $brand, $description, (string) $detailUrl, $affiliateUrl, $primaryUrl, $gallery['urls'], $price, AvailabilityInfo::unknown(), null, null, array(), array(), $now, array() ); $saveResult = $this->saveUseCase->execute( $snapshot, $this->extractProductUrlDrafts( $body ) ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $status = $saveResult->created() ? 201 : 200; $envelope = SuccessEnvelope::fromContext( array( 'id' => $saveResult->record()->id(), 'created' => $saveResult->created(), 'product' => $this->summaryMapper->map( $saveResult->record(), null ), ), $ctx ); return $this->successResponse( $envelope, $status ); } /** * Extract child provider URL drafts from the write payload. * * @param array $body Request body. * * @return list */ private function extractProductUrlDrafts( array $body ): array { if ( ! array_key_exists( 'provider_urls', $body ) || $body['provider_urls'] === null ) { return array(); } $raw = $body['provider_urls']; if ( ! is_array( $raw ) || ! array_is_list( $raw ) ) { throw new InvalidArgumentException( 'provider_urls must be a list.' ); } $drafts = array(); foreach ( $raw as $index => $entry ) { if ( ! is_array( $entry ) ) { throw new InvalidArgumentException( sprintf( 'provider_urls[%d] must be an object.', (int) $index ) ); } $provider = $this->extractStringField( $entry, 'provider_type' ) ?? $this->extractStringField( $entry, 'provider' ); $url = $this->extractStringField( $entry, 'url' ) ?? $this->extractStringField( $entry, 'detail_url' ); $providerItemId = $this->extractStringField( $entry, 'provider_item_id' ); $source = $this->extractStringField( $entry, 'source' ) ?? 'user'; if ( $provider === null || ! in_array( $provider, self::WRITE_PROVIDER_TOKENS, true ) ) { throw new InvalidArgumentException( sprintf( 'provider_urls[%d].provider_type is invalid.', (int) $index ) ); } if ( $url === null || preg_match( '#\Ahttps?://#i', $url ) !== 1 ) { throw new InvalidArgumentException( sprintf( 'provider_urls[%d].url must be an HTTP(S) URL.', (int) $index ) ); } $drafts[] = new ProductUrlDraft( $provider, $providerItemId, $url, $source ); } return $drafts; } /** * Build the per-request {@see RequestContext} carrying the * resolved user id and the currently-held subset of the * supplied required-capability list. * * The caps map is populated by probing each candidate cap via * the injected `$capabilityProvider`; only caps the provider * confirms are entered into the map. {@see CapabilityPolicy::isAuthorized()} * therefore behaves correctly against the resulting context. * * @param mixed $request WP_REST_Request, or array shim. * @param list $candidateCaps Caps to probe and populate. */ private function buildContext( mixed $request, array $candidateCaps ): RequestContext { $userIdResolver = $this->userIdProvider; $userId = $userIdResolver(); if ( $userId !== null && ! is_int( $userId ) ) { $userId = null; } $capResolver = $this->capabilityProvider; $caps = array(); foreach ( $candidateCaps as $cap ) { if ( $capResolver( $cap ) === true ) { $caps[ $cap ] = 'true'; } } return $this->requestAdapter->buildRequestContext( $request, $userId, $caps ); } /** * Build a §4.3 error envelope + synthetic * {@see self::META_HTTP_STATUS}. * * @param string $code §15.1 code. * @param string $message Human-readable summary. * @param int $status §14.1 HTTP status. * @param RequestContext $ctx Per-request meta. * @param array $details Structured diagnostic context. * @param bool $retryable §10.2 retryability hint. * * @return array */ private function errorResponse( string $code, string $message, int $status, RequestContext $ctx, array $details = array(), bool $retryable = false ): array { $envelope = ErrorEnvelope::fromContext( $code, $message, $status, $ctx, $details, $retryable ); return $envelope->toArray() + array( self::META_HTTP_STATUS => $status ); } /** * Build a §4.4 validation error envelope + synthetic * {@see self::META_HTTP_STATUS} pinned to 422. * * @param ValidationResult $result Accumulator carrying the * per-field errors. * @param RequestContext $ctx Per-request meta. * * @return array */ private function validationErrorResponse( ValidationResult $result, RequestContext $ctx ): array { $envelope = $result->toEnvelope( $ctx ); return $envelope->toArray() + array( self::META_HTTP_STATUS => ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY ); } /** * Wrap a {@see SuccessEnvelope} into the response array shape * the route registration layer consumes. * * @param SuccessEnvelope $envelope Source envelope. * @param int $status §14.1 HTTP status to stamp. * * @return array */ private function successResponse( SuccessEnvelope $envelope, int $status ): array { return $envelope->toArray() + array( self::META_HTTP_STATUS => $status ); } /** * Translate an arbitrary {@see Throwable} into a §4.3 envelope * + synthetic {@see self::META_HTTP_STATUS}. * * Mapping order: * * 1. {@see ProductNotFoundException} (Application layer) → 404. * 2. {@see ProviderException} → routed through * {@see ProviderExceptionMapper}. * 3. {@see InvalidArgumentException} → 422 with * `details.exception = $e->getMessage()`. * 4. Other {@see Throwable} → 500 `affilun_internal_error` with * the literal message `'Internal server error.'`. * * @param Throwable $e Exception to translate. * @param RequestContext $ctx Per-request meta. * * @return array */ private function mapException( Throwable $e, RequestContext $ctx ): array { if ( $e instanceof ProductNotFoundException ) { return $this->errorResponse( ErrorEnvelope::CODE_PRODUCT_NOT_FOUND, $e->getMessage() !== '' ? $e->getMessage() : 'Product not found.', ErrorEnvelope::STATUS_NOT_FOUND, $ctx ); } if ( $e instanceof ProviderException ) { $envelope = $this->exceptionMapper->map( $e, $ctx ); return $envelope->toArray() + array( self::META_HTTP_STATUS => $envelope->status() ); } if ( $e instanceof InvalidArgumentException ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Validation failed.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'exception' => $e->getMessage() ) ); } // Log the actual exception class + message + first frame so // operators can correlate the opaque 500 envelope with the // underlying defect via WP_DEBUG_LOG. The response body still // carries only the literal `'Internal server error.'` per // the §10.1 "no internal detail leakage" posture. if ( function_exists( 'error_log' ) ) { $trace_line = ''; $trace = $e->getTrace(); if ( isset( $trace[0] ) && is_array( $trace[0] ) ) { $file = isset( $trace[0]['file'] ) ? (string) $trace[0]['file'] : '?'; $line = isset( $trace[0]['line'] ) ? (string) $trace[0]['line'] : '?'; $trace_line = ' at ' . $file . ':' . $line; } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional diagnostic logging for unmapped 500s; see surrounding comment. error_log( sprintf( 'AFFILUN ProductsController unmapped exception: %s: %s%s', get_class( $e ), $e->getMessage(), $trace_line ) ); } return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Internal server error.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } /** * Build the §8.1 `meta.warnings` payload from a bundle's * captured per-provider exceptions. * * Each captured exception is routed through * {@see ProviderExceptionMapper::map()} so the §15.2 stable * code surfaces verbatim onto each warning entry. The HTTP * status the mapper picks is discarded — `meta.warnings` * carries no status field. * * @param array $providerWarnings Captured exceptions. * @param RequestContext $ctx Per-request meta. * * @return list> */ private function buildWarnings( array $providerWarnings, RequestContext $ctx ): array { $warnings = array(); foreach ( $providerWarnings as $token => $exception ) { $envelope = $this->exceptionMapper->map( $exception, $ctx ); $warnings[] = array( 'provider' => $token, 'code' => $envelope->code(), 'message' => $envelope->message(), ); } return $warnings; } /** * Pull a string field from a body map, returning null when * absent or non-string. * * @param array $source Body map. * @param string $key Field name. */ private function extractStringField( array $source, string $key ): ?string { if ( ! array_key_exists( $key, $source ) ) { return null; } $value = $source[ $key ]; if ( ! is_string( $value ) ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { return null; } return $trimmed; } /** * Validate an HTTP / HTTPS URL field; appends a single message * on failure. * * @param ValidationResult $r Running accumulator. * @param string $field Field name. * @param mixed $value Value to inspect. */ private function requireHttpUrl( ValidationResult $r, string $field, mixed $value ): ValidationResult { if ( ! is_string( $value ) || $value === '' ) { return $r->withField( $field, 'Must be an http(s) URL.' ); } if ( preg_match( '#\Ahttps?://#i', $value ) !== 1 ) { return $r->withField( $field, 'Must be an http(s) URL.' ); } if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) { return $r->withField( $field, 'Must be an http(s) URL.' ); } return $r; } /** * Validate the optional `snapshot.image_gallery` field. * * Returns the running accumulator alongside the normalized * list of gallery URLs (empty list when the field is absent or * fails validation; the controller relies on the validator's * `isValid()` short-circuit before consuming the URLs). * * @param ValidationResult $r Running accumulator. * @param array $snapshotMap Snapshot body map. * * @return array{result: ValidationResult, urls: list} */ private function validateGallery( ValidationResult $r, array $snapshotMap ): array { if ( ! array_key_exists( 'image_gallery', $snapshotMap ) ) { return array( 'result' => $r, 'urls' => array(), ); } $gallery = $snapshotMap['image_gallery']; if ( ! is_array( $gallery ) || ! array_is_list( $gallery ) ) { return array( 'result' => $r->withField( 'image_gallery', 'Must be a list.' ), 'urls' => array(), ); } $urls = array(); $result = $r; foreach ( $gallery as $entry ) { if ( ! is_string( $entry ) || preg_match( '#\Ahttps?://#i', $entry ) !== 1 ) { $result = $result->withField( 'image_gallery', 'Every entry must be an http(s) URL.' ); continue; } $urls[] = $entry; } return array( 'result' => $result, 'urls' => $urls, ); } /** * Validate the optional `snapshot.price` sub-object. * * Returns the running accumulator alongside the extracted * price triple (`amount` / `currency` / `label`) when the * field is present and well-shaped, or `null` when absent. * Validation failures populate the accumulator and the * returned price triple is `null`. * * @param ValidationResult $r Running accumulator. * @param array $snapshotMap Snapshot body map. * * @return array{result: ValidationResult, price: array{amount: string, currency: ?string, label: ?string}|null} */ private function validatePriceFields( ValidationResult $r, array $snapshotMap ): array { if ( ! array_key_exists( 'price', $snapshotMap ) ) { return array( 'result' => $r, 'price' => null, ); } $price = $snapshotMap['price']; if ( ! is_array( $price ) || ( $price !== array() && array_is_list( $price ) ) ) { return array( 'result' => $r->withField( 'price', 'Must be an object.' ), 'price' => null, ); } $amount = array_key_exists( 'amount', $price ) ? $price['amount'] : null; $currency = array_key_exists( 'currency', $price ) ? $price['currency'] : null; $label = array_key_exists( 'label', $price ) ? $price['label'] : null; $result = $r; $result = $this->requestValidator->requireString( $result, 'amount', $amount, 1, 32 ); if ( $currency !== null ) { $result = $this->requestValidator->requireString( $result, 'currency', $currency, 1, 16 ); } if ( $label !== null ) { $result = $this->requestValidator->requireString( $result, 'label', $label, 1, 64 ); } if ( ! is_string( $amount ) ) { return array( 'result' => $result, 'price' => null, ); } return array( 'result' => $result, 'price' => array( 'amount' => $amount, 'currency' => is_string( $currency ) ? $currency : null, 'label' => is_string( $label ) ? $label : null, ), ); } }