Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 86 additions & 80 deletions src/Filters/FixWPCoreCollectionEndpoints.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
4 changes: 2 additions & 2 deletions src/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
12 changes: 7 additions & 5 deletions src/Spec/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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'] ) ) {
Expand Down
3 changes: 3 additions & 0 deletions src/Spec/Path.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
103 changes: 82 additions & 21 deletions src/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
Loading