|null */ public static function pending(): ?array { $response = SignedClient::post( self::PENDING, SignedClient::body( array() ) ); if ( ! $response->ok() ) { self::log_failure( 'what is waiting', $response ); return null; } $products = is_array( $response->json ) && isset( $response->json['products'] ) && is_array( $response->json['products'] ) ? $response->json['products'] : null; if ( null === $products ) { Log::error( 'Approved-product sync: CortexCommerce answered "what is waiting" without a product list.' ); return null; } $ids = array(); foreach ( $products as $product ) { $id = is_array( $product ) && isset( $product['product_id'] ) && is_scalar( $product['product_id'] ) ? (string) $product['product_id'] : ''; if ( 1 === preg_match( '/^[1-9]\d*$/', $id ) && ! in_array( $id, $ids, true ) ) { $ids[] = $id; } } return $ids; } /** * Outcome: ok, not_ready (404), bad_image (answer understood, but an image entry has no usable id; fields then * holds the rest and writeback_id is set, so the sync can report image_not_found), mismatch (a signed answer for * another store or product: nothing is written, and writeback_id is set when the answer had one, so the sync can * report it) or skip (anything else, including an answer that fails SyncAnswer's checks). * * @return array{outcome: string, writeback_id: string, fields: array} */ public static function product( string $product_id ): array { $nonce = SyncAnswer::nonce(); $response = SignedClient::post( self::PRODUCT, SignedClient::body( array( 'product_id' => $product_id, 'nonce' => $nonce, ) ) ); if ( self::is_not_ready( $response ) ) { return self::offer( 'not_ready' ); } if ( ! $response->ok() ) { self::log_failure( 'get product ' . $product_id, $response ); return self::offer( 'skip' ); } $check = SyncAnswer::check( $response, $product_id, $nonce ); if ( SyncAnswer::REFUSED === $check ) { return self::offer( 'skip' ); } $json = is_array( $response->json ) ? $response->json : array(); $wb_id = isset( $json['writeback_id'] ) && is_string( $json['writeback_id'] ) ? $json['writeback_id'] : ''; if ( SyncAnswer::MISMATCH === $check ) { return self::offer( '' === $wb_id ? 'skip' : 'mismatch', $wb_id ); } return self::accepted( $json, $product_id, $wb_id ); } private static function is_not_ready( SignedResponse $response ): bool { return 404 === $response->status && is_array( $response->json ) && isset( $response->json['error'] ) && 'not_ready' === $response->json['error']; } /** * An answer that passed SyncAnswer's checks: ok or bad_image when it names the product, an approval and fields of * the right types; skip otherwise. * * @param array $json The decoded answer. * @return array{outcome: string, writeback_id: string, fields: array} */ private static function accepted( array $json, string $product_id, string $wb_id ): array { $parsed = array_key_exists( 'fields', $json ) ? self::parse( $json['fields'], $product_id ) : null; $same = isset( $json['product_id'] ) && is_scalar( $json['product_id'] ) && (string) $json['product_id'] === $product_id; if ( null === $parsed || ! $same || '' === $wb_id ) { Log::error( sprintf( 'Approved-product sync: unexpected answer for product %s; skipped until the next sync.', $product_id ) ); return self::offer( 'skip' ); } // "bad_image": an image entry the plugin cannot use; the sync reports it as image_not_found. return self::offer( $parsed['bad_image'] ? 'bad_image' : 'ok', $wb_id, $parsed['fields'] ); } /** * @param array $fields Approved fields. * @return array{outcome: string, writeback_id: string, fields: array} */ private static function offer( string $outcome, string $writeback_id = '', array $fields = array() ): array { return array( 'outcome' => $outcome, 'writeback_id' => $writeback_id, 'fields' => $fields, ); } /** * Only the known keys that are present; null when one of them has the wrong type, or an image entry is unusable. * An unusable new_image is logged and left out, and never fails the rest. * * @param mixed $raw The answer's "fields". * @return array|null */ public static function fields( $raw ): ?array { $parsed = self::parse( $raw, 'unknown' ); return null === $parsed || $parsed['bad_image'] ? null : $parsed['fields']; } /** * Like fields(), but an image entry without a usable id does not fail the parse: it is flagged, and image_alts is * left out of fields. * * @param mixed $raw The answer's "fields". * @param string $product_id For the log. * @return array{fields: array, bad_image: bool}|null */ private static function parse( $raw, string $product_id ): ?array { if ( ! is_array( $raw ) ) { return null; } self::log_unknown( array_keys( $raw ), self::FIELD_KEYS, 'fields', $product_id ); if ( isset( $raw['seo'] ) && is_array( $raw['seo'] ) ) { self::log_unknown( array_keys( $raw['seo'] ), self::SEO_KEYS, 'seo', $product_id ); } // Each parser gives null for a value of the wrong type, which fails the whole answer. $fields = self::strings( $raw, self::TEXT_FIELDS ); if ( array_key_exists( 'tags', $raw ) ) { $fields['tags'] = self::tags( $raw['tags'] ); } if ( array_key_exists( 'seo', $raw ) ) { $fields['seo'] = is_array( $raw['seo'] ) ? self::strings( $raw['seo'], self::SEO_KEYS ) : null; } $alts = array_key_exists( 'image_alts', $raw ) ? self::image_alts( $raw['image_alts'] ) : array( 'alts' => null, 'bad' => false, ); if ( null === $alts || self::has_wrong_type( $fields ) ) { return null; } if ( null !== $alts['alts'] && ! $alts['bad'] ) { $fields['image_alts'] = $alts['alts']; } if ( array_key_exists( 'new_image', $raw ) ) { $fields += self::new_image_field( $raw['new_image'], $product_id ); } return array( 'fields' => $fields, 'bad_image' => $alts['bad'], ); } /** * Whether a parser gave null for one of the fields, or for one of the SEO values. * * @param array $fields Parsed fields. */ private static function has_wrong_type( array $fields ): bool { return in_array( null, $fields, true ) || ( isset( $fields['seo'] ) && in_array( null, $fields['seo'], true ) ); } /** * The $keys present in $raw, each its string value, or null when it is not a string. * * @param array $raw Values received. * @param array $keys Keys to take. * @return array */ private static function strings( array $raw, array $keys ): array { $out = array(); foreach ( $keys as $key ) { if ( array_key_exists( $key, $raw ) ) { $out[ $key ] = is_string( $raw[ $key ] ) ? $raw[ $key ] : null; } } return $out; } /** * @param mixed $raw The answer's "tags". * @return array|null A list of strings, or null. */ private static function tags( $raw ): ?array { if ( ! is_array( $raw ) || array_values( $raw ) !== $raw || count( array_filter( $raw, 'is_string' ) ) !== count( $raw ) ) { return null; } return $raw; } /** * Null when the list or an entry has the wrong type. bad is true when an entry has no usable image id: the answer * is still understood, and the sync reports image_not_found. * * @param mixed $raw The answer's "image_alts". * @return array{alts: array, bad: bool}|null */ private static function image_alts( $raw ): ?array { if ( ! is_array( $raw ) ) { return null; } $alts = array(); $bad = false; foreach ( $raw as $item ) { if ( ! is_array( $item ) || ! isset( $item['alt'] ) || ! is_string( $item['alt'] ) ) { return null; } $image_id = isset( $item['image_id'] ) && is_scalar( $item['image_id'] ) ? (string) $item['image_id'] : ''; if ( 1 !== preg_match( '/^[1-9]\d*$/', $image_id ) ) { $bad = true; continue; } $alts[] = array( 'image_id' => (int) $image_id, 'alt' => $item['alt'], ); } return array( 'alts' => $alts, 'bad' => $bad, ); } /** * array( 'new_image' => … ) when it can be used; otherwise logged, and nothing, so the rest is written without it. * * @param mixed $raw The answer's "new_image". * @return array */ private static function new_image_field( $raw, string $product_id ): array { $new_image = self::new_image( $raw ); if ( null === $new_image ) { Log::error( sprintf( 'Approved-product sync: CortexCommerce sent a new image for product %s the plugin cannot use; the rest is written without it.', $product_id ) ); return array(); } return array( 'new_image' => $new_image ); } /** * @param mixed $raw The answer's "new_image". * @return array{url: string, alt: string, image_id: string}|null Null unless it is an HTTPS URL on the allowed host, * a string alt and a non-empty image id. */ private static function new_image( $raw ): ?array { if ( ! is_array( $raw ) || ! isset( $raw['url'], $raw['alt'], $raw['image_id'] ) ) { return null; } if ( ! is_string( $raw['url'] ) || ! is_string( $raw['alt'] ) || ! is_string( $raw['image_id'] ) || '' === $raw['image_id'] ) { return null; } $url = wp_parse_url( $raw['url'] ); if ( ! is_array( $url ) || 'https' !== ( $url['scheme'] ?? '' ) || SyncImageWriter::allowed_host() !== ( $url['host'] ?? '' ) ) { return null; } return array( 'url' => $raw['url'], 'alt' => $raw['alt'], 'image_id' => $raw['image_id'], ); } /** * @param array $before Fields as they were in WordPress. * @param array $after Fields as written. */ public static function synced( string $product_id, string $writeback_id, array $before, array $after ): bool { return self::status( array( 'product_id' => $product_id, 'writeback_id' => $writeback_id, 'status' => 'synced', // Objects even when empty: {} rather than []. 'before' => (object) $before, 'after' => (object) $after, ) ); } public static function failed( string $product_id, string $writeback_id, int $attempt, string $reason ): bool { return self::status( array( 'product_id' => $product_id, 'writeback_id' => $writeback_id, 'status' => 'failed', 'attempt' => $attempt, 'reason' => $reason, ) ); } /** * @param array $fields Status body after the envelope. */ private static function status( array $fields ): bool { $response = SignedClient::post( self::STATUS, SignedClient::body( $fields ) ); if ( $response->ok() ) { return true; } self::log_failure( 'update status of product ' . $fields['product_id'], $response ); return false; } /** * Logs the keys the plugin does not write, by name only (never a value). A name that is not a plain identifier is * logged as such, so nothing from the answer reaches the log unfiltered. * * @param array $keys Keys received. * @param array $known Keys written. */ private static function log_unknown( array $keys, array $known, string $where, string $product_id ): void { $unknown = array(); foreach ( $keys as $key ) { if ( ! in_array( $key, $known, true ) ) { $unknown[] = 1 === preg_match( '/^[a-z0-9_]{1,40}$/', (string) $key ) ? (string) $key : '(not an identifier)'; } } if ( array() !== $unknown ) { Log::warning( sprintf( 'Approved-product sync: ignored %s %s for product %s (not something the plugin writes).', $where, implode( ', ', array_unique( $unknown ) ), $product_id ) ); } } private static function log_failure( string $call, SignedResponse $response ): void { if ( SignedResponse::RETRY === $response->kind ) { Log::warning( sprintf( 'Approved-product sync: "%s" did not get through (%s); the next sync tries again.', $call, $response->describe() ) ); return; } Log::error( sprintf( 'Approved-product sync: CortexCommerce refused "%s": %s.', $call, $response->describe() ) ); } }