PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.3
WooCommerce v10.9.3
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Api / Infrastructure / ResolverHelpers.php
woocommerce / src / Api / Infrastructure Last commit date
Schema 4 weeks ago ClassResolver.php 4 weeks ago GraphQLControllerBase.php 4 weeks ago Main.php 4 weeks ago MetadataController.php 4 weeks ago Principal.php 4 weeks ago PrincipalResolver.php 4 weeks ago QueryInfoExtractor.php 4 weeks ago ResolverHelpers.php 4 weeks ago
ResolverHelpers.php
482 lines
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Automattic\WooCommerce\Api\Infrastructure;
6
7 use Automattic\WooCommerce\Api\Infrastructure\Schema\Error;
8
9 /**
10 * Shared utilities for the auto-generated GraphQL resolvers.
11 *
12 * The public surface uses only {@see Schema\Error} (a stable subclass of the
13 * engine's Error) on throws/returns so generated code never imports an
14 * engine-specific symbol — a future engine switch can rewrite the bodies
15 * here without invalidating already-committed plugin trees.
16 */
17 class ResolverHelpers {
18 /**
19 * Compute the complexity cost of a paginated connection field.
20 *
21 * Used as the `complexity` callable on every generated resolver field
22 * that returns a `Connection`. Runs during query validation (before
23 * resolver execution, so before `PaginationParams::validate_args()` has
24 * a chance to reject bad input) — so out-of-range / wrong-type values
25 * are clamped to MAX_PAGE_SIZE here. Using MAX_PAGE_SIZE as the
26 * fallback means a malicious attempt to shrink cost via e.g. a
27 * negative `first` value only inflates the computed complexity,
28 * closing the cost-bypass angle.
29 *
30 * @param int $child_complexity The complexity of a single child node.
31 * @param array $args The field arguments (expects `first` / `last`).
32 *
33 * @return int The total complexity for this connection field.
34 */
35 public static function complexity_from_pagination( int $child_complexity, array $args ): int {
36 $requested = $args['first'] ?? $args['last'] ?? \Automattic\WooCommerce\Api\Pagination\PaginationParams::get_default_page_size();
37 $page_size = ( is_int( $requested ) && $requested >= 0 && $requested <= \Automattic\WooCommerce\Api\Pagination\PaginationParams::MAX_PAGE_SIZE )
38 ? $requested
39 : \Automattic\WooCommerce\Api\Pagination\PaginationParams::MAX_PAGE_SIZE;
40 return $page_size * ( $child_complexity + 1 );
41 }
42
43 /**
44 * Build a PaginationParams instance from the standard GraphQL pagination
45 * arguments (first, last, after, before).
46 *
47 * @param array $args The GraphQL field arguments.
48 *
49 * @return \Automattic\WooCommerce\Api\Pagination\PaginationParams
50 * @throws Error When a pagination value is out of range.
51 */
52 public static function create_pagination_params( array $args ): \Automattic\WooCommerce\Api\Pagination\PaginationParams {
53 return self::create_input(
54 fn() => new \Automattic\WooCommerce\Api\Pagination\PaginationParams(
55 first: $args['first'] ?? null,
56 last: $args['last'] ?? null,
57 after: $args['after'] ?? null,
58 before: $args['before'] ?? null,
59 )
60 );
61 }
62
63 /**
64 * Invoke a factory callable, catching InvalidArgumentException and
65 * converting it to a client-visible GraphQL error.
66 *
67 * Used to wrap construction of unrolled input types (PaginationParams,
68 * ProductFilterInput, etc.) whose constructors may validate their
69 * arguments and throw.
70 *
71 * @param callable $factory A callable that returns the constructed object.
72 *
73 * @return mixed The return value of the factory.
74 * @throws Error When the factory throws InvalidArgumentException.
75 */
76 public static function create_input( callable $factory ): mixed {
77 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
78 try {
79 return $factory();
80 } catch ( \InvalidArgumentException $e ) {
81 throw new Error(
82 $e->getMessage(),
83 extensions: array( 'code' => 'INVALID_ARGUMENT' )
84 );
85 }
86 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
87 }
88
89 /**
90 * Execute a command's execute() method, translating any thrown exceptions
91 * into spec-compliant GraphQL errors.
92 *
93 * @param object $command The command instance (must have an execute() method).
94 * @param array $execute_args Named arguments to pass to execute().
95 *
96 * @return mixed The return value of execute().
97 * @throws Error On any exception from the command.
98 */
99 public static function execute_command( object $command, array $execute_args ): mixed {
100 return self::translate_exceptions(
101 static fn() => $command->execute( ...$execute_args )
102 );
103 }
104
105 /**
106 * Invoke a command's authorize() method, translating any thrown exceptions
107 * into spec-compliant GraphQL errors.
108 *
109 * Mirror of execute_command() for the authorize step. Needed because an
110 * authorize() call can throw an ApiException (e.g. UnauthorizedException
111 * when a target record does not exist); without this wrapper the
112 * exception would propagate up to the engine and lose its error code and
113 * user-visible message on its way through the generic error formatter.
114 *
115 * @param object $command The command instance (must have an authorize() method).
116 * @param array $authorize_args Named arguments to pass to authorize().
117 *
118 * @return bool The return value of authorize().
119 * @throws Error On any exception from the authorize method.
120 */
121 public static function authorize_command( object $command, array $authorize_args ): bool {
122 return self::translate_exceptions(
123 static fn() => $command->authorize( ...$authorize_args )
124 );
125 }
126
127 /**
128 * Build the GraphQL error to throw when an authorization check fails.
129 *
130 * Distinguishes the two HTTP-correct shapes:
131 * - **UNAUTHORIZED (401)** when the principal is anonymous — the caller
132 * could plausibly fix it by authenticating, so the response invites
133 * re-auth.
134 * - **FORBIDDEN (403)** otherwise — the principal is recognised but
135 * isn't allowed; re-authenticating wouldn't help.
136 *
137 * The "anonymous" check is opt-in by convention: the principal's
138 * `is_authenticated(): bool` method, when present, decides. Principals
139 * that don't define it fall through to FORBIDDEN — generated resolvers
140 * still emit a coded error, just without the 401/403 distinction.
141 *
142 * Used for class-level denials (operation-level "you cannot call this
143 * query/mutation"). For field-level denials that should carry a
144 * structured `subject` payload (type / field / attribute), see
145 * {@see self::build_field_authorization_error()}.
146 *
147 * @param object $principal The resolved request principal.
148 */
149 public static function build_authorization_error( object $principal ): Error {
150 $is_anonymous = method_exists( $principal, 'is_authenticated' ) && ! $principal->is_authenticated();
151 return new Error(
152 $is_anonymous ? 'Authentication required.' : 'You do not have permission to perform this action.',
153 extensions: array( 'code' => $is_anonymous ? 'UNAUTHORIZED' : 'FORBIDDEN' )
154 );
155 }
156
157 /**
158 * Like {@see self::build_authorization_error()} but carries a structured
159 * `subject` payload identifying *what* was denied — the enclosing type,
160 * the field (when applicable), and the attribute class name driving the
161 * decision. Clients can branch on `extensions.subject.field` to tell a
162 * field-level deny apart from an operation-level one.
163 *
164 * The error code (UNAUTHORIZED / FORBIDDEN) is preserved verbatim so
165 * existing client handlers continue to work; the subject payload is
166 * additive.
167 *
168 * @param object $principal The resolved request principal.
169 * @param string $type GraphQL type name carrying the gate.
170 * @param ?string $field Field name when the deny is field-level; null for type/operation-level denies.
171 * @param string $attribute_short Short class name of the deciding authorization attribute (no namespace).
172 */
173 public static function build_field_authorization_error( object $principal, string $type, ?string $field, string $attribute_short ): Error {
174 $is_anonymous = method_exists( $principal, 'is_authenticated' ) && ! $principal->is_authenticated();
175 $subject = array(
176 'type' => $type,
177 'attribute' => $attribute_short,
178 );
179 if ( null !== $field ) {
180 $subject['field'] = $field;
181 }
182 return new Error(
183 $is_anonymous ? 'Authentication required.' : 'You do not have permission to perform this action.',
184 extensions: array(
185 'code' => $is_anonymous ? 'UNAUTHORIZED' : 'FORBIDDEN',
186 'subject' => $subject,
187 )
188 );
189 }
190
191 /**
192 * Compute the value `_preauthorized` would carry for the given command and
193 * principal (the AND of the autodiscovered authorization attributes'
194 * authorize() outcomes).
195 *
196 * Lets code-API callers (and tests) ask "would this command's attribute-based
197 * authorization grant access to this principal?" without going through the
198 * GraphQL pipeline.
199 *
200 * Note that it returns true when the command has no authorization attributes
201 * (in that case the command's own `authorize()` method, if any, is the sole
202 * guard; and consulting it requires running the command, which this helper
203 * deliberately doesn't do).
204 *
205 * Note: this provides the attribute-level authorization only. A command with
206 * both attributes and an `authorize()` method composes the two via the
207 * `_preauthorized` infrastructure parameter; this helper returns the value
208 * that `_preauthorized` would carry, not the final `authorize()` outcome.
209 *
210 * Scope is class-level (queries / mutations). Field-level authorization
211 * lives on output-type / input-type properties and is enforced inside
212 * the generated resolvers. To inspect a field's declared authorization
213 * from code, walk {@see \Automattic\WooCommerce\Api\Utils\SchemaHandle::find_metadata()}
214 * and read the `authorization` slice on each row.
215 *
216 * @param string $command_fqcn Fully-qualified command class name.
217 * @param object $principal The resolved principal. Anonymous requests are represented by a sentinel principal (e.g. {@see \Automattic\WooCommerce\Api\Infrastructure\Principal} whose underlying WP_User has ID=0), not by null.
218 *
219 * @throws \InvalidArgumentException When `$command_fqcn` does not name an existing class.
220 */
221 public static function compute_preauthorized( string $command_fqcn, object $principal ): bool {
222 if ( ! class_exists( $command_fqcn ) ) {
223 throw new \InvalidArgumentException(
224 sprintf( 'Class %s does not exist.', esc_html( $command_fqcn ) )
225 );
226 }
227 $ref = new \ReflectionClass( $command_fqcn );
228 $direct = self::collect_authorization_instances( $ref );
229 $usages = $direct;
230 if ( empty( $usages ) ) {
231 // No direct attribute — collect from the entire ancestor tree:
232 // the parent chain plus each ancestor's traits and interfaces
233 // (recursively). All inherited sources contribute as peers; the
234 // only thing direct attributes shadow is the inherited tree as a
235 // whole. Mirrors
236 // {@see \Automattic\WooCommerce\Api\Infrastructure\DesignTime\ApiBuilder::resolve_authorization()}.
237 $visited = array();
238 $stack = array_merge(
239 $ref->getParentClass() ? array( $ref->getParentClass() ) : array(),
240 $ref->getTraits(),
241 $ref->getInterfaces(),
242 );
243 while ( ! empty( $stack ) ) {
244 $source = array_shift( $stack );
245 $name = $source->getName();
246 if ( in_array( $name, $visited, true ) ) {
247 continue;
248 }
249 $visited[] = $name;
250 $usages = array_merge( $usages, self::collect_authorization_instances( $source ) );
251 if ( false !== $source->getParentClass() ) {
252 $stack[] = $source->getParentClass();
253 }
254 $stack = array_merge( $stack, $source->getTraits(), $source->getInterfaces() );
255 }
256 }
257
258 $query_metadata = self::harvest_class_metadata( $ref );
259
260 foreach ( $usages as $instance ) {
261 $auth_method = new \ReflectionMethod( $instance, 'authorize' );
262 $call_args = self::build_authorize_call_args(
263 $auth_method,
264 $principal,
265 array( 'query' => $query_metadata ),
266 array(),
267 null
268 );
269 $result = $instance->authorize( ...$call_args );
270 if ( ! $result ) {
271 return false;
272 }
273 }
274 return true;
275 }
276
277 /**
278 * Mirror of `ApiBuilder::harvest_metadata()` for the runtime path. Walks
279 * {@see \Automattic\WooCommerce\Api\Attributes\Metadata}-subclass attributes
280 * on a class reflector and returns `name => value`. Duplicate names are
281 * resolved last-wins — the build-time validator already errors on
282 * duplicates, so this is only relevant for in-process classes that
283 * never went through a build.
284 *
285 * The per-target `_apiMetadata` opt-out (`shows_in_metadata_query()`)
286 * is not applied here: the `$_metadata` slot threaded into a class-
287 * level attribute's `authorize()` is for policy input, not discovery,
288 * so attribute authors see every entry regardless of how it surfaces
289 * through `_apiMetadata`.
290 *
291 * @param \ReflectionClass $ref The class to read metadata from.
292 * @return array<string, bool|int|float|string|null>
293 */
294 private static function harvest_class_metadata( \ReflectionClass $ref ): array {
295 $entries = array();
296 foreach ( $ref->getAttributes( \Automattic\WooCommerce\Api\Attributes\Metadata::class, \ReflectionAttribute::IS_INSTANCEOF ) as $attribute ) {
297 $instance = $attribute->newInstance();
298 $entries[ $instance->get_name() ] = $instance->get_value();
299 }
300 return $entries;
301 }
302
303 /**
304 * Build the positional/named argument list for an attribute's `authorize()`
305 * method based on which opt-in slots its signature declares.
306 *
307 * The principal is always passed first (positionally) when the method
308 * declares a non-`_`-prefixed parameter; infrastructure parameters
309 * (`$_metadata`, `$_args`, `$_parent`) are passed as named arguments so
310 * the attribute can omit any subset without affecting the call shape.
311 *
312 * @param \ReflectionMethod $method The attribute's `authorize()` method.
313 * @param object $principal The resolved principal to pass when the method takes one.
314 * @param array $metadata Value for `$_metadata` (passed if the method declares it).
315 * @param array $args Value for `$_args` (passed if the method declares it).
316 * @param mixed $parent Value for `$_parent` (passed if the method declares it).
317 *
318 * @return array<int|string, mixed> Positional principal first (if any), then named infra slots. Use with `...` spread.
319 */
320 private static function build_authorize_call_args( \ReflectionMethod $method, object $principal, array $metadata, array $args, mixed $parent ): array {
321 $call_args = array();
322 foreach ( $method->getParameters() as $param ) {
323 $name = $param->getName();
324 if ( '_metadata' === $name ) {
325 $call_args['_metadata'] = $metadata;
326 } elseif ( '_args' === $name ) {
327 $call_args['_args'] = $args;
328 } elseif ( '_parent' === $name ) {
329 $call_args['_parent'] = $parent;
330 } elseif ( '' === $name || '_' !== $name[0] ) {
331 // Principal — positional, must be the first entry in the spread.
332 array_unshift( $call_args, $principal );
333 }
334 }
335 return $call_args;
336 }
337
338 /**
339 * Collect attribute instances declared on $source whose class declares an
340 * authorization-shaped `authorize()` method.
341 *
342 * Mirrors {@see \Automattic\WooCommerce\Api\Infrastructure\DesignTime\ApiBuilder::collect_authorization_usages()}
343 * for the runtime path: same direct-then-inherited precedence, same
344 * "any class with a bool-returning authorize() method qualifies" rule.
345 *
346 * @param \ReflectionClass $source Class/trait/interface to read attributes from.
347 *
348 * @return array<int, object>
349 */
350 private static function collect_authorization_instances( \ReflectionClass $source ): array {
351 $instances = array();
352 foreach ( $source->getAttributes() as $attr ) {
353 $name = $attr->getName();
354 if ( ! class_exists( $name ) || ! method_exists( $name, 'authorize' ) ) {
355 continue;
356 }
357 $method = new \ReflectionMethod( $name, 'authorize' );
358 if ( ! self::authorize_method_shape_is_valid( $method ) ) {
359 continue;
360 }
361 $instances[] = $attr->newInstance();
362 }
363 return $instances;
364 }
365
366 /**
367 * Whether a method's shape matches the authorization-attribute contract:
368 * public, non-static, returns bool, and parameters drawn from the accepted
369 * set — at most one principal (any non-`_`-prefixed name, non-nullable
370 * typed) plus any subset of `$_metadata` (array), `$_args` (array), and
371 * `$_parent` (any type).
372 *
373 * Mirrors the build-time `ApiBuilder::validate_attribute_authorize_shape()`
374 * check so the runtime helper recognises the same set of attributes ApiBuilder
375 * would have emitted into a resolver.
376 *
377 * @param \ReflectionMethod $method The method to inspect.
378 */
379 private static function authorize_method_shape_is_valid( \ReflectionMethod $method ): bool {
380 if ( $method->isStatic() || ! $method->isPublic() ) {
381 return false;
382 }
383 $return_type = $method->getReturnType();
384 if ( ! $return_type instanceof \ReflectionNamedType || 'bool' !== $return_type->getName() ) {
385 return false;
386 }
387
388 $principal_seen = false;
389 foreach ( $method->getParameters() as $param ) {
390 $name = $param->getName();
391 if ( '_metadata' === $name || '_args' === $name ) {
392 $type = $param->getType();
393 if ( ! $type instanceof \ReflectionNamedType || 'array' !== $type->getName() ) {
394 return false;
395 }
396 continue;
397 }
398 if ( '_parent' === $name ) {
399 continue;
400 }
401 if ( '' !== $name && '_' === $name[0] ) {
402 // Unknown infra parameter — reject.
403 return false;
404 }
405 if ( $principal_seen ) {
406 return false;
407 }
408 $type = $param->getType();
409 if ( ! $type instanceof \ReflectionNamedType || $type->allowsNull() ) {
410 return false;
411 }
412 $principal_seen = true;
413 }
414 return true;
415 }
416
417 /**
418 * Invoke a callable, translating any thrown exception into a
419 * spec-compliant GraphQL error with a machine-readable code.
420 *
421 * - ApiException → its own code + extensions, with the original message.
422 * - InvalidArgumentException → INVALID_ARGUMENT, with the original message.
423 * - Any other Throwable → INTERNAL_ERROR, with a generic message; the
424 * original throwable is attached as `previous` for debug-mode surfacing.
425 *
426 * Public so that generated resolvers can wrap Code-API calls that happen
427 * outside the execute()/authorize() pair (e.g. the Connection::slice()
428 * call emitted for nested paginated connection fields, which can throw
429 * InvalidArgumentException when pagination bounds are exceeded).
430 *
431 * @param callable $operation Callable to invoke.
432 *
433 * @return mixed The return value of the callable.
434 * @throws Error On any exception from the callable.
435 */
436 public static function translate_exceptions( callable $operation ): mixed {
437 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
438 try {
439 return $operation();
440 } catch ( \Automattic\WooCommerce\Api\ApiException $e ) {
441 // Caller-supplied extensions come first so the canonical
442 // getErrorCode() can't be silently overridden by an extensions
443 // entry keyed 'code'. The invariant "the code on the wire
444 // equals ApiException::getErrorCode()" is worth enforcing.
445 throw new Error(
446 $e->getMessage(),
447 extensions: array_merge(
448 $e->getExtensions(),
449 array( 'code' => $e->getErrorCode() )
450 )
451 );
452 } catch ( \InvalidArgumentException $e ) {
453 throw new Error(
454 $e->getMessage(),
455 extensions: array( 'code' => 'INVALID_ARGUMENT' )
456 );
457 } catch ( \Throwable $e ) {
458 throw new Error(
459 'An unexpected error occurred.',
460 previous: $e,
461 extensions: array( 'code' => 'INTERNAL_ERROR' )
462 );
463 }//end try
464 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
465 }
466
467 /**
468 * Lazy-initialize and return the WP_Filesystem global, or null when the
469 * direct method isn't available (e.g. credentials prompt would be needed).
470 */
471 public static function wp_filesystem(): ?\WP_Filesystem_Base {
472 global $wp_filesystem;
473 if ( ! $wp_filesystem ) {
474 require_once ABSPATH . 'wp-admin/includes/file.php';
475 if ( ! WP_Filesystem() ) {
476 return null;
477 }
478 }
479 return $wp_filesystem;
480 }
481 }
482