Autogenerated
3 days ago
GraphQLEndpointRegistrar.php
2 months ago
OpcacheFileExpiry.php
2 months ago
QueryCache.php
2 months ago
QueryComplexityRule.php
3 days ago
QueryDepthRule.php
3 days ago
Settings.php
2 months ago
StatusResolverFailedException.php
2 months ago
QueryComplexityRule.php
284 lines
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Internal\Api; |
| 6 | |
| 7 | use Automattic\WooCommerce\Vendor\GraphQL\Error\Error; |
| 8 | use Automattic\WooCommerce\Vendor\GraphQL\Executor\Values; |
| 9 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode; |
| 10 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentSpreadNode; |
| 11 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\NodeKind; |
| 12 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionNode; |
| 13 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode; |
| 14 | use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Directive; |
| 15 | use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\FieldDefinition; |
| 16 | use Automattic\WooCommerce\Vendor\GraphQL\Validator\QueryValidationContext; |
| 17 | use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryComplexity; |
| 18 | |
| 19 | /** |
| 20 | * QueryComplexity validation rule that returns a generic error message when |
| 21 | * the complexity is exceeded. Admins can still read both values via debug |
| 22 | * mode; see {@see GraphQLController} step 8. |
| 23 | * |
| 24 | * Unlike the stock webonyx rule, the work done stays proportional to the size |
| 25 | * of the document: each named fragment is scored once and the result reused |
| 26 | * for every spread, variable values are coerced once instead of once per |
| 27 | * directive or complexity callback, field definitions come from the visitor's |
| 28 | * TypeInfo instead of being re-collected for every selection set, and scores |
| 29 | * saturate at {@see self::COMPLEXITY_CEILING} instead of overflowing. |
| 30 | */ |
| 31 | class QueryComplexityRule extends QueryComplexity { |
| 32 | /** |
| 33 | * Upper bound for computed complexity scores. |
| 34 | * |
| 35 | * Far above any configurable limit, so real scores stay exact, while leaving |
| 36 | * headroom below PHP_INT_MAX for complexity callbacks to multiply a saturated |
| 37 | * child score by a page size without overflowing. |
| 38 | */ |
| 39 | public const COMPLEXITY_CEILING = PHP_INT_MAX >> 10; |
| 40 | |
| 41 | /** |
| 42 | * Memoized complexity of each named fragment, keyed by fragment name. |
| 43 | * |
| 44 | * @var array<string, int> |
| 45 | */ |
| 46 | private array $fragment_complexities = array(); |
| 47 | |
| 48 | /** |
| 49 | * Names of the fragments whose complexity is currently being computed; |
| 50 | * guards against fragment cycles (which the NoFragmentCycles rule reports). |
| 51 | * |
| 52 | * @var array<string, true> |
| 53 | */ |
| 54 | private array $fragments_in_progress = array(); |
| 55 | |
| 56 | /** |
| 57 | * Variable values coerced for the current document, or null when not yet computed. |
| 58 | * |
| 59 | * @var ?array<string, mixed> |
| 60 | */ |
| 61 | private ?array $coerced_variable_values = null; |
| 62 | |
| 63 | /** |
| 64 | * Schema definition of every field node in the document, keyed by the |
| 65 | * node's spl_object_id(). Populated as the visitor enters each field. |
| 66 | * |
| 67 | * @var array<int, ?FieldDefinition> |
| 68 | */ |
| 69 | private array $field_definitions = array(); |
| 70 | |
| 71 | /** |
| 72 | * Reset the per-document state, then replace the stock SELECTION_SET |
| 73 | * callback, which re-collects field definitions through every fragment |
| 74 | * reachable from each selection set, with recording the definition that |
| 75 | * TypeInfo already resolves as the visitor enters each field. |
| 76 | * |
| 77 | * @param QueryValidationContext $context The validation context. |
| 78 | * @return array The visitor definition. |
| 79 | */ |
| 80 | public function getVisitor( QueryValidationContext $context ): array { |
| 81 | $this->fragment_complexities = array(); |
| 82 | $this->fragments_in_progress = array(); |
| 83 | $this->coerced_variable_values = null; |
| 84 | $this->field_definitions = array(); |
| 85 | |
| 86 | $visitor = parent::getVisitor( $context ); |
| 87 | if ( array() === $visitor ) { |
| 88 | // The rule is disabled. |
| 89 | return $visitor; |
| 90 | } |
| 91 | |
| 92 | unset( $visitor[ NodeKind::SELECTION_SET ] ); |
| 93 | $visitor[ NodeKind::FIELD ] = function ( FieldNode $node ) use ( $context ): void { |
| 94 | $this->field_definitions[ spl_object_id( $node ) ] = $context->getFieldDef(); |
| 95 | }; |
| 96 | |
| 97 | return $visitor; |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Look up the schema definition recorded for a field node. |
| 102 | * |
| 103 | * @param FieldNode $field The field node. |
| 104 | * @return ?FieldDefinition The definition, or null when the field doesn't exist on its parent type. |
| 105 | */ |
| 106 | protected function fieldDefinition( FieldNode $field ): ?FieldDefinition { |
| 107 | return $this->field_definitions[ spl_object_id( $field ) ] ?? null; |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Sum the complexity of a selection set's selections, saturating at |
| 112 | * {@see self::COMPLEXITY_CEILING}. |
| 113 | * |
| 114 | * @param SelectionSetNode $selection_set The selection set to score. |
| 115 | * @return int The (possibly saturated) complexity. |
| 116 | * @throws \Exception When variable or argument coercion fails. |
| 117 | */ |
| 118 | protected function fieldComplexity( SelectionSetNode $selection_set ): int { |
| 119 | $complexity = 0; |
| 120 | |
| 121 | foreach ( $selection_set->selections as $selection ) { |
| 122 | $complexity = $this->add_saturating( $complexity, $this->nodeComplexity( $selection ) ); |
| 123 | } |
| 124 | |
| 125 | return $complexity; |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Score a single selection. Named fragments are scored once and the result |
| 130 | * reused for every spread; everything else is delegated to the stock rule. |
| 131 | * |
| 132 | * @param SelectionNode $node The selection to score. |
| 133 | * @return int The complexity of the selection. |
| 134 | * @throws \Exception When variable or argument coercion fails. |
| 135 | */ |
| 136 | protected function nodeComplexity( SelectionNode $node ): int { |
| 137 | if ( ! $node instanceof FragmentSpreadNode ) { |
| 138 | return parent::nodeComplexity( $node ); |
| 139 | } |
| 140 | |
| 141 | $fragment = $this->getFragment( $node ); |
| 142 | if ( is_null( $fragment ) ) { |
| 143 | return 0; |
| 144 | } |
| 145 | |
| 146 | $name = $fragment->name->value; |
| 147 | if ( array_key_exists( $name, $this->fragment_complexities ) ) { |
| 148 | return $this->fragment_complexities[ $name ]; |
| 149 | } |
| 150 | |
| 151 | // A fragment that (transitively) spreads itself has unbounded |
| 152 | // complexity. NoFragmentCycles reports the actual error. |
| 153 | if ( isset( $this->fragments_in_progress[ $name ] ) ) { |
| 154 | return self::COMPLEXITY_CEILING; |
| 155 | } |
| 156 | |
| 157 | $this->fragments_in_progress[ $name ] = true; |
| 158 | try { |
| 159 | $complexity = $this->fieldComplexity( $fragment->selectionSet ); |
| 160 | } finally { |
| 161 | unset( $this->fragments_in_progress[ $name ] ); |
| 162 | } |
| 163 | |
| 164 | $this->fragment_complexities[ $name ] = $complexity; |
| 165 | |
| 166 | return $complexity; |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Whether `@include` / `@skip` directives exclude the field from execution. |
| 171 | * |
| 172 | * Same semantics as the stock rule, but variable values are coerced once |
| 173 | * per document (see {@see self::get_coerced_variable_values()}). |
| 174 | * |
| 175 | * @param FieldNode $node The field node. |
| 176 | * @return bool True when the field will not be executed. |
| 177 | * @throws \Exception When variable coercion fails. |
| 178 | */ |
| 179 | protected function directiveExcludesField( FieldNode $node ): bool { |
| 180 | foreach ( $node->directives as $directive_node ) { |
| 181 | $directive_name = $directive_node->name->value; |
| 182 | |
| 183 | if ( Directive::INCLUDE_NAME === $directive_name ) { |
| 184 | $include_arguments = Values::getArgumentValues( |
| 185 | Directive::includeDirective(), |
| 186 | $directive_node, |
| 187 | $this->get_coerced_variable_values() |
| 188 | ); |
| 189 | if ( false === $include_arguments['if'] ) { |
| 190 | return true; |
| 191 | } |
| 192 | } elseif ( Directive::SKIP_NAME === $directive_name ) { |
| 193 | $skip_arguments = Values::getArgumentValues( |
| 194 | Directive::skipDirective(), |
| 195 | $directive_node, |
| 196 | $this->get_coerced_variable_values() |
| 197 | ); |
| 198 | if ( true === $skip_arguments['if'] ) { |
| 199 | return true; |
| 200 | } |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | return false; |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * Build the argument values handed to a field's complexity callback. |
| 209 | * |
| 210 | * Same semantics as the stock rule, but variable values are coerced once |
| 211 | * per document (see {@see self::get_coerced_variable_values()}). |
| 212 | * |
| 213 | * @param FieldNode $node The field node. |
| 214 | * @return array<string, mixed> The coerced argument values. |
| 215 | * @throws \Exception When variable or argument coercion fails. |
| 216 | */ |
| 217 | protected function buildFieldArguments( FieldNode $node ): array { |
| 218 | $field_definition = $this->fieldDefinition( $node ); |
| 219 | |
| 220 | return $field_definition instanceof FieldDefinition |
| 221 | ? Values::getArgumentValues( $field_definition, $node, $this->get_coerced_variable_values() ) |
| 222 | : array(); |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Coerce the document's variable values against their definitions, |
| 227 | * once per document. |
| 228 | * |
| 229 | * @return array<string, mixed> The coerced variable values. |
| 230 | * @throws Error When the provided variables don't satisfy their definitions (same error the stock rule throws). |
| 231 | */ |
| 232 | private function get_coerced_variable_values(): array { |
| 233 | if ( ! is_null( $this->coerced_variable_values ) ) { |
| 234 | return $this->coerced_variable_values; |
| 235 | } |
| 236 | |
| 237 | list( $errors, $variable_values ) = Values::getVariableValues( |
| 238 | $this->context->getSchema(), |
| 239 | $this->variableDefs, |
| 240 | $this->getRawVariableValues() |
| 241 | ); |
| 242 | |
| 243 | if ( ! empty( $errors ) ) { |
| 244 | // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON by the GraphQL error formatter. |
| 245 | throw new Error( |
| 246 | implode( |
| 247 | "\n\n", |
| 248 | array_map( static fn( Error $error ): string => $error->getMessage(), $errors ) |
| 249 | ) |
| 250 | ); |
| 251 | // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 252 | } |
| 253 | |
| 254 | $this->coerced_variable_values = $variable_values ?? array(); |
| 255 | |
| 256 | return $this->coerced_variable_values; |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * Add two complexity scores, saturating at {@see self::COMPLEXITY_CEILING}. |
| 261 | * |
| 262 | * @param int $a First score. |
| 263 | * @param int $b Second score. |
| 264 | * @return int The saturated sum. |
| 265 | */ |
| 266 | private function add_saturating( int $a, int $b ): int { |
| 267 | $sum = $a + $b; |
| 268 | |
| 269 | // An int overflow turns the sum into a float, which is also above the ceiling. |
| 270 | return $sum > self::COMPLEXITY_CEILING ? self::COMPLEXITY_CEILING : (int) $sum; |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Override webonyx's default ("Max query complexity should be {max} but |
| 275 | * got {count}."). |
| 276 | * |
| 277 | * @param int $max The configured maximum complexity (unused). |
| 278 | * @param int $count The computed query complexity (unused). |
| 279 | */ |
| 280 | public static function maxQueryComplexityErrorMessage( int $max, int $count ): string { |
| 281 | return 'Maximum query complexity exceeded.'; |
| 282 | } |
| 283 | } |
| 284 |