From 4d624a90674938cc06b24ede25879bead57c6367 Mon Sep 17 00:00:00 2001 From: Youssef El Houti Date: Wed, 4 Feb 2026 03:05:46 +0100 Subject: [PATCH 1/2] Normalize schema types and convert date-time to proper format --- src/SchemaGenerator.php | 4 +- src/Spec/Operation.php | 12 ++- src/Spec/Path.php | 3 + src/Util.php | 103 +++++++++++++++---- tests/Unit/UtilTest.php | 219 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 28 deletions(-) create mode 100644 tests/Unit/UtilTest.php diff --git a/src/SchemaGenerator.php b/src/SchemaGenerator.php index f25be77..b5c4207 100644 --- a/src/SchemaGenerator.php +++ b/src/SchemaGenerator.php @@ -74,7 +74,7 @@ public function generate( $requestedNamespace ): array { if ( isset( $schema['title'] ) ) { $title = Util::normalizeSchemaTitle( $schema['title'] ); $schemaTitle = $title; - $base['components']['schemas'][ $schemaTitle ] = $schema; + $base['components']['schemas'][ $schemaTitle ] = Util::normalizeSchema( $schema ); } } // Extract responses from route options if available @@ -178,7 +178,7 @@ protected function fixComponentsSchemas( array $base ): array { $base['components']['schemas'][$schemaKey]['properties'] = $base['components']['schemas'][$schemaKey]['properties']['properties']; foreach ($base['components']['schemas'][$schemaKey]['properties'] as $key => $property) { if (is_string($property)) { - $base['components']['schemas'][$schemaKey]['properties'][$key] = array('type' => Util::normalzieInvalidType($property)); + $base['components']['schemas'][$schemaKey]['properties'][$key] = Util::normalizeSchema($property); } } } diff --git a/src/Spec/Operation.php b/src/Spec/Operation.php index f1330b8..b26a1e6 100644 --- a/src/Spec/Operation.php +++ b/src/Spec/Operation.php @@ -185,9 +185,7 @@ function( Parameter $parameter ) { } $schema = Util::removeArrayKeysRecursively( $schema, array( 'context', 'readonly' ) ); - Util::modifyArrayValueByKeyRecursive($schema, 'type', function($type) { - return Util::normalzieInvalidType($type); - }); + $schema = Util::normalizeSchema( $schema ); Util::modifyArrayValueByKeyRecursive($schema, 'properties', function($properties) { if (is_array($properties) && count($properties) === 0) { @@ -206,7 +204,7 @@ function( Parameter $parameter ) { } foreach ($property as $propKey => $propValue) { if ($propKey === 'items' && is_string($propValue)) { - $properties[$key]['items'] = array('type' => Util::normalzieInvalidType($propValue)); + $properties[$key]['items'] = Util::normalizeSchema($propValue); } } } @@ -327,7 +325,11 @@ public function generateParametersFromRouteArgs( $method, array $args, array $pa $values['type'] = 'string'; } - $values['type'] = Util::normalzieInvalidType( $values['type'] ); + $normalized = Util::normalizeSchema( $values['type'] ); + $values['type'] = $normalized['type']; + if ( isset( $normalized['format'] ) && ! isset( $values['format'] ) ) { + $values['format'] = $normalized['format']; + } $parameter = new Parameter( $in, $name, $values['type'], $values['description'], $values['required'] ); if ( isset( $values['default'] ) ) { diff --git a/src/Spec/Path.php b/src/Spec/Path.php index c3b5b65..eb20dc9 100644 --- a/src/Spec/Path.php +++ b/src/Spec/Path.php @@ -68,6 +68,9 @@ public function generateOperationsFromRouteArgs( $args, $routeResponses = null ) if ( isset( $responseDef['content'] ) && is_array( $responseDef['content'] ) ) { foreach ( $responseDef['content'] as $mediaType => $contentDef ) { $schema = $contentDef['schema'] ?? array(); + if ( is_array( $schema ) && ! isset( $schema['$ref'] ) ) { + $schema = \WPOpenAPI\Util::normalizeSchema( $schema ); + } $example = $contentDef['example'] ?? null; $content = new ResponseContent( $mediaType, $schema, $example ); $response->addContent( $content ); diff --git a/src/Util.php b/src/Util.php index e2dbf42..5df117a 100644 --- a/src/Util.php +++ b/src/Util.php @@ -32,35 +32,96 @@ public static function modifyArrayValueByKeyRecursive(array &$array, $key, calla } /** - * In WordPress, some schema formats are not compatible with the OpenAPI specification. - * This function converts those special or non-standard types to OpenAPI-compatible values. - * These values should not be used as 'type' values according to JSON Schema, - * but are sometimes used in WordPress REST API schemas regardless. - */ - public static function normalzieInvalidType( $type ) { - if ( is_array( $type ) ) { - foreach ( $type as $key => $value ) { - $type[ $key ] = self::normalzieInvalidType( $value ); - } - return $type; + * Recursively normalizes a schema by fixing invalid types. + * Converts types like 'date-time' to proper 'type: string, format: date-time'. + * + * Accepts either: + * - A string (type value) - returns normalized schema array + * - An array (full schema) - returns normalized schema array + * + * For example: + * - 'date-time' returns ['type' => 'string', 'format' => 'date-time'] + * - 'bool' returns ['type' => 'boolean'] + * - ['type' => 'date-time'] returns ['type' => 'string', 'format' => 'date-time'] + * + * @param string|array $schema The type value or schema to normalize + * @return array The normalized schema + */ + public static function normalizeSchema( $schema ): array { + // Handle string input (just a type value) + if ( is_string( $schema ) ) { + $schema = array( 'type' => $schema ); } + $typeToFormat = array( + 'date' => 'date', + 'date-time' => 'date-time', + 'email' => 'email', + 'hostname' => 'hostname', + 'ipv4' => 'ipv4', + 'uri' => 'uri', + ); + $replacements = array( - 'date' => 'string', - 'date-time' => 'string', - 'email' => 'string', - 'hostname' => 'string', - 'ipv4' => 'string', - 'uri' => 'string', 'mixed' => 'string', - 'bool' => 'boolean', + 'bool' => 'boolean', ); - if ( isset( $replacements[ $type ] ) ) { - return $replacements[ $type ]; + // Normalize 'type' field if present and format is not already set + if ( isset( $schema['type'] ) && ! isset( $schema['format'] ) ) { + $type = $schema['type']; + + if ( is_array( $type ) ) { + // Handle array of types (e.g., ['string', 'null']) + foreach ( $type as $key => $value ) { + if ( isset( $typeToFormat[ $value ] ) ) { + $schema['type'][ $key ] = 'string'; + if ( ! isset( $schema['format'] ) ) { + $schema['format'] = $typeToFormat[ $value ]; + } + } elseif ( isset( $replacements[ $value ] ) ) { + $schema['type'][ $key ] = $replacements[ $value ]; + } + } + } elseif ( isset( $typeToFormat[ $type ] ) ) { + $schema['type'] = 'string'; + $schema['format'] = $typeToFormat[ $type ]; + } elseif ( isset( $replacements[ $type ] ) ) { + $schema['type'] = $replacements[ $type ]; + } + } + + // Recursively normalize 'properties' + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + foreach ( $schema['properties'] as $key => $property ) { + if ( is_array( $property ) ) { + $schema['properties'][ $key ] = self::normalizeSchema( $property ); + } + } + } + + // Recursively normalize 'items' + if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { + $schema['items'] = self::normalizeSchema( $schema['items'] ); + } + + // Recursively normalize 'allOf', 'oneOf', 'anyOf' + foreach ( array( 'allOf', 'oneOf', 'anyOf' ) as $combiner ) { + if ( isset( $schema[ $combiner ] ) && is_array( $schema[ $combiner ] ) ) { + foreach ( $schema[ $combiner ] as $key => $subSchema ) { + if ( is_array( $subSchema ) ) { + $schema[ $combiner ][ $key ] = self::normalizeSchema( $subSchema ); + } + } + } + } + + // Recursively normalize 'additionalProperties' if it's a schema + if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) { + $schema['additionalProperties'] = self::normalizeSchema( $schema['additionalProperties'] ); } - return $type; + return $schema; } public static function normalizeEnum( $enum ) { diff --git a/tests/Unit/UtilTest.php b/tests/Unit/UtilTest.php new file mode 100644 index 0000000..68d1f13 --- /dev/null +++ b/tests/Unit/UtilTest.php @@ -0,0 +1,219 @@ +assertEquals(['type' => 'string', 'format' => 'date-time'], $result); + } + + public function testNormalizeSchemaWithStringDateType() + { + $result = Util::normalizeSchema('date'); + $this->assertEquals(['type' => 'string', 'format' => 'date'], $result); + } + + public function testNormalizeSchemaWithStringEmailType() + { + $result = Util::normalizeSchema('email'); + $this->assertEquals(['type' => 'string', 'format' => 'email'], $result); + } + + public function testNormalizeSchemaWithStringUriType() + { + $result = Util::normalizeSchema('uri'); + $this->assertEquals(['type' => 'string', 'format' => 'uri'], $result); + } + + public function testNormalizeSchemaWithStringBoolType() + { + $result = Util::normalizeSchema('bool'); + $this->assertEquals(['type' => 'boolean'], $result); + } + + public function testNormalizeSchemaWithStringMixedType() + { + $result = Util::normalizeSchema('mixed'); + $this->assertEquals(['type' => 'string'], $result); + } + + public function testNormalizeSchemaWithStringRegularType() + { + $result = Util::normalizeSchema('string'); + $this->assertEquals(['type' => 'string'], $result); + } + + public function testNormalizeSchemaWithArrayDateTimeType() + { + $result = Util::normalizeSchema(['type' => 'date-time']); + $this->assertEquals(['type' => 'string', 'format' => 'date-time'], $result); + } + + public function testNormalizeSchemaWithArrayBoolType() + { + $result = Util::normalizeSchema(['type' => 'bool']); + $this->assertEquals(['type' => 'boolean'], $result); + } + + public function testNormalizeSchemaPreservesExistingFormat() + { + $result = Util::normalizeSchema(['type' => 'date-time', 'format' => 'custom-format']); + $this->assertEquals(['type' => 'date-time', 'format' => 'custom-format'], $result); + } + + public function testNormalizeSchemaWithArrayOfTypes() + { + $result = Util::normalizeSchema(['type' => ['date-time', 'null']]); + $this->assertEquals(['type' => ['string', 'null'], 'format' => 'date-time'], $result); + } + + public function testNormalizeSchemaWithArrayOfTypesIncludingBool() + { + $result = Util::normalizeSchema(['type' => ['bool', 'null']]); + $this->assertEquals(['type' => ['boolean', 'null']], $result); + } + + public function testNormalizeSchemaWithNestedProperties() + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'created_at' => ['type' => 'date-time'], + 'name' => ['type' => 'string'], + 'active' => ['type' => 'bool'], + ], + ]; + + $result = Util::normalizeSchema($schema); + + $this->assertEquals('string', $result['properties']['created_at']['type']); + $this->assertEquals('date-time', $result['properties']['created_at']['format']); + $this->assertEquals('string', $result['properties']['name']['type']); + $this->assertEquals('boolean', $result['properties']['active']['type']); + } + + public function testNormalizeSchemaWithItems() + { + $schema = [ + 'type' => 'array', + 'items' => ['type' => 'date-time'], + ]; + + $result = Util::normalizeSchema($schema); + + $this->assertEquals('string', $result['items']['type']); + $this->assertEquals('date-time', $result['items']['format']); + } + + public function testNormalizeSchemaWithOneOf() + { + $schema = [ + 'oneOf' => [ + ['type' => 'date-time'], + ['type' => 'bool'], + ], + ]; + + $result = Util::normalizeSchema($schema); + + $this->assertEquals('string', $result['oneOf'][0]['type']); + $this->assertEquals('date-time', $result['oneOf'][0]['format']); + $this->assertEquals('boolean', $result['oneOf'][1]['type']); + } + + public function testNormalizeSchemaWithAnyOf() + { + $schema = [ + 'anyOf' => [ + ['type' => 'email'], + ['type' => 'uri'], + ], + ]; + + $result = Util::normalizeSchema($schema); + + $this->assertEquals('string', $result['anyOf'][0]['type']); + $this->assertEquals('email', $result['anyOf'][0]['format']); + $this->assertEquals('string', $result['anyOf'][1]['type']); + $this->assertEquals('uri', $result['anyOf'][1]['format']); + } + + public function testNormalizeSchemaWithAllOf() + { + $schema = [ + 'allOf' => [ + ['type' => 'date'], + ['type' => 'mixed'], + ], + ]; + + $result = Util::normalizeSchema($schema); + + $this->assertEquals('string', $result['allOf'][0]['type']); + $this->assertEquals('date', $result['allOf'][0]['format']); + $this->assertEquals('string', $result['allOf'][1]['type']); + } + + public function testNormalizeSchemaWithAdditionalProperties() + { + $schema = [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'date-time'], + ]; + + $result = Util::normalizeSchema($schema); + + $this->assertEquals('string', $result['additionalProperties']['type']); + $this->assertEquals('date-time', $result['additionalProperties']['format']); + } + + public function testNormalizeSchemaWithDeeplyNestedStructure() + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'data' => [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'timestamp' => ['type' => 'date-time'], + ], + ], + ], + ], + ], + ], + ]; + + $result = Util::normalizeSchema($schema); + + $this->assertEquals('string', $result['properties']['data']['properties']['items']['items']['properties']['timestamp']['type']); + $this->assertEquals('date-time', $result['properties']['data']['properties']['items']['items']['properties']['timestamp']['format']); + } + + public function testNormalizeSchemaPreservesOtherFields() + { + $schema = [ + 'type' => 'date-time', + 'description' => 'A timestamp', + 'required' => true, + ]; + + $result = Util::normalizeSchema($schema); + + $this->assertEquals('string', $result['type']); + $this->assertEquals('date-time', $result['format']); + $this->assertEquals('A timestamp', $result['description']); + $this->assertTrue($result['required']); + } +} From 3d96b10fd021ef004f3f7c89ac2ed00607dc96a6 Mon Sep 17 00:00:00 2001 From: Youssef El Houti Date: Wed, 4 Feb 2026 03:16:02 +0100 Subject: [PATCH 2/2] Auto-detect collection endpoints using /{id} pattern matching --- src/Filters/FixWPCoreCollectionEndpoints.php | 166 ++++++++++--------- 1 file changed, 86 insertions(+), 80 deletions(-) diff --git a/src/Filters/FixWPCoreCollectionEndpoints.php b/src/Filters/FixWPCoreCollectionEndpoints.php index 461f1da..50d2f48 100644 --- a/src/Filters/FixWPCoreCollectionEndpoints.php +++ b/src/Filters/FixWPCoreCollectionEndpoints.php @@ -8,106 +8,112 @@ class FixWPCoreCollectionEndpoints { + /** + * Fallback list of known collection endpoints that don't have a matching single-item endpoint. + * These are primarily search/listing endpoints without a corresponding /{id} variant. + */ const WP_CORE_COLLECTION_ENDPOINTS = array( - '/wp/v2/posts', - '/wp/v2/pages', - '/wp/v2/media', - '/wp/v2/menu-items', - '/wp/v2/blocks', - '/wp/v2/templates', - '/wp/v2/template-parts', - '/wp/v2/navigation', - '/wp/v2/font-families', - '/wp/v2/categories', - '/wp/v2/tags', - '/wp/v2/menus', - '/wp/v2/wp_pattern_category', - '/wp/v2/users', - '/wp/v2/comments', '/wp/v2/search', - '/wp/v2/block-types', - '/wp/v2/themes', - '/wp/v2/plugins', - '/wp/v2/sidebars', - '/wp/v2/widget-types', - '/wp/v2/widgets', '/wp/v2/block-directory/search', '/wp/v2/pattern-directory/patterns', '/wp/v2/block-patterns/patterns', '/wp/v2/block-patterns/categories', - '/wp/v2/font-collections', - '/wp/v2/posts/{parent}/revisions', - '/wp/v2/posts/{id}/autosaves', - '/wp/v2/pages/{parent}/revisions', - '/wp/v2/pages/{id}/autosaves', - '/wp/v2/menu-items/{id}/autosaves', - '/wp/v2/blocks/{parent}/revisions', - '/wp/v2/blocks/{id}/autosaves', - '/wp/v2/templates/{parent}/revisions', - '/wp/v2/templates/{id}/autosaves', - '/wp/v2/template-parts/{parent}/revisions', - '/wp/v2/template-parts/{id}/autosaves', - '/wp/v2/global-styles/{parent}/revisions', - '/wp/v2/global-styles/themes/{stylesheet}/variations', - '/wp/v2/navigation/{parent}/revisions', - '/wp/v2/navigation/{id}/autosaves', - '/wp/v2/font-families/{font_family_id}/font-faces', - '/wp/v2/users/{user_id}/application-passwords', ); public function __construct( Filters $hooks ) { - $hooks->addOperationsFilter(function(array $operations) { - foreach ($operations as $operation) { - $endpoint = $operation->getEndpoint(); - $method = $operation->getMethod(); - if ($method !== 'get') { - continue; - } - if (!in_array($endpoint, self::WP_CORE_COLLECTION_ENDPOINTS, true)) { - continue; - } + $hooks->addPathsFilter(function(array $paths) { + // Build a set of all endpoint paths for collection detection + $allEndpoints = array(); + foreach ($paths as $path) { + $allEndpoints[] = $path->getPath(); + } + $allEndpoints = array_unique($allEndpoints); - $response = $operation->getResponse(200); - if (!$response) { - continue; - } + foreach ($paths as $path) { + foreach ($path->getOperations() as $operation) { + $endpoint = $operation->getEndpoint(); + $method = $operation->getMethod(); + if ($method !== 'get') { + continue; + } - $newResponse = new Response( - $response->getCode(), - $response->getDescription() - ); + // Check if this is a collection endpoint using heuristic or fallback list + if (!$this->isCollectionEndpoint($endpoint, $allEndpoints)) { + continue; + } - foreach ($response->getContents() as $content) { - $mediaType = $content->getMediaType(); - $schema = $content->getSchema(); + $response = $operation->getResponse(200); + if (!$response) { + continue; + } - $hasValidJsonSchema = ( - $mediaType === 'application/json' && - is_array($schema) && - isset($schema['$ref']) + $newResponse = new Response( + $response->getCode(), + $response->getDescription() ); - if ($hasValidJsonSchema) { - $newContent = new ResponseContent( - 'application/json', - [ - 'type' => 'array', - 'items' => [ - '$ref' => $schema['$ref'], - ], - ] + foreach ($response->getContents() as $content) { + $mediaType = $content->getMediaType(); + $schema = $content->getSchema(); + + $hasValidJsonSchema = ( + $mediaType === 'application/json' && + is_array($schema) && + isset($schema['$ref']) ); - $newResponse->addContent($newContent); - } else { - $newResponse->addContent($content); + + if ($hasValidJsonSchema) { + $newContent = new ResponseContent( + 'application/json', + [ + 'type' => 'array', + 'items' => [ + '$ref' => $schema['$ref'], + ], + ] + ); + $newResponse->addContent($newContent); + } else { + $newResponse->addContent($content); + } } - } - $operation->addResponse($newResponse); + $operation->addResponse($newResponse); + } } - return $operations; + return $paths; }); } -} + + /** + * Check if an endpoint is a collection endpoint. + * + * An endpoint is considered a collection if: + * 1. There exists another endpoint with the same base path plus a path variable + * (e.g., /customers is a collection if /customers/{id} exists), OR + * 2. It's in the fallback list of known collection endpoints + * + * @param string $endpoint The endpoint to check + * @param array $allEndpoints All available endpoints + * @return bool True if this is a collection endpoint + */ + private function isCollectionEndpoint(string $endpoint, array $allEndpoints): bool { + // Check fallback list first for known collection endpoints without single-item variants + if (in_array($endpoint, self::WP_CORE_COLLECTION_ENDPOINTS, true)) { + return true; + } + + // Check if there's a matching single-item endpoint: endpoint + /{variable} + $pattern = '#^' . preg_quote($endpoint, '#') . '/\{[^}]+\}$#'; + + foreach ($allEndpoints as $otherEndpoint) { + if (preg_match($pattern, $otherEndpoint)) { + return true; + } + } + + return false; + } +} \ No newline at end of file