Automattic\WooCommerce\Api\Infrastructure

MetadataController{}WC 1.0

Hand-written controller that contributes the _apiMetadata root query field and the supporting MetadataEntry, MetadataTarget, MetadataValue and AuthEntry types to the generated schema.

The autogenerated RootQueryType references this controller alongside the autogenerated query resolvers, so the field appears on the root Query type without any special wiring at the controller level. The resolver delegates to SchemaHandle::find_metadata() for the schema walk and filter application, then reshapes the rows so each entry is exposed as the { name, value } pair that MetadataEntry expects. Authorization descriptors (each row's authorization list) pass through unchanged.

Access is gated by {@see self::can_query_metadata()}; once allowed, the returned content is principal-independent — the full declared shape of the schema, irrespective of who is calling.

Usage

$MetadataController = new MetadataController();
// use class methods

Methods

  1. public static get_field_definition()
  2. public static resolve( ?array $root, array $args, mixed $context, ResolveInfo $info )
  3. private static build_metadata_query_authorization_error( ?object $principal )
  4. private static can_query_metadata( ?object $principal )
  5. private static get_auth_entry_type()
  6. private static get_entry_type()
  7. private static get_target_type()
  8. private static get_value_scalar()

MetadataController{} code WC 10.9.1

class MetadataController {
	/**
	 * Memoised `MetadataValue` scalar type.
	 *
	 * @var ?CustomScalarType
	 */
	private static ?CustomScalarType $value_scalar = null;

	/**
	 * Memoised `MetadataEntry` output type.
	 *
	 * @var ?ObjectType
	 */
	private static ?ObjectType $entry_type = null;

	/**
	 * Memoised `MetadataTarget` output type.
	 *
	 * @var ?ObjectType
	 */
	private static ?ObjectType $target_type = null;

	/**
	 * Memoised `AuthEntry` output type — describes one authorization
	 * attribute attached to a schema target.
	 *
	 * @var ?ObjectType
	 */
	private static ?ObjectType $auth_entry_type = null;

	/**
	 * GraphQL field name used on the root `Query` type.
	 */
	public const FIELD_NAME = '_apiMetadata';

	/**
	 * Field definition for the root `_apiMetadata` query, in the shape the
	 * autogenerated `RootQueryType` expects (same as every autogenerated
	 * resolver's `get_field_definition()`).
	 *
	 * @return array<string, mixed>
	 */
	public static function get_field_definition(): array {
		return array(
			'type'        => Type::nonNull( Type::listOf( Type::nonNull( self::get_target_type() ) ) ),
			'description' => __(
				'Lists metadata attached to elements of this schema. All filter arguments are optional; supplying multiple narrows the result. Use this to discover internal-use APIs, beta features, ownership, etc., or to ask "can I use this specific element?".',
				'woocommerce'
			),
			'args'        => array(
				'name'      => array(
					'type'        => Type::string(),
					'description' => __( 'Match rows that carry a metadata entry with this name. Surviving rows have their entries trimmed to the matching one.', 'woocommerce' ),
				),
				'type'      => array(
					'type'        => Type::string(),
					'description' => __( 'Match rows whose target type equals this name.', 'woocommerce' ),
				),
				'field'     => array(
					'type'        => Type::string(),
					'description' => __( 'Match rows whose target field equals this name.', 'woocommerce' ),
				),
				'attribute' => array(
					'type'        => Type::string(),
					'description' => __( 'Match rows whose authorization carries an attribute with this class short name. Surviving rows have their authorization trimmed to the matching descriptors.', 'woocommerce' ),
				),
			),
			'resolve'     => array( self::class, 'resolve' ),
		);
	}

	/**
	 * Resolver for the `_apiMetadata` root field. Signature matches the
	 * engine's resolver contract; `$root` is unused here (root operations
	 * have no parent). `$context` is read for the principal so the
	 * `can_query_metadata` ladder can run.
	 *
	 * @param ?array      $root    The engine passes null for root resolvers.
	 * @param array       $args    GraphQL arguments (`name`, `type`, `field`, `attribute`).
	 * @param mixed       $context Per-request context — an ArrayObject wrapping {`principal`, `_query_metadata`}.
	 * @param ResolveInfo $info    Carries the schema instance to walk.
	 * @return list<array<string, mixed>>
	 * @throws Error When the principal is not allowed to query `_apiMetadata`.
	 */
	public static function resolve( ?array $root, array $args, mixed $context, ResolveInfo $info ): array {
		unset( $root );

		$principal = is_object( $context ) || is_array( $context ) ? ( $context['principal'] ?? null ) : null;
		if ( ! self::can_query_metadata( $principal ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Static error message + machine code; serialized as JSON, not HTML.
			throw self::build_metadata_query_authorization_error( $principal );
		}

		// Wrap the resolver's engine-typed schema into the same handle clients
		// receive from `GraphQLControllerBase::get_schema()`, so the resolver and
		// PHP-side callers share a single inspection surface.
		$schema = new SchemaHandle( $info->schema );

		$rows = $schema->find_metadata(
			$args['name'] ?? null,
			$args['type'] ?? null,
			$args['field'] ?? null,
			$args['attribute'] ?? null,
		);

		// SchemaHandle returns entries as an associative `name => value` map,
		// which is the natural shape for filtering and PHP-side consumers. The
		// GraphQL `MetadataEntry` type instead exposes each entry as a
		// `{ name, value }` object so clients can `entries { name value }` over
		// a list. Reshape here.
		return array_map(
			static function ( array $row ): array {
				$row['entries'] = array_map(
					static fn( string $entry_name, $entry_value ): array => array(
						'name'  => $entry_name,
						'value' => $entry_value,
					),
					array_keys( $row['entries'] ),
					array_values( $row['entries'] ),
				);
				return $row;
			},
			$rows
		);
	}

	/**
	 * Whether the principal may run the `_apiMetadata` query.
	 *
	 * Tri-tier ladder, deliberately fail-closed:
	 *
	 *  1. If the principal declares `can_query_metadata(): bool`, use it.
	 *     Plugins distinguish metadata-query access from native
	 *     introspection access by declaring this method.
	 *  2. Else if the principal declares `can_introspect(): bool`, fall
	 *     back to it — one switch then gates both metadata and
	 *     introspection, which is the common case.
	 *  3. Else (neither method declared) deny. Plugin authors that don't
	 *     opt their principal in get a locked-down endpoint rather than
	 *     leaking schema shape and gate descriptors by default.
	 *
	 * The principal-derived decision is then passed through the
	 * {@see 'woocommerce_graphql_can_query_metadata'} filter so sites
	 * can grant or revoke access without subclassing the principal —
	 * useful for per-request rules (specific IPs, headers, query
	 * parameters, etc.).
	 *
	 * Fail-closed contract: null principal denies before the filter is
	 * consulted; either method's return is checked with `=== true`; any
	 * throw from the principal method or the filter denies; the filter
	 * must likewise return strictly `true` to allow.
	 *
	 * @param ?object $principal The resolved principal, or null when principal resolution failed.
	 */
	private static function can_query_metadata( ?object $principal ): bool {
		if ( null === $principal ) {
			return false;
		}

		try {
			if ( method_exists( $principal, 'can_query_metadata' ) ) {
				$allowed = true === $principal->can_query_metadata();
			} elseif ( method_exists( $principal, 'can_introspect' ) ) {
				$allowed = true === $principal->can_introspect();
			} else {
				$allowed = false;
			}

			/**
			 * Filters whether the current principal may run the `_apiMetadata` query.
			 *
			 * The filter receives the principal-derived decision (see the tri-tier
			 * ladder in {@see MetadataController::can_query_metadata()}) and must
			 * return strictly `true` to grant access; any other return value
			 * denies. The filter is not invoked when principal resolution failed
			 * (i.e. when the resolver receives a null principal) — that case
			 * denies outright.
			 *
			 * @since 10.9.0
			 *
			 * @internal
			 *
			 * @param bool   $allowed   Whether the principal may query `_apiMetadata`.
			 * @param object $principal The resolved principal.
			 */
			$allowed = apply_filters( 'woocommerce_graphql_can_query_metadata', $allowed, $principal );
		} catch ( \Throwable $e ) {
			return false;
		}

		return true === $allowed;
	}

	/**
	 * Build the GraphQL error thrown when `_apiMetadata` is queried by a
	 * principal that cannot. Mirrors
	 * {@see ResolverHelpers::build_authorization_error()}'s
	 * UNAUTHORIZED / FORBIDDEN distinction so clients can branch on
	 * `extensions.code` the same way they do for field-level denies.
	 *
	 * @param ?object $principal The resolved principal (null when principal resolution failed).
	 */
	private static function build_metadata_query_authorization_error( ?object $principal ): Error {
		$is_anonymous = null === $principal
			|| ( method_exists( $principal, 'is_authenticated' ) && ! $principal->is_authenticated() );
		return new Error(
			$is_anonymous ? 'Authentication required.' : 'You do not have permission to perform this action.',
			extensions: array( 'code' => $is_anonymous ? 'UNAUTHORIZED' : 'FORBIDDEN' )
		);
	}

	/**
	 * The `MetadataTarget` output type, lazily built and cached.
	 */
	private static function get_target_type(): ObjectType {
		if ( null === self::$target_type ) {
			self::$target_type = new ObjectType(
				array(
					'name'        => 'MetadataTarget',
					'description' => __(
						'One element of the schema with its attached metadata. Type-level rows have `field`, `argument` and `enumValue` set to null; field-level rows set `field` (and `argument` when the target is a field argument); enum-value rows set `enumValue`.',
						'woocommerce'
					),
					'fields'      => fn() => array(
						'type'          => array(
							'type'        => Type::nonNull( Type::string() ),
							'description' => __( 'Name of the GraphQL type this row describes.', 'woocommerce' ),
						),
						'field'         => array(
							'type'        => Type::string(),
							'description' => __( 'Field name when this row describes a field (or a field argument); null for type-level rows.', 'woocommerce' ),
						),
						'argument'      => array(
							'type'        => Type::string(),
							'description' => __( 'Argument name when this row describes a field argument; null otherwise.', 'woocommerce' ),
						),
						'enumValue'     => array(
							'type'        => Type::string(),
							'description' => __( 'Enum value name when this row describes one specific enum value; null otherwise.', 'woocommerce' ),
						),
						'entries'       => array(
							'type'        => Type::nonNull( Type::listOf( Type::nonNull( self::get_entry_type() ) ) ),
							'description' => __( 'Metadata entries attached to the target.', 'woocommerce' ),
						),
						'authorization' => array(
							'type'        => Type::nonNull( Type::listOf( Type::nonNull( self::get_auth_entry_type() ) ) ),
							'description' => __( 'Authorization attributes attached to the target (e.g. `RequiredCapability`, `PublicAccess`, or plugin-defined). Empty when the target carries no authorization attributes.', 'woocommerce' ),
						),
					),
				)
			);
		}
		return self::$target_type;
	}

	/**
	 * The `AuthEntry` output type — one authorization attribute attached
	 * to a target. Carries the attribute's short class name and the
	 * scalar args supplied at the usage site.
	 */
	private static function get_auth_entry_type(): ObjectType {
		if ( null === self::$auth_entry_type ) {
			self::$auth_entry_type = new ObjectType(
				array(
					'name'        => 'AuthEntry',
					'description' => __( 'One authorization attribute attached to a schema target.', 'woocommerce' ),
					'fields'      => fn() => array(
						'attribute' => array(
							'type'        => Type::nonNull( Type::string() ),
							'description' => __( 'Short class name of the authorization attribute (e.g. `RequiredCapability`).', 'woocommerce' ),
						),
						'args'      => array(
							'type'        => Type::nonNull( Type::listOf( self::get_value_scalar() ) ),
							'description' => __( 'Constructor arguments supplied at the usage site, in source order. Element type is the same scalar union as `MetadataValue`.', 'woocommerce' ),
						),
					),
				)
			);
		}
		return self::$auth_entry_type;
	}

	/**
	 * The `MetadataEntry` output type, lazily built and cached.
	 */
	private static function get_entry_type(): ObjectType {
		if ( null === self::$entry_type ) {
			self::$entry_type = new ObjectType(
				array(
					'name'        => 'MetadataEntry',
					'description' => __( 'One metadata entry: a `name` plus a scalar `value`.', 'woocommerce' ),
					'fields'      => fn() => array(
						'name'  => array(
							'type'        => Type::nonNull( Type::string() ),
							'description' => __( 'Identifier of the entry (e.g. `internal`, `beta`).', 'woocommerce' ),
						),
						'value' => array(
							// Nullable: `MetadataValue` itself permits a null payload (e.g.
							// `#[Metadata( 'deprecated_reason', null )]`), so the wrapping
							// must allow it through.
							'type'        => self::get_value_scalar(),
							'description' => __( 'Scalar payload associated with the entry. Null when the metadata entry carries a null value.', 'woocommerce' ),
						),
					),
				)
			);
		}
		return self::$entry_type;
	}

	/**
	 * The `MetadataValue` custom scalar, accepting any GraphQL-compatible scalar.
	 *
	 * The autogenerated scalar template hard-codes acceptance of string
	 * literals only, so this scalar is hand-built rather than going through
	 * ApiBuilder. `parseLiteral` walks the AST node types and `parseValue`
	 * accepts the already-decoded PHP scalar that variables-mode delivers.
	 */
	private static function get_value_scalar(): CustomScalarType {
		if ( null === self::$value_scalar ) {
			self::$value_scalar = new CustomScalarType(
				array(
					'name'         => 'MetadataValue',
					'description'  => __(
						'Scalar payload of a metadata entry. Accepts a string, integer, float, boolean, or null.',
						'woocommerce'
					),
					// Resolvers return the raw PHP scalar; webonyx serialises it as JSON directly.
					'serialize'    => static fn( $value ) => $value,
					'parseValue'   => static function ( $value ) {
						if ( null === $value || is_bool( $value ) || is_int( $value ) || is_float( $value ) || is_string( $value ) ) {
							return $value;
						}
						throw new Error( 'MetadataValue must be a string, integer, float, boolean, or null.' );
					},
					'parseLiteral' => static function ( $value_node, ?array $variables = null ) {
						unset( $variables );

						if ( $value_node instanceof StringValueNode ) {
							return $value_node->value;
						}
						if ( $value_node instanceof BooleanValueNode ) {
							return $value_node->value;
						}
						if ( $value_node instanceof IntValueNode ) {
							return (int) $value_node->value;
						}
						if ( $value_node instanceof FloatValueNode ) {
							return (float) $value_node->value;
						}
						if ( $value_node instanceof NullValueNode ) {
							return null;
						}
						throw new Error( 'MetadataValue must be a string, integer, float, boolean, or null literal.' );
					},
				)
			);
		}
		return self::$value_scalar;
	}
}