| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS sync read surface. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\API\V2 |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\API\V2; |
| 9 |
|
| 10 |
use WCPOS\WooCommercePOS\Sync\Api; |
| 11 |
use WCPOS\WooCommercePOS\Sync\Collections; |
| 12 |
use WCPOS\WooCommercePOS\Sync\Digest_Index; |
| 13 |
use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| 14 |
use WP_REST_Controller; |
| 15 |
use WP_REST_Request; |
| 16 |
use WP_REST_Response; |
| 17 |
use WP_REST_Server; |
| 18 |
|
| 19 |
// phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim. |
| 20 |
|
| 21 |
/** |
| 22 |
* Leg-3 prime-pass digest endpoint (ADR 0014 increment 4c) — GET {API_NAMESPACE}/digests?include=<ids>. |
| 23 |
* |
| 24 |
* Returns each requested product/variation id's STORED 64-bit digest, with NO payload — the compact |
| 25 |
* `{id, digest}` the client needs to backfill its existence-reconcile manifest for records that were |
| 26 |
* already resident BEFORE Leg 3 shipped, so the first reconcile audit doesn't re-pull the whole catalog |
| 27 |
* just to seed manifest rows. Digest-on-pull (#331/#332) covers records pulled AFTER Leg 3; this covers |
| 28 |
* the pre-existing resident set. Servable ids with no stored digest remain absent; when `absence=explicit`, |
| 29 |
* unservable ids are returned as `{id, deleted: true}` so the caller can prune authoritative absence. |
| 30 |
*/ |
| 31 |
final class Digests_Controller extends WP_REST_Controller { |
| 32 |
use Endpoint_Permissions; |
| 33 |
|
| 34 |
/** |
| 35 |
* The digest store's read half — the readable-catalog scoping lives there so |
| 36 |
* this endpoint and integrity/bucket can never disagree on what it means. |
| 37 |
*/ |
| 38 |
private Digest_Index $index; |
| 39 |
|
| 40 |
public function __construct( ?Digest_Index $index = null ) { |
| 41 |
$this->index = $index ?? new Digest_Index(); |
| 42 |
} |
| 43 |
|
| 44 |
public function register_routes(): void { |
| 45 |
register_rest_route( |
| 46 |
Api::ROUTE_NAMESPACE, |
| 47 |
'/digests', |
| 48 |
array( |
| 49 |
'methods' => WP_REST_Server::READABLE, |
| 50 |
'callback' => array( $this, 'get_digests' ), |
| 51 |
'permission_callback' => array( $this, 'permissions_check' ), |
| 52 |
'args' => array( |
| 53 |
'include' => array( |
| 54 |
'required' => true, |
| 55 |
'description' => 'Comma-separated ids to read stored digests for.', |
| 56 |
), |
| 57 |
'collection' => array( |
| 58 |
'default' => 'products', |
| 59 |
'sanitize_callback' => 'sanitize_key', |
| 60 |
'description' => "Which id-space: 'products' (default) or 'customers'.", |
| 61 |
), |
| 62 |
'status' => array( |
| 63 |
'sanitize_callback' => static function ( $status ) { |
| 64 |
return 'publish' === $status ? 'publish' : ''; |
| 65 |
}, |
| 66 |
'description' => "Set to 'publish' to scope product digests to the readable catalog.", |
| 67 |
), |
| 68 |
'absence' => array( |
| 69 |
'sanitize_callback' => static function ( $absence ) { |
| 70 |
return 'explicit' === $absence ? 'explicit' : ''; |
| 71 |
}, |
| 72 |
'description' => "Set to 'explicit' to return deleted rows for unservable ids.", |
| 73 |
), |
| 74 |
), |
| 75 |
) |
| 76 |
); |
| 77 |
} |
| 78 |
|
| 79 |
public function get_digests( WP_REST_Request $request ): WP_REST_Response { |
| 80 |
$ids = $this->parse_ids( $request->get_param( 'include' ) ); |
| 81 |
if ( empty( $ids ) ) { |
| 82 |
return new WP_REST_Response( array( 'digests' => array() ), 200 ); |
| 83 |
} |
| 84 |
// Each collection has its own digest source + id-space (ADR 0015): 'customers' reads the wp_users |
| 85 |
// customer digests; default 'products' reads the products/variations digests. The client boot |
| 86 |
// prime uses this to backfill its per-id-space manifest. |
| 87 |
$collection = $request->get_param( 'collection' ); |
| 88 |
$collection = \is_string( $collection ) ? $collection : 'products'; |
| 89 |
// Fail closed (#421 increment 8): only the registry's digest id-space |
| 90 |
// OWNERS are servable — an unknown collection gets an explicit empty |
| 91 |
// response, never the products digests under the wrong name. |
| 92 |
if ( ! \array_key_exists( $collection, Collections::with( 'digest' ) ) ) { |
| 93 |
return new WP_REST_Response( |
| 94 |
array( |
| 95 |
'digests' => array(), |
| 96 |
'note' => \sprintf( 'collection "%s" has no digest id-space', $collection ), |
| 97 |
), |
| 98 |
200 |
| 99 |
); |
| 100 |
} |
| 101 |
$read_ids = $ids; |
| 102 |
if ( 'products' === $collection && 'publish' === $request->get_param( 'status' ) ) { |
| 103 |
$read_ids = $this->index->published_product_ids( $ids ); |
| 104 |
} |
| 105 |
$digests = $this->index->read_digests( $collection, $read_ids ); |
| 106 |
$explicit_absence = 'explicit' === $request->get_param( 'absence' ); |
| 107 |
$absent_ids = $explicit_absence ? array_values( array_diff( $ids, array_keys( $digests ) ) ) : array(); |
| 108 |
// Authoritative absence is the store's answer, not this endpoint's: the |
| 109 |
// id-space, its live-row rule and the fail-open guard all live in |
| 110 |
// Digest_Index, so this controller never learns the table shape. |
| 111 |
$servable = array_fill_keys( $this->index->servable( $collection, $absent_ids ), true ); |
| 112 |
$out = array(); |
| 113 |
// Preserve request order; servable ids with no stored digest remain absent. |
| 114 |
foreach ( $ids as $id ) { |
| 115 |
if ( isset( $digests[ $id ] ) ) { |
| 116 |
$out[] = array( |
| 117 |
'id' => $id, |
| 118 |
'digest' => $digests[ $id ], |
| 119 |
); |
| 120 |
} elseif ( $explicit_absence && ! isset( $servable[ $id ] ) ) { |
| 121 |
$out[] = array( |
| 122 |
'id' => $id, |
| 123 |
'deleted' => true, |
| 124 |
); |
| 125 |
} |
| 126 |
} |
| 127 |
|
| 128 |
return new WP_REST_Response( array( 'digests' => $out ), 200 ); |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Accept `include` as a comma-separated string (?include=1,2,3) or an array; coerce to UNIQUE |
| 133 |
* positive ints in request order. `Digest_Index::read_digests` re-sanitizes, but bounding here keeps a malformed |
| 134 |
* query cheap and lets the response echo the caller's id ordering. |
| 135 |
* |
| 136 |
* @param mixed $include |
| 137 |
*/ |
| 138 |
private function parse_ids( $include ): array { |
| 139 |
if ( \is_string( $include ) ) { |
| 140 |
$include = explode( ',', $include ); |
| 141 |
} |
| 142 |
if ( ! \is_array( $include ) ) { |
| 143 |
return array(); |
| 144 |
} |
| 145 |
$ids = array(); |
| 146 |
foreach ( $include as $value ) { |
| 147 |
$id = (int) $value; |
| 148 |
if ( $id > 0 ) { |
| 149 |
$ids[ $id ] = $id; // dedupe, preserve first-seen order |
| 150 |
} |
| 151 |
} |
| 152 |
|
| 153 |
return array_values( $ids ); |
| 154 |
} |
| 155 |
} |
| 156 |
|