PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 1.11.0 All 47 releases
thinkrank / includes / api / class-rest-args.php

class-rest-args.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/api/class-rest-args.php

221 lines 7.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST argument helpers.
4 *
5 * WordPress core only falls back to `rest_parse_request_arg` when an argument
6 * declares **no** `sanitize_callback`, and `WP_REST_Request::has_valid_params()`
7 * never installs `rest_validate_request_arg` on its own — it calls a validator
8 * only when one is explicitly set. So any argument that declares a
9 * `sanitize_callback` and no `validate_callback` gets zero schema validation:
10 * `enum`, `minimum`, `maximum` and `format` are all inert, and the schema is a
11 * lie the API answers to (#394).
12 *
13 * The rule is core's, verified against this install:
14 * - `wp-includes/rest-api/class-wp-rest-request.php` `sanitize_params()`
15 * falls back only when there is no `sanitize_callback`
16 * - `has_valid_params()` calls a validator only when `! empty( $arg['validate_callback'] )`
17 *
18 * @package ThinkRank
19 * @subpackage API
20 * @since 2.0.1
21 */
22
23 declare(strict_types=1);
24
25 namespace ThinkRank\API;
26
27 // Prevent direct access
28 if (!defined('ABSPATH')) {
29 exit;
30 }
31
32 /**
33 * REST argument helpers.
34 *
35 * @since 2.0.1
36 */
37 class Rest_Args {
38
39 /**
40 * Schema keywords that need a validator to have any effect.
41 *
42 * @since 2.0.1
43 * @var string[]
44 */
45 private const CONSTRAINTS = ['enum', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'format', 'pattern'];
46
47 /**
48 * The plugin's REST namespace.
49 *
50 * @since 2.0.1
51 * @var string
52 */
53 private const NAMESPACE = 'thinkrank/v1';
54
55 /**
56 * Attach core's validator to every argument that declares a constraint.
57 *
58 * Deliberately only those. Applying `rest_validate_request_arg` to *every*
59 * argument would also start enforcing `type`, turning today's lenient
60 * boolean and numeric coercion into a hard 400 across routes that have
61 * always accepted `"1"` for a boolean — a much larger behaviour change than
62 * making a declared enum mean something. The same reasoning is recorded at
63 * `class-image-seo-endpoint.php`, which is where this pattern started.
64 *
65 * An argument that already sets its own `validate_callback` is left alone,
66 * and so is one that declares a constraint but no `type`: core's
67 * `rest_validate_value_from_schema()` reads `$args['type']` unguarded, so
68 * attaching the validator there buys a burst of `Undefined array key
69 * "type"` warnings and a `_doing_it_wrong` on *every* request, valid ones
70 * included. Such an argument is reported by `typeless()`, which the test
71 * suite asserts is empty — the fix is to declare the type, not to widen
72 * this guard.
73 *
74 * @since 2.0.1
75 *
76 * @param array<string, array<string, mixed>> $args Argument definitions.
77 * @return array<string, array<string, mixed>> Definitions with validators attached.
78 */
79 public static function enforce(array $args): array {
80 foreach ($args as $name => $definition) {
81 if (!is_array($definition)
82 || !empty($definition['validate_callback'])
83 || !isset($definition['type'])
84 ) {
85 continue;
86 }
87
88 foreach (self::CONSTRAINTS as $keyword) {
89 if (array_key_exists($keyword, $definition)) {
90 $args[$name]['validate_callback'] = 'rest_validate_request_arg';
91 break;
92 }
93 }
94 }
95
96 return $args;
97 }
98
99 /**
100 * Attach validators across every route in the plugin's namespace.
101 *
102 * Hooked to `rest_endpoints`, which hands over the whole route map after
103 * registration — so a constraint declared anywhere is enforced, including
104 * on routes added by an add-on, and nobody has to remember to wrap an
105 * argument array.
106 *
107 * @since 2.0.1
108 *
109 * @param array<string, array> $endpoints Registered routes.
110 * @return array<string, array> Routes with validators attached.
111 */
112 public static function enforce_namespace(array $endpoints): array {
113 foreach ($endpoints as $route => $handlers) {
114 if (0 !== strpos((string) $route, '/' . self::NAMESPACE . '/')) {
115 continue;
116 }
117
118 foreach ($handlers as $index => $handler) {
119 if (!is_array($handler) || empty($handler['args']) || !is_array($handler['args'])) {
120 continue;
121 }
122
123 $endpoints[ $route ][ $index ]['args'] = self::enforce($handler['args']);
124 }
125 }
126
127 return $endpoints;
128 }
129
130 /**
131 * Argument names whose declared constraints would not be enforced.
132 *
133 * Used by the test that walks the namespace so this class of defect cannot
134 * come back one route at a time.
135 *
136 * @since 2.0.1
137 *
138 * @param array<string, array<string, mixed>> $args Argument definitions.
139 * @return string[] Names of arguments declaring a constraint with no validator.
140 */
141 public static function unenforced(array $args): array {
142 $unenforced = [];
143
144 foreach ($args as $name => $definition) {
145 if (!is_array($definition) || !empty($definition['validate_callback'])) {
146 continue;
147 }
148
149 foreach (self::CONSTRAINTS as $keyword) {
150 if (array_key_exists($keyword, $definition)) {
151 $unenforced[] = (string) $name;
152 break;
153 }
154 }
155 }
156
157 return $unenforced;
158 }
159
160 /**
161 * Argument names declaring a constraint with no `type` to validate against.
162 *
163 * Core cannot validate such an argument without emitting warnings, so
164 * `enforce()` skips it and the constraint stays inert. Every one of these
165 * is a bug at the point of registration.
166 *
167 * @since 2.0.1
168 *
169 * @param array<string, array<string, mixed>> $args Argument definitions.
170 * @return string[] Names of constrained arguments missing a `type`.
171 */
172 public static function typeless(array $args): array {
173 $typeless = [];
174
175 foreach ($args as $name => $definition) {
176 if (!is_array($definition) || isset($definition['type'])) {
177 continue;
178 }
179
180 foreach (self::CONSTRAINTS as $keyword) {
181 if (array_key_exists($keyword, $definition)) {
182 $typeless[] = (string) $name;
183 break;
184 }
185 }
186 }
187
188 return $typeless;
189 }
190
191 /**
192 * Constrained arguments missing a `type`, across a whole route map.
193 *
194 * @since 2.0.1
195 *
196 * @param array<string, array> $endpoints Registered routes.
197 * @return string[] "route: arg" for each offender in this namespace.
198 */
199 public static function typeless_in_namespace(array $endpoints): array {
200 $offenders = [];
201
202 foreach ($endpoints as $route => $handlers) {
203 if (0 !== strpos((string) $route, '/' . self::NAMESPACE . '/')) {
204 continue;
205 }
206
207 foreach ($handlers as $handler) {
208 if (!is_array($handler) || empty($handler['args']) || !is_array($handler['args'])) {
209 continue;
210 }
211
212 foreach (self::typeless($handler['args']) as $name) {
213 $offenders[] = $route . ': ' . $name;
214 }
215 }
216 }
217
218 return $offenders;
219 }
220 }
221