PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.20 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 All 164 releases
woocommerce-pos / includes / API / Controller_Registry.php

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

424 lines 17.4 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 foreach ( self::namespace_scopes( $controller ) as $scope ) {
221 self::stamp_namespace( $controller, $scope, $lane );
222 }
223 }
224 $this->controllers[ $registry_key ] = $controller;
225
226 // WordPress appends a new route pattern to the end of its table, so the
227 // patterns added since the count taken before registration are this
228 // controller's, whatever namespace they are under and whatever shape their
229 // callbacks take. A pattern registered twice keeps its first owner. The
230 // index route WordPress adds for a namespace's first pattern is the
231 // server's, not the controller's.
232 $server = rest_get_server();
233 $from = \count( self::endpoints( $server ) );
234 $controller->register_routes();
235 $routes = array();
236 foreach ( \array_slice( self::endpoints( $server ), $from, null, true ) as $route => $entry ) {
237 if ( isset( $entry['namespace'] ) && '/' . $entry['namespace'] === $route ) {
238 continue;
239 }
240 $this->routes[ $route ] = $registry_key;
241 $routes[] = $route;
242 }
243
244 if ( method_exists( $controller, 'wcpos_route_classifications' ) ) {
245 $classifier->merge( $controller->wcpos_route_classifications() );
246 } elseif ( isset( self::LEGACY_CLASSIFICATIONS[ $key ] ) ) {
247 $classifier->merge( self::legacy_classifications( $key, $lane ) );
248 }
249
250 return $routes;
251 }
252
253 /**
254 * The server's raw route table, in registration order.
255 *
256 * `WP_REST_Server::$endpoints` is protected and `get_routes()` is its only
257 * reader, but that applies `rest_endpoints` and parses every handler on each
258 * call: measured 2026-09-18, one call per controller made API construction
259 * six times slower. Reading the table itself costs nothing (copy-on-write).
260 *
261 * @param \WP_REST_Server $server The REST server.
262 *
263 * @return array<string, array>
264 */
265 private static function endpoints( \WP_REST_Server $server ): array {
266 return \Closure::bind(
267 function (): array {
268 return $this->endpoints;
269 },
270 $server,
271 $server
272 )();
273 }
274
275 /**
276 * Whether any of the routes is under the lane.
277 *
278 * @param string[] $routes Route patterns.
279 * @param string $lane Namespace.
280 */
281 private static function under( array $routes, string $lane ): bool {
282 foreach ( $routes as $route ) {
283 if ( 0 === strpos( $route, '/' . $lane . '/' ) ) {
284 return true;
285 }
286 }
287
288 return false;
289 }
290
291 /**
292 * The historical gate exemptions under one lane's namespace.
293 *
294 * @param string $key A key of LEGACY_CLASSIFICATIONS.
295 * @param string $namespace The lane.
296 *
297 * @return array<string, string[]> Classification → routes.
298 */
299 private static function legacy_classifications( string $key, string $namespace ): array {
300 $classifications = array();
301 foreach ( self::LEGACY_CLASSIFICATIONS[ $key ] as $classification => $suffixes ) {
302 foreach ( $suffixes as $suffix ) {
303 $classifications[ $classification ][] = '/' . $namespace . $suffix;
304 }
305 }
306
307 return $classifications;
308 }
309
310 /**
311 * Find the controller that registered a route.
312 *
313 * @param string $route Route pattern.
314 * @return object|null Controller, or null when no WCPOS controller registered it.
315 */
316 public function controller_for_route( string $route ): ?object {
317 $key = $this->routes()[ $route ] ?? null;
318 return $this->controllers()[ $key ] ?? null;
319 }
320
321 /**
322 * Get route attribution.
323 *
324 * @return array<string, string> Route pattern to registry key (test seam).
325 */
326 public function routes(): array {
327 return $this->routes;
328 }
329
330 /**
331 * Get registered instances.
332 *
333 * @return array<string, object> V1 keys and 'v2-' plus v2 keys (test seam).
334 */
335 public function controllers(): array {
336 return $this->controllers;
337 }
338
339 /**
340 * Every scope holding a namespace slot on this controller, outermost first.
341 *
342 * The ancestry is walked rather than asked, because property_exists() answers
343 * false for a property a BASE class keeps private, and a private declaration
344 * is a slot of its own: a subclass that declares its own namespace alongside
345 * one the base keeps private has two, and an inherited register_routes() reads
346 * the base's. Writing from the wrong scope would quietly add a dynamic
347 * property while the controller went on reading the old value, so each slot is
348 * written where it lives and all of them are written.
349 *
350 * A namespace a constructor assigned without declaring the property belongs to
351 * no class, but the instance holds it and the controller reads it, so the
352 * runtime class is the scope for that one.
353 *
354 * A controller that keeps no namespace at all has nothing to stamp and
355 * registers where its own register_routes() says, as it did before the map was
356 * derived.
357 *
358 * @param object $controller Controller instance.
359 *
360 * @return string[] Class names, empty when the controller holds no namespace.
361 */
362 private static function namespace_scopes( object $controller ): array {
363 $scopes = array();
364
365 for ( $class = new \ReflectionClass( $controller ); false !== $class; $class = $class->getParentClass() ) {
366 if ( ! $class->hasProperty( 'namespace' ) ) {
367 continue;
368 }
369 // Ask where the slot this class can see actually lives, rather than
370 // assuming this class owns it: an inherited protected property is ONE
371 // slot visible from every descendant, so naming each descendant would
372 // write it several times — harmless for a plain property, not for one
373 // with a set hook. A private declaration a subclass shadows really is
374 // a second slot, and this reports it as such.
375 $scopes[] = $class->getProperty( 'namespace' )->getDeclaringClass()->getName();
376 }
377
378 // A namespace a constructor assigned without declaring the property belongs
379 // to no class, but the instance holds it and the controller reads it. It
380 // can also sit on top of a private one a base declares, so this is asked
381 // whether or not the walk above found anything. The property list is read
382 // rather than hasProperty(), which answers false for exactly this case
383 // (measured on PHP 8.3) while getProperties() still returns the property.
384 foreach ( ( new \ReflectionObject( $controller ) )->getProperties() as $property ) {
385 if ( 'namespace' === $property->getName() && ! $property->isDefault() ) {
386 $scopes[] = \get_class( $controller );
387 break;
388 }
389 }
390
391 return array_values( array_unique( $scopes ) );
392 }
393
394 /**
395 * WP_REST_Controller::$namespace is protected with no setter (Paul, 2026-09-18: stamp
396 * every v2 entry here rather than ask each class to opt in, so a v1 replacement from
397 * Pro or a third party reaches wcpos/v2 with no work on its side).
398 *
399 * @param object $controller Controller instance.
400 * @param string $scope The class that declares the property, so a namespace
401 * a base class keeps private is written where it lives.
402 * @param string $namespace Target namespace.
403 */
404 private static function stamp_namespace( object $controller, string $scope, string $namespace ): void {
405 try {
406 \Closure::bind(
407 function () use ( $namespace ): void {
408 $this->namespace = $namespace;
409 },
410 $controller,
411 $scope
412 )();
413 } catch ( \Error $e ) {
414 // The controller declared its namespace readonly, so it has already
415 // decided it and PHP refuses the write even from inside the class.
416 // Left alone it registers where its own register_routes() says, as it
417 // did before the map was derived; rethrowing would abort rest_api_init
418 // and take every WCPOS route with it. The bound closure does nothing
419 // else, so there is no other Error this can swallow.
420 Logger::log( 'wcpos/v2 promotion left ' . \get_class( $controller ) . ' on its own namespace: ' . $e->getMessage() );
421 }
422 }
423 }
424