PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / API / Controller_Registry.php

Controller_Registry.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.19, at includes/API/Controller_Registry.php

394 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS REST controller registry.
4 *
5 * @package WCPOS\WooCommercePOS\API
6 */
7
8 namespace WCPOS\WooCommercePOS\API;
9
10 use WCPOS\WooCommercePOS\Logger;
11
12 /**
13 * Owns both controller maps, their instances, route attribution and classification merge.
14 *
15 * The v2 service map is the v2-native services plus every v1 entry that serves
16 * wcpos/v1 except the nine frozen data controllers (#544); the registry stamps
17 * wcpos/v2 on each promoted instance. A derived replacement that registers nothing
18 * under wcpos/v2 leaves the lane to the core service, as the hand-kept map did.
19 * Attribution takes the tail of the server's route table after each registration,
20 * so callback shape and namespace are irrelevant. The 'v2-' registry key prefix is
21 * internal, not a route namespace.
22 */
23 final class Controller_Registry {
24 /** The v1 data controllers the sync surface replaced. Never promoted to wcpos/v2 (#544). */
25 public const FROZEN_DATA_KEYS = array( 'products', 'product_variations', 'orders', 'customers', 'product_tags', 'product_categories', 'product_brands', 'coupons', 'taxes' );
26
27 /** The frozen lane; every v1 class declares it itself. */
28 public const V1_NAMESPACE = 'wcpos/v1';
29
30 /** Namespace every entry of the v2 map is stamped with. */
31 public const V2_NAMESPACE = 'wcpos/v2';
32
33 /**
34 * Gate exemptions for a filtered replacement of these three services that
35 * predates `wcpos_route_classifications()` (the gate carried them itself
36 * before 1.10.0). Applied under whichever lane the replacement registers on,
37 * so a legacy auth replacement that now reaches wcpos/v2 keeps anonymous
38 * login there too. Not a list to extend: a service declares its own.
39 *
40 * @var array<string, array<string, string[]>> Map key → classification → route suffixes.
41 */
42 private const LEGACY_CLASSIFICATIONS = array(
43 'auth' => array( 'public' => array( '/auth/test', '/auth/refresh' ) ),
44 'print_jobs' => array( 'printer_token' => array( '/print-jobs/cloudprnt', '/print-jobs/epson-sdp' ) ),
45 'receipts' => array( 'permission_error_passthrough' => array( '/receipts/' ) ),
46 );
47
48 /**
49 * Route pattern to registry key.
50 *
51 * @var array<string, string>
52 */
53 private array $routes = array();
54
55 /**
56 * Registry key to controller instance.
57 *
58 * @var array<string, object>
59 */
60 private array $controllers = array();
61
62 /**
63 * Get the filtered v1 controller map.
64 *
65 * @return array<string, class-string> The v1 map after woocommerce_pos_rest_api_controllers.
66 */
67 public static function v1_map(): array {
68 /**
69 * Filter the list of controller classes used in the WCPOS REST API.
70 *
71 * This filter allows customizing or extending the set of controller classes that handle
72 * REST API routes for the WCPOS. By filtering these controllers, plugins can
73 * modify existing endpoints or add new controllers for additional functionality.
74 * Core legacy controllers use their versioned WCPOS\WooCommercePOS\API\V1 FQCNs.
75 *
76 * @since 1.5.0
77 *
78 * @param array $controllers Associative array of controller identifiers to their corresponding class names.
79 * - 'auth' => Fully qualified name of the class handling authentication.
80 * - 'settings' => Fully qualified name of the class handling settings.
81 * - 'cashier' => Fully qualified name of the class handling cashier management.
82 * - 'products' => Fully qualified name of the class handling products.
83 * - 'product_variations' => Fully qualified name of the class handling product variations.
84 * - 'orders' => Fully qualified name of the class handling orders.
85 * - 'customers' => Fully qualified name of the class handling customers.
86 * - 'product_tags' => Fully qualified name of the class handling product tags.
87 * - 'product_categories' => Fully qualified name of the class handling product categories.
88 * - 'taxes' => Fully qualified name of the class handling taxes.
89 * - 'shipping_methods' => Fully qualified name of the class handling shipping methods.
90 * - 'tax_classes' => Fully qualified name of the class handling tax classes.
91 * - 'order_statuses' => Fully qualified name of the class handling order statuses.
92 */
93 return apply_filters( 'woocommerce_pos_rest_api_controllers', self::core_v1_map() );
94 }
95
96 /**
97 * The v1 map as this plugin ships it, before any filter.
98 *
99 * @return array<string, class-string>
100 */
101 public static function core_v1_map(): array {
102 return array(
103 // WCPOS rest api controllers.
104 'auth' => V1\Auth::class,
105 'settings' => V1\Settings::class,
106 'cashier' => V1\Cashier::class,
107 'templates' => V1\Templates_Controller::class,
108 'receipts' => V1\Receipts_Controller::class,
109 'print_jobs' => V1\Print_Jobs_Controller::class,
110
111 // TODO: remove this?
112 'stores' => V1\Stores::class,
113 'extensions' => V1\Extensions::class,
114 'logs' => V1\Logs::class,
115 'payment_gateways' => V1\Payment_Gateways::class,
116 'gateway_bootstrap' => V1\Gateway_Bootstrap_Controller::class,
117 'checkout' => V1\Checkout_Controller::class,
118
119 // extend WC REST API controllers.
120 'products' => V1\Products_Controller::class,
121 'product_variations' => V1\Product_Variations_Controller::class,
122 'orders' => V1\Orders_Controller::class,
123 'customers' => V1\Customers_Controller::class,
124 'product_tags' => V1\Product_Tags_Controller::class,
125 'product_categories' => V1\Product_Categories_Controller::class,
126 'product_brands' => V1\Product_Brands_Controller::class,
127 'coupons' => V1\Coupons_Controller::class,
128 'taxes' => V1\Taxes_Controller::class,
129 'shipping_methods' => V1\Shipping_Methods_Controller::class,
130 'tax_classes' => V1\Tax_Classes_Controller::class,
131 'order_statuses' => V1\Data_Order_Statuses_Controller::class,
132 );
133 }
134
135 /**
136 * Promote shared services, add v2-native services, then apply the v2 filter.
137 *
138 * @param array<string, class-string> $v1 The v1 entries that serve wcpos/v1 (register() passes only those).
139 * @return array<string, class-string> The v2 map.
140 */
141 public static function v2_map( array $v1 ): array {
142 // The v2-native services come first, as they did in the hand-kept map, and win a key collision.
143 $natives = array(
144 'ping' => V2\Ping::class,
145 'echo_probe' => V2\Echo_Probe::class,
146 'site' => V2\Site::class,
147 'order_email' => V2\Order_Email_Controller::class,
148 );
149 $map = $natives + array_diff_key( $v1, array_flip( self::FROZEN_DATA_KEYS ) );
150
151 /**
152 * Filter the wcpos/v2 service map derived from v1 minus FROZEN_DATA_KEYS.
153 *
154 * This additive filter can add a v2-only service or replace a derived entry
155 * by key. Every entry is instantiated by the registry and stamped with
156 * wcpos/v2, so a replacement needs no $namespace override of its own.
157 *
158 * @since 1.10.0
159 * @since 1.10.19 The map is derived; a v1 replacement now reaches v2 on its own.
160 *
161 * @param array $controllers Associative array of v2 service controller class names.
162 */
163 return apply_filters( 'woocommerce_pos_rest_api_v2_controllers', $map );
164 }
165
166 /**
167 * Instantiate, stamp, register and attribute every controller on both lanes;
168 * merge each controller's classifications.
169 *
170 * @param Route_Classifier $classifier Route permission-gate classifier.
171 */
172 public function register( Route_Classifier $classifier ): void {
173 $core = self::core_v1_map();
174 $v1 = self::v1_map();
175
176 // Only an entry that serves wcpos/v1 is promoted: the v1 filter also carries
177 // the sync controllers, which are wcpos/v2-native and register there already.
178 $serves_v1 = array();
179 foreach ( $v1 as $key => $class ) {
180 if ( self::under( $this->register_one( self::V1_NAMESPACE, $key, $class, $classifier ), self::V1_NAMESPACE ) ) {
181 $serves_v1[ $key ] = $class;
182 }
183 }
184
185 foreach ( self::v2_map( $serves_v1 ) as $key => $class ) {
186 $routes = $this->register_one( self::V2_NAMESPACE, $key, $class, $classifier );
187 // A derived v1 replacement (not a class the v2 filter chose) that registered
188 // nothing under wcpos/v2 — a class that hard-codes wcpos/v1, or a plain object
189 // with no namespace to stamp — leaves the lane to the core service, as the
190 // hand-kept map did.
191 $derived_replacement = isset( $serves_v1[ $key ], $core[ $key ] ) && $serves_v1[ $key ] === $class && $core[ $key ] !== $class;
192 if ( $derived_replacement && ! self::under( $routes, self::V2_NAMESPACE ) ) {
193 $this->register_one( self::V2_NAMESPACE, $key, $core[ $key ], $classifier );
194 }
195 }
196 }
197
198 /**
199 * Instantiate, stamp, register and attribute one controller on one lane.
200 *
201 * @param string $lane The lane the map belongs to.
202 * @param string $key The map key.
203 * @param string $class The controller class.
204 * @param Route_Classifier $classifier Route permission-gate classifier.
205 *
206 * @return string[] The route patterns this registration added, in any namespace.
207 */
208 private function register_one( string $lane, string $key, string $class, Route_Classifier $classifier ): array {
209 if ( ! class_exists( $class ) ) {
210 return array();
211 }
212 $controller = new $class();
213 $registry_key = $key;
214 if ( self::V2_NAMESPACE === $lane ) {
215 $registry_key = 'v2-' . $key;
216 // Any controller that keeps a namespace is stamped, not only a
217 // WP_REST_Controller subclass: the v2 map takes a class name, so a
218 // controller written against WP_REST_Server directly is as entitled to
219 // the promotion as one that extends core's base.
220 $scope = self::namespace_scope( $controller );
221 if ( null !== $scope ) {
222 self::stamp_namespace( $controller, $scope, $lane );
223 }
224 }
225 $this->controllers[ $registry_key ] = $controller;
226
227 // WordPress appends a new route pattern to the end of its table, so the
228 // patterns added since the count taken before registration are this
229 // controller's, whatever namespace they are under and whatever shape their
230 // callbacks take. A pattern registered twice keeps its first owner. The
231 // index route WordPress adds for a namespace's first pattern is the
232 // server's, not the controller's.
233 $server = rest_get_server();
234 $from = \count( self::endpoints( $server ) );
235 $controller->register_routes();
236 $routes = array();
237 foreach ( \array_slice( self::endpoints( $server ), $from, null, true ) as $route => $entry ) {
238 if ( isset( $entry['namespace'] ) && '/' . $entry['namespace'] === $route ) {
239 continue;
240 }
241 $this->routes[ $route ] = $registry_key;
242 $routes[] = $route;
243 }
244
245 if ( method_exists( $controller, 'wcpos_route_classifications' ) ) {
246 $classifier->merge( $controller->wcpos_route_classifications() );
247 } elseif ( isset( self::LEGACY_CLASSIFICATIONS[ $key ] ) ) {
248 $classifier->merge( self::legacy_classifications( $key, $lane ) );
249 }
250
251 return $routes;
252 }
253
254 /**
255 * The server's raw route table, in registration order.
256 *
257 * `WP_REST_Server::$endpoints` is protected and `get_routes()` is its only
258 * reader, but that applies `rest_endpoints` and parses every handler on each
259 * call: measured 2026-09-18, one call per controller made API construction
260 * six times slower. Reading the table itself costs nothing (copy-on-write).
261 *
262 * @param \WP_REST_Server $server The REST server.
263 *
264 * @return array<string, array>
265 */
266 private static function endpoints( \WP_REST_Server $server ): array {
267 return \Closure::bind(
268 function (): array {
269 return $this->endpoints;
270 },
271 $server,
272 $server
273 )();
274 }
275
276 /**
277 * Whether any of the routes is under the lane.
278 *
279 * @param string[] $routes Route patterns.
280 * @param string $lane Namespace.
281 */
282 private static function under( array $routes, string $lane ): bool {
283 foreach ( $routes as $route ) {
284 if ( 0 === strpos( $route, '/' . $lane . '/' ) ) {
285 return true;
286 }
287 }
288
289 return false;
290 }
291
292 /**
293 * The historical gate exemptions under one lane's namespace.
294 *
295 * @param string $key A key of LEGACY_CLASSIFICATIONS.
296 * @param string $namespace The lane.
297 *
298 * @return array<string, string[]> Classification → routes.
299 */
300 private static function legacy_classifications( string $key, string $namespace ): array {
301 $classifications = array();
302 foreach ( self::LEGACY_CLASSIFICATIONS[ $key ] as $classification => $suffixes ) {
303 foreach ( $suffixes as $suffix ) {
304 $classifications[ $classification ][] = '/' . $namespace . $suffix;
305 }
306 }
307
308 return $classifications;
309 }
310
311 /**
312 * Find the controller that registered a route.
313 *
314 * @param string $route Route pattern.
315 * @return object|null Controller, or null when no WCPOS controller registered it.
316 */
317 public function controller_for_route( string $route ): ?object {
318 $key = $this->routes()[ $route ] ?? null;
319 return $this->controllers()[ $key ] ?? null;
320 }
321
322 /**
323 * Get route attribution.
324 *
325 * @return array<string, string> Route pattern to registry key (test seam).
326 */
327 public function routes(): array {
328 return $this->routes;
329 }
330
331 /**
332 * Get registered instances.
333 *
334 * @return array<string, object> V1 keys and 'v2-' plus v2 keys (test seam).
335 */
336 public function controllers(): array {
337 return $this->controllers;
338 }
339
340 /**
341 * The class that declares this controller's namespace, if any declares one.
342 *
343 * Walked rather than asked, because property_exists() answers false for a
344 * property a BASE class keeps private — the shape where a naive write would
345 * quietly add a dynamic property to the subclass while the inherited
346 * register_routes() went on reading the original value. A controller that
347 * declares no namespace anywhere has nothing to stamp and registers where its
348 * own register_routes() says, as it did before the map was derived.
349 *
350 * @param object $controller Controller instance.
351 *
352 * @return string|null The declaring class name, or null when there is none.
353 */
354 private static function namespace_scope( object $controller ): ?string {
355 for ( $class = new \ReflectionClass( $controller ); false !== $class; $class = $class->getParentClass() ) {
356 if ( $class->hasProperty( 'namespace' ) ) {
357 return $class->getName();
358 }
359 }
360
361 return null;
362 }
363
364 /**
365 * WP_REST_Controller::$namespace is protected with no setter (Paul, 2026-09-18: stamp
366 * every v2 entry here rather than ask each class to opt in, so a v1 replacement from
367 * Pro or a third party reaches wcpos/v2 with no work on its side).
368 *
369 * @param object $controller Controller instance.
370 * @param string $scope The class that declares the property, so a namespace
371 * a base class keeps private is written where it lives.
372 * @param string $namespace Target namespace.
373 */
374 private static function stamp_namespace( object $controller, string $scope, string $namespace ): void {
375 try {
376 \Closure::bind(
377 function () use ( $namespace ): void {
378 $this->namespace = $namespace;
379 },
380 $controller,
381 $scope
382 )();
383 } catch ( \Error $e ) {
384 // The controller declared its namespace readonly, so it has already
385 // decided it and PHP refuses the write even from inside the class.
386 // Left alone it registers where its own register_routes() says, as it
387 // did before the map was derived; rethrowing would abort rest_api_init
388 // and take every WCPOS route with it. The bound closure does nothing
389 // else, so there is no other Error this can swallow.
390 Logger::log( 'wcpos/v2 promotion left ' . \get_class( $controller ) . ' on its own namespace: ' . $e->getMessage() );
391 }
392 }
393 }
394