PluginProbe
Metricool – Social media and site statistics / trunk
Metricool – Social media and site statistics vtrunk
2.1.0 2.0.2 2.0.1 2.0.0 1.27 trunk
metricool / app / Managers / EndpointManager.php

EndpointManager.php in Metricool – Social media and site statistics trunk, at app/Managers/EndpointManager.php

300 lines 10.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Metricool\Managers;
6
7 use Metricool\Bootstrap\App;
8 use Metricool\Exceptions\RestDataException;
9 use Metricool\Interfaces\MiddlewareInterface;
10 use Metricool\Interfaces\MultiEndpointInterface;
11 use Metricool\Interfaces\SingleEndpointInterface;
12 use Metricool\Support\Helpers\Storages\EnvironmentConfig;
13 use Metricool\Support\Helpers\Storages\MiddlewareConfig;
14 use Metricool\Traits\HasAllowlistControl;
15 use Metricool\Traits\HasNonces;
16 use Metricool\Traits\HasRestAccess;
17 use Throwable;
18
19 final class EndpointManager extends AbstractManager
20 {
21 use HasNonces;
22 use HasAllowlistControl;
23 use HasRestAccess;
24
25 private EnvironmentConfig $env;
26
27 private array $routes = [];
28 private array $defaultMiddleware;
29 private array $aliases;
30
31 public function __construct(MiddlewareConfig $middleware, EnvironmentConfig $env)
32 {
33 $this->env = $env;
34 $this->aliases = $middleware->get('aliases', []);
35 $this->defaultMiddleware = $middleware->get('default_middleware', []);
36 }
37
38 /**
39 * @inheritDoc
40 */
41 public function isRegistrable(object $class): bool
42 {
43 return ($class instanceof SingleEndpointInterface
44 || $class instanceof MultiEndpointInterface
45 );
46 }
47
48 /**
49 * @inheritDoc
50 */
51 public function registerClass(object $class): void
52 {
53 if ($class instanceof SingleEndpointInterface) {
54 $this->registerSingleEndpointRoute($class);
55 }
56
57 if ($class instanceof MultiEndpointInterface) {
58 $this->registerMultiEndpointRoute($class);
59 }
60 }
61
62 /**
63 * @inheritDoc
64 */
65 public function afterRegister(): void
66 {
67 $this->registerWordPressRestRoutes();
68 do_action('metricool_endpoints_loaded');
69 }
70
71 /**
72 * Register a plugin route for and endpoint instance that implements the
73 * {@see SingleEndpointInterface}
74 */
75 private function registerSingleEndpointRoute(SingleEndpointInterface $endpoint): void
76 {
77 if ($endpoint->enabled() === false) {
78 return;
79 }
80
81 $this->routes[$endpoint->registerRoute()] = $endpoint->registerArguments();
82 }
83
84 /**
85 * Register plugin routes for an endpoint instance that implements the
86 * {@see MultiEndpointInterface}
87 */
88 private function registerMultiEndpointRoute(MultiEndpointInterface $endpoint): void
89 {
90 if ($endpoint->enabled() === false) {
91 return;
92 }
93
94 $routeEndpoints = $endpoint->registerRoutes();
95 foreach ($routeEndpoints as $route => $arguments) {
96 $this->routes[$route] = $arguments;
97 }
98 }
99
100 /**
101 * This method provides a way to register custom REST routes via the
102 * metricool_rest_routes filter. A controller or feature should be
103 * instantiated before this manager is called and the controller should
104 * hook into the metricool_rest_routes filter to add its own routes.
105 *
106 * public function registerArguments(): array
107 * {
108 * return [
109 * 'methods' => \WP_REST_Server::READABLE,
110 * 'callback' => [$this, 'callback'],
111 * 'permission_callback' => [$this, 'permissionCallback'],
112 * 'middleware' => [
113 * 'metricool:auth', // alias in config/middleware.php
114 * ExampleMiddleware::class, // MiddlewareInterface class
115 * ],
116 * 'apply_default_middleware' => true, // optional, default is true
117 * 'version' => 'v1', // optional, default is the value config/env.php
118 * 'args' => [], // optional, args passed to register_rest_route
119 * ];
120 * }
121 *
122 * @uses apply_filters metricool_rest_routes
123 * @throws \InvalidArgumentException
124 * @throws \ReflectionException
125 */
126 public function registerWordPressRestRoutes(): void
127 {
128 $routes = $this->getPluginRestRoutes();
129
130 foreach ($routes as $route => $data) {
131 $methods = ($data['methods'] ?? 'GET');
132 $callback = ($data['callback'] ?? null);
133 $permissionCallback = ($data['permission_callback'] ?? $this->defaultPermissionCallback());
134 $middleware = ($data['middleware'] ?? []);
135 $applyDefaultMiddleware = ($data['apply_default_middleware'] ?? true);
136 $version = ($data['version'] ?? $this->env->getString('http.version'));
137 $args = ($data['args'] ?? null);
138
139 if (!is_callable($callback)) {
140 throw new \InvalidArgumentException(
141 esc_html(sprintf('The callback for the route: %s is not callable.', $route))
142 );
143 }
144
145 if ($applyDefaultMiddleware === true) {
146 $middleware = array_merge($this->defaultMiddleware, $middleware);
147 }
148
149 $arguments = [
150 'methods' => $this->normalizeMethods($methods),
151 'callback' => $this->applyMiddleware($callback, $middleware),
152 'permission_callback' => $permissionCallback,
153 ];
154
155 if ($args !== null) {
156 $arguments['args'] = $args;
157 }
158
159 register_rest_route($this->env->getString('http.namespace') . '/' . $version, $route, $arguments);
160 }
161 }
162
163 /**
164 * The default permission callback applied to routes that do not define
165 * their own. Default is ```metricool_manage``` capability check.
166 */
167 private function defaultPermissionCallback(): callable
168 {
169 return function (): bool {
170 return $this->userCanManage();
171 };
172 }
173
174 /**
175 * Get the plugins REST routes
176 * @uses apply_filters metricool_rest_routes
177 */
178 private function getPluginRestRoutes(): array
179 {
180 /**
181 * Filter: metricool_rest_routes
182 * Can be used to add or modify the REST routes
183 *
184 * @param array $routes
185 * @return array
186 * @example [
187 * 'route' => [ // key is the route name
188 * 'methods' => 'GET', // required
189 * 'callback' => 'callback_function', // required
190 * 'permission_callback' => 'permission_callback_function', // optional to override the default permission callback
191 * 'version' => 'v1' // optional to override the default version
192 * ]
193 * ]
194 */
195 return apply_filters('metricool_rest_routes', $this->routes);
196 }
197
198 /**
199 * Wrap the endpoint callback with middleware. Provided middleware can either be an alias ```auth:metricool``` or
200 * a FQCN```MetricoolAuthenticated::class```
201 *
202 * @param callable $callback The endpoint's callback
203 * @param array $middlewares The middleware to apply
204 * @return callable The wrapped callback
205 *
206 * @throws \ReflectionException
207 */
208 public function applyMiddleware(callable $callback, array $middlewares = []): callable
209 {
210 $instances = $this->resolveMiddleware($middlewares);
211 $pipeline = $this->buildPipeline($callback, $instances);
212
213 return function (\WP_REST_Request $request) use ($pipeline) {
214 try {
215 return $pipeline($request);
216 } catch (RestDataException $e) {
217 return $this->sendHttpErrorResponse($e->getMessage(), $e->getData(), $e->getResponseCode());
218 } catch (Throwable $e) {
219 return $this->sendHttpErrorResponse($e->getMessage(), null, $e->getCode() ?: 500);
220 }
221 };
222 }
223
224 /**
225 * Return a pipeline of middleware into a single callback.
226 */
227 private function buildPipeline(callable $callback, array $middleware): callable
228 {
229 return array_reduce(
230 array_reverse($middleware),
231 static function (callable $next, MiddlewareInterface $middleware): callable {
232 return static function (\WP_REST_Request $request) use ($middleware, $next) {
233 return $middleware->handle($request, $next);
234 };
235 },
236 static function (\WP_REST_Request $request) use ($callback) {
237 return $callback($request);
238 }
239 );
240 }
241
242 /**
243 * Resolve middleware entries to MiddlewareInterface instances.
244 *
245 * @param string[] $middleware
246 * @return MiddlewareInterface[]
247 * @throws \ReflectionException
248 */
249 private function resolveMiddleware(array $middleware): array
250 {
251 $resolved = [];
252
253 foreach ($middleware as $entry) {
254 $class = $this->aliases[$entry] ?? $entry;
255
256 if (!is_string($class) || !class_exists($class)) {
257 throw new \InvalidArgumentException(
258 esc_html(sprintf("Middleware: %s could not be resolved to a valid class.", $entry))
259 );
260 }
261
262 $instance = App::getInstance()->get($class);
263
264 if (!$instance instanceof MiddlewareInterface) {
265 throw new \InvalidArgumentException(
266 esc_html(sprintf('Middleware: %s must implement MiddlewareInterface.', $entry))
267 );
268 }
269
270 $resolved[] = $instance;
271 }
272
273 return $resolved;
274 }
275
276 /**
277 * Process the given methods and compare them to the allowed
278 * {@see \WP_REST_Server::ALLMETHODS} methods. Remove unwanted entries and
279 * cleanup method usage from, for example, "get " to "GET".
280 *
281 * @return string From "get, POSt, fake" to "GET,POST"
282 */
283 private function normalizeMethods(string $methods): string
284 {
285 // Split into array, trim whitespace and uppercase entries
286 $methodsArray = array_map('trim', explode(',', $methods));
287 $methodsArray = array_map('strtoupper', $methodsArray);
288
289 // Split allowed entries into array and trim whitespaces
290 $allowedMethodsArray = array_map('trim', explode(',', \WP_REST_Server::ALLMETHODS));
291
292 // Keep only allowed methods
293 $methodsArray = array_intersect($methodsArray, $allowedMethodsArray);
294 $methodsArray = array_values(array_unique($methodsArray));
295
296 // Convert back to CSV format for register_rest_route usage
297 return implode(',', $methodsArray);
298 }
299 }
300