-
Notifications
You must be signed in to change notification settings - Fork 109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow URL metric schema to be extended #1492
Changes from 9 commits
a52381d
e244b22
079e6d9
bbc97dc
70fd825
381c829
e85c683
0cc9103
00d57b4
22711e4
384fa53
ed58151
aa1e663
8917ce9
907d8c3
0db296f
ea28aa1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
<?php | ||
/** | ||
* Optimization Detective: OD_Strict_URL_Metric class | ||
* | ||
* @package optimization-detective | ||
* @since n.e.x.t | ||
*/ | ||
|
||
// Exit if accessed directly. | ||
if ( ! defined( 'ABSPATH' ) ) { | ||
exit; | ||
} | ||
|
||
/** | ||
* Representation of the measurements taken from a single client's visit to a specific URL without additionalProperties allowed. | ||
* | ||
* This is used exclusively in the REST API endpoint for capturing new URL metrics to prevent invalid additional data from being | ||
* submitted in the request. For URL metrics which have been stored the looser OD_URL_Metric class is used instead. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
*/ | ||
final class OD_Strict_URL_Metric extends OD_URL_Metric { | ||
|
||
/** | ||
* Gets JSON schema for URL Metric without additionalProperties. | ||
* | ||
* @since n.e.x.t | ||
* | ||
* @return array<string, mixed> Schema. | ||
*/ | ||
public static function get_json_schema(): array { | ||
return self::falsify_additional_properties( parent::get_json_schema() ); | ||
} | ||
|
||
/** | ||
* Recursively processes the schema to ensure that all objects have additionalProperties set to false. | ||
* | ||
* @since n.e.x.t | ||
* | ||
* @param mixed $schema Schema. | ||
* @return mixed Processed schema. | ||
*/ | ||
private static function falsify_additional_properties( $schema ) { | ||
if ( ! isset( $schema['type'] ) ) { | ||
return $schema; | ||
} | ||
if ( 'object' === $schema['type'] ) { | ||
$schema['additionalProperties'] = false; | ||
if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { | ||
$schema['properties'] = array_map( | ||
static function ( $property_schema ) { | ||
return self::falsify_additional_properties( $property_schema ); | ||
}, | ||
$schema['properties'] | ||
); | ||
} | ||
} elseif ( 'array' === $schema['type'] && isset( $schema['items'] ) && is_array( $schema['items'] ) ) { | ||
$schema['items'] = self::falsify_additional_properties( $schema['items'] ); | ||
} | ||
return $schema; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,45 +44,51 @@ | |
* elements: ElementData[] | ||
* } | ||
* | ||
* @property-read string $uuid | ||
* @property-read string $url | ||
* @property-read float $timestamp | ||
* @property-read ViewportRect $viewport | ||
* @property-read ElementData[] $elements | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure about the value of this and There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In #1098 (comment) you suggested There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's fair. This works. My only concern with such an approach is that it only works reasonably well if another plugin extending it would also have its own class that it annotated with these So I think I still prefer not to have this, but not a big deal if you want to keep it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's true. Plugins that extend the schema wouldn't have type definitions for these properties. However, having them for the core properties is useful so that we don't have to add For plugins that extend the schema, this ties back to the discussion of having default values for the properties. As for plugins that extend the schema, I'm thinking about Embed Optimizer adding a foreach ( $url_metric->elements as $element ) {
if ( null !== $element['resizedBoundingClientRect'] ) {
// ...
}
} However, PHPStan here complains:
This makes sense because I tried adding the following to the
And then explicitly overriding the type for /**
* Extended element.
*
* @var ExtendedElementData $element
*/ But it's not working. I'm getting either errors like:
Or when moving
I'm nearing my PHPStan knowledge limits. Maybe it just can't do what I want. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it comes down to the aforementioned problem with magic getters and That's related to my previous concern, and I don't think we should deprecate the getter methods in favor of magic properties. I think the win in ergonomics is tiny - only about not having to write For any custom properties, we can use the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually I think the problem here is not related to foreach ( $url_metric->get_elements() as $element ) {
if ( null !== $element['resizedBoundingClientRect'] ) {
// ...
}
} The problem is that foreach ( $url_metric->elements as $element ) {
if ( isset( $element['resizedBoundingClientRect'] ) ) {
/**
* Resized bounding client rect.
*
* @var DOMRect $rect
*/
$rect = $element['resizedBoundingClientRect'];
PHPStan\dumpType( $rect );
}
} Here we have the
This is what we want. However, it would be ideal if we didn't have to use Otherwise, for built-in root-level keys like PHPStan\dumpType( $url_metric->get( 'web_vitals' ) );
No type information as expected since PHPStan\dumpType( $url_metric->web_vitals )
That helpful Learn more link may have the answer and I'll investigate further tomorrow. In particular, it may come down to writing our own PHPStan extension. This could ensure that PHPStan detects the correct types for both There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Writing a PHPStan extension sounds like overkill to me. I think we should try to make it work against its constraints. Regarding the And for extension that don't want to go the extra mile, they can still do it the "dirty" way of directly calling On the other hand, undefined properties is a real problem, as reported by PHPStan. That's why I would continue to advise against using a magic getter. I just don't see the value. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The magic getter is only relevant to the root properties. The issue exists for extensions to /**
* Gets the resized bounding client rect.
*
* @phpstan-param ElementData $element
*
* @param array $element Element.
* @return DOMRect|null Whether visible or null if unknown.
*/
public static function get_resized_bounding_client_rect( array $element ): ?array {
if ( isset( $element['resizedBoundingClientRect'] ) ) {
return $element['resizedBoundingClientRect'];
}
return null;
}
public function do_something( OD_HTML_Tag_Processor $processor, OD_URL_Metric $url_metric ): void {
foreach ( $url_metric->get_elements() as $element ) {
$rect = self::get_resized_bounding_client_rect( $element );
if ( isset( $rect ) ) {
PHPStan\dumpType( $rect['height'] ); // => Dumped type: float
}
}
} It would be nicer to not have to resort to public function do_something( OD_HTML_Tag_Processor $processor, OD_URL_Metric $url_metric ): void {
foreach ( $url_metric->get_elements() as $element ) {
if ( isset( $element['resizedBoundingClientRect'] ) ) {
PHPStan\dumpType( $element['resizedBoundingClientRect']['height'] );
}
}
} But this results in Informing PHPStan how to resolve the extended schema would ensure the expected types are used without having to duplicate typing information which is already being enforced by validation and sanitization with the schema. For root properties it also happens, without magic getters: /**
* Gets the CWV metrics.
*
* @param OD_URL_Metric $url_metric URL Metric.
* @return array{ lcp: float, inp: ?float, cls: float }|null CWV data or null if not set.
*/
public static function get_cwv_metrics( OD_URL_Metric $url_metric ): ?array {
$cwv_metrics = $url_metric->get( 'cwv_metrics' );
if ( is_array( $cwv_metrics ) ) {
/**
* CWV metrics.
*
* @var array{ lcp: float, inp: ?float, cls: float } $cwv_metrics
*/
return $cwv_metrics;
}
return null;
}
public function do_something( OD_URL_Metric $url_metric ): void {
$cwv_metrics = self::get_cwv_metrics( $url_metric );
if ( isset( $cwv_metrics ) ) {
PHPStan\dumpType( $cwv_metrics['lcp'] ); // => Dumped type: float
}
} It would be preferable to just have to do: public function do_something( OD_URL_Metric $url_metric ): void {
$cwv_metrics = $url_metric->get( 'cwv_metrics' );
if ( isset( $cwv_metrics ) ) {
PHPStan\dumpType( $cwv_metrics['lcp'] );
}
} But again, this results in I don't think the use of a magic getter is necessarily a problem. I believe static analysis would be able to deal with them. But it isn't necessary. I've removed it in 0db296f. I would still like to explore the use of a PHPStan extension. It's designed to be extensible for cases like this. |
||
* | ||
* @since 0.1.0 | ||
* @access private | ||
*/ | ||
final class OD_URL_Metric implements JsonSerializable { | ||
class OD_URL_Metric implements JsonSerializable { | ||
|
||
/** | ||
* Data. | ||
* | ||
* @var Data | ||
*/ | ||
private $data; | ||
protected $data; | ||
|
||
/** | ||
* Constructor. | ||
* | ||
* @phpstan-param Data|array<string, mixed> $data Valid data or invalid data (in which case an exception is thrown). | ||
* | ||
* @param array<string, mixed> $data URL metric data. | ||
* | ||
* @throws OD_Data_Validation_Exception When the input is invalid. | ||
* | ||
* @param array<string, mixed> $data URL metric data. | ||
*/ | ||
public function __construct( array $data ) { | ||
if ( ! isset( $data['uuid'] ) ) { | ||
$data['uuid'] = wp_generate_uuid4(); | ||
} | ||
$this->validate_data( $data ); | ||
$this->data = $data; | ||
$this->data = $this->prepare_data( $data ); | ||
} | ||
|
||
/** | ||
* Validate data. | ||
* Prepares data with validation and sanitization. | ||
* | ||
* @phpstan-assert Data $data | ||
* @throws OD_Data_Validation_Exception When the input is invalid. | ||
* | ||
* @param array<string, mixed> $data Data to validate. | ||
* @throws OD_Data_Validation_Exception When the input is invalid. | ||
* @return Data Validated and sanitized data. | ||
*/ | ||
private function validate_data( array $data ): void { | ||
$valid = rest_validate_object_value_from_schema( $data, self::get_json_schema(), self::class ); | ||
private function prepare_data( array $data ): array { | ||
$schema = static::get_json_schema(); | ||
$valid = rest_validate_object_value_from_schema( $data, $schema, self::class ); | ||
if ( is_wp_error( $valid ) ) { | ||
throw new OD_Data_Validation_Exception( esc_html( $valid->get_error_message() ) ); | ||
} | ||
|
@@ -105,6 +111,7 @@ private function validate_data( array $data ): void { | |
) | ||
); | ||
} | ||
return rest_sanitize_value_from_schema( $data, $schema, self::class ); | ||
} | ||
|
||
/** | ||
|
@@ -116,12 +123,12 @@ private function validate_data( array $data ): void { | |
*/ | ||
public static function get_json_schema(): array { | ||
/* | ||
* The intersectionRect and clientBoundingRect are both instances of the DOMRectReadOnly, which | ||
* The intersectionRect and boundingClientRect are both instances of the DOMRectReadOnly, which | ||
* the following schema describes. See <https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly>. | ||
* Note that 'number' is used specifically instead of 'integer' because the values are all specified as | ||
* floats/doubles. | ||
*/ | ||
$properties = array_fill_keys( | ||
$dom_rect_properties = array_fill_keys( | ||
array( | ||
'width', | ||
'height', | ||
|
@@ -139,17 +146,17 @@ public static function get_json_schema(): array { | |
); | ||
|
||
// The spec allows these to be negative but this doesn't make sense in the context of intersectionRect and boundingClientRect. | ||
$properties['width']['minimum'] = 0.0; | ||
$properties['height']['minimum'] = 0.0; | ||
$dom_rect_properties['width']['minimum'] = 0.0; | ||
$dom_rect_properties['height']['minimum'] = 0.0; | ||
|
||
$dom_rect_schema = array( | ||
'type' => 'object', | ||
'required' => true, | ||
'properties' => $properties, | ||
'properties' => $dom_rect_properties, | ||
'additionalProperties' => false, | ||
); | ||
|
||
return array( | ||
$schema = array( | ||
'$schema' => 'http://json-schema.org/draft-04/schema#', | ||
'title' => 'od-url-metric', | ||
'type' => 'object', | ||
|
@@ -225,12 +232,141 @@ public static function get_json_schema(): array { | |
'intersectionRect' => $dom_rect_schema, | ||
'boundingClientRect' => $dom_rect_schema, | ||
), | ||
'additionalProperties' => false, | ||
|
||
// Additional properties may be added to the schema for items of elements via the od_url_metric_schema_element_item_additional_properties filter. | ||
// See further explanation below. | ||
'additionalProperties' => true, | ||
), | ||
), | ||
), | ||
'additionalProperties' => false, | ||
// Additional root properties may be added to the schema via the od_url_metric_schema_root_additional_properties filter. | ||
// Therefore, additionalProperties is set to true so that additional properties defined in the extended schema may persist | ||
// in a stored URL Metric even when the extension is deactivated. For REST API requests, the OD_Strict_URL_Metric | ||
// which sets this to false so that newly-submitted URL Metrics only ever include the known properties. | ||
'additionalProperties' => true, | ||
felixarntz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
); | ||
|
||
/** | ||
* Filters additional schema properties which should be allowed at the root of a URL metric. | ||
* | ||
* @since n.e.x.t | ||
* | ||
* @param array<string, array{type: string}> $additional_properties Additional properties. | ||
*/ | ||
$additional_properties = (array) apply_filters( 'od_url_metric_schema_root_additional_properties', array() ); | ||
if ( count( $additional_properties ) > 0 ) { | ||
$schema['properties'] = self::extend_schema_with_optional_properties( $schema['properties'], $additional_properties, 'od_url_metric_schema_root_additional_properties' ); | ||
} | ||
|
||
/** | ||
* Filters additional schema properties which should be allowed for an elements item in a URL metric. | ||
* | ||
* @since n.e.x.t | ||
* | ||
* @param array<string, array{type: string}> $additional_properties Additional properties. | ||
*/ | ||
$additional_properties = (array) apply_filters( 'od_url_metric_schema_element_item_additional_properties', array() ); | ||
if ( count( $additional_properties ) > 0 ) { | ||
$schema['properties']['elements']['items']['properties'] = self::extend_schema_with_optional_properties( | ||
$schema['properties']['elements']['items']['properties'], | ||
$additional_properties, | ||
'od_url_metric_schema_root_additional_properties' | ||
); | ||
} | ||
|
||
return $schema; | ||
} | ||
|
||
/** | ||
* Extends a schema with additional optional properties. | ||
* | ||
* @since n.e.x.t | ||
* | ||
* @param array<string, mixed> $properties_schema Properties schema to extend. | ||
* @param array<string, mixed> $additional_properties Additional properties. | ||
* @param string $filter_name Filter name used to extend. | ||
* | ||
* @return array<string, mixed> Extended schema. | ||
*/ | ||
protected static function extend_schema_with_optional_properties( array $properties_schema, array $additional_properties, string $filter_name ): array { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The whole method is a bit complex with all the validation in it. Is there some existing WP function we could use for schema validation instead? Or does WP do this validation later on anyway? I don't see similar validation in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WordPress does have a
But then it goes ahead and tries to access it anyway, resulting in a warning: Maybe the validation is excessive. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the validation here makes sense, specifically because some of those requirements we have here don't apply to the WordPress validation (for instance, we want to prohibit |
||
$doing_it_wrong = static function ( string $message ) use ( $filter_name ): void { | ||
_doing_it_wrong( | ||
esc_html( "Filter: '{$filter_name}'" ), | ||
esc_html( $message ), | ||
'Optimization Detective n.e.x.t' | ||
westonruter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
); | ||
}; | ||
foreach ( $additional_properties as $property_key => $property_schema ) { | ||
if ( ! is_array( $property_schema ) ) { | ||
continue; | ||
} | ||
if ( isset( $properties_schema[ $property_key ] ) ) { | ||
$doing_it_wrong( | ||
sprintf( | ||
/* translators: property name */ | ||
__( 'Disallowed override of existing schema property "%s".', 'optimization-detective' ), | ||
$property_key | ||
) | ||
); | ||
continue; | ||
} | ||
if ( ! isset( $property_schema['type'] ) || ! ( is_string( $property_schema['type'] ) || is_array( $property_schema['type'] ) ) ) { | ||
$doing_it_wrong( | ||
sprintf( | ||
/* translators: 1: property name, 2: 'type' */ | ||
__( 'Supplied schema property "%1$s" with missing "%2$s" key.', 'optimization-detective' ), | ||
'type', | ||
$property_key | ||
) | ||
); | ||
continue; | ||
} | ||
// TODO: Should 'default' be required? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd say either yes, or we should at least automatically set it based on the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See #1492 (comment). I think we should force the default to always be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hummm. I had thought that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've just disallowed the |
||
if ( isset( $property_schema['required'] ) && false !== $property_schema['required'] ) { | ||
$doing_it_wrong( | ||
sprintf( | ||
/* translators: 1: property name, 2: 'required' */ | ||
__( 'Supplied schema property "%1$s" has a truthy value for "%2$s". All extended properties must be optional so that URL Metrics are not all immediately invalidated once an extension is deactivated..', 'optimization-detective' ), | ||
$property_key, | ||
'required' | ||
) | ||
); | ||
} | ||
$property_schema['required'] = false; | ||
|
||
$properties_schema[ $property_key ] = $property_schema; | ||
} | ||
return $properties_schema; | ||
} | ||
|
||
/** | ||
* Gets property value for an arbitrary key. | ||
* | ||
* This is particularly useful in conjunction with the `od_url_metric_schema_root_additional_properties` filter. | ||
* | ||
* @since n.e.x.t | ||
* @todo Instead of returning null when the key doesn't exist, should the `default` value be returned as defined in the schema? | ||
felixarntz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* | ||
* @param string $key Property. | ||
* @return mixed|null | ||
westonruter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
public function get( string $key ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could make use of generics to specify the expected return type given the input There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe not, as then attempting to access an extended property will result in a PHPStan error. |
||
return $this->data[ $key ] ?? null; | ||
} | ||
|
||
/** | ||
* Gets property value for an arbitrary key. | ||
* | ||
* This is useful with the `@property-read` annotations for the class. For accessing other data, | ||
* it's likely the `get()` method will be more useful for static analysis reasons. | ||
* | ||
* @since n.e.x.t | ||
* | ||
* @param string $key Property. | ||
* @return mixed|null | ||
westonruter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
public function __get( string $key ) { | ||
return $this->get( $key ); | ||
} | ||
|
||
/** | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a bit confusing to have "strict" URL metric extend the "extendable" URL metric - would make more sense to be the other way around. This is also highlighted by how the strict class will still run the filters, but without that serving any purpose.
Taking this a bit further, strictly speaking neither of the two makes sense because a strict URL metric is not an extendable URL metric and vice-versa. So it would make more sense to use e.g. a decorator pattern, or make the two classes separate implementations of a
OD_URL_Metric
interface. Of course that would be a decent amount of refactoring to do - simple, but still some code changes so we could consider that separately.So at this point I mostly think:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I understand. The filters are still used. It's just that any objects have
additionalProperties
set tofalse
in the strict version.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In other words, in the strict version, it sets
additionalProperties
tofalse
which refers to the properties which aren't recognized as part of the schema. The filters are still applying, however, so if a plugin adds new properties to the schema these "additional" properties will be recognized. It's just that additional unrecognized properties will be rejected in the strict verison.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah right, I had something wrong in my logic here. Some of my other feedback on the class hierarchy still applies, but that's not a big deal in regards to this PR. I do think we should separately consider using a
OD_URL_Metric
interface and use that in all (or at least most of) the type hints so that the class implementations can be independent.