From 21b0c0d1621f2dabc77f79aad152758c37f48833 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 19 Oct 2026 12:14:25 +0200 Subject: [PATCH 1/2] fix(hydra): keep jsonapi input schemas out of the json rewrite The Hydra schema factory rewrote every input schema to json. Because it wraps the JSON:API factory, a jsonapi request body was documented as a plain object without the data/attributes/relationships envelope whenever jsonld was enabled. Only jsonapi is now excluded from the rewrite, so other formats are unchanged. JSON:API input definitions get an .input postfix, e.g. Dummy.jsonapi.input, so they no longer share a name with the output definition, which requires data.id and lists included. The input definition requires only type on POST and drops included. --- src/Hydra/JsonSchema/SchemaFactory.php | 2 +- src/JsonApi/JsonSchema/SchemaFactory.php | 15 ++++++++-- tests/Functional/OpenApiTest.php | 38 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index 83a1b15e6c..55f443ba2e 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -109,7 +109,7 @@ public function buildSchema(string $className, string $format = 'jsonld', string // Strictly speaking, it is possible to include `@id` or `@context` in the input, // but the generated JSON Schema does not include `"additionalProperties": false` by default, // so it is possible to include `@id` or `@context` in the input even if the input schema is a JSON schema. - if (Schema::TYPE_INPUT === $type) { + if (Schema::TYPE_INPUT === $type && 'jsonapi' !== $format) { $format = 'json'; } diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index d4b0ab154f..53a2dbe285 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -46,6 +46,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI */ public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups'; + private const INPUT_SCHEMA_POSTFIX = 'input'; private const COLLECTION_BASE_SCHEMA_NAME = 'JsonApiCollectionBaseSchema'; private const COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION = 'JsonApiCollectionBaseSchemaNoPagination'; @@ -127,7 +128,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin } $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); - $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $jsonApiSerializerContext + ['schema_type' => $type]); + $definitionName = $this->createDefinitionName($className, $format, $inputOrOutputClass, $operation, $jsonApiSerializerContext, $type); $prefix = $this->getSchemaUriPrefix($schema->getVersion()); $definitions = $schema->getDefinitions(); $collectionKey = $schema->getItemsDefinitionKey(); @@ -295,7 +296,7 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, if ($relation = $this->getRelationship($className, $propertyName, $serializerContext)) { [$isOne, $relatedClasses] = $relation; $refs = []; - foreach ($relatedClasses as $relatedClassName => $hasOperations) { + foreach (Schema::TYPE_OUTPUT === $type ? $relatedClasses : [] as $relatedClassName => $hasOperations) { if (false === $hasOperations) { continue; } @@ -358,6 +359,9 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, 'type' => 'object', 'properties' => $relationships, ]; + } + + if (Schema::TYPE_OUTPUT === $type && \count($relationships) > 0) { $included = [ 'included' => [ 'description' => 'Related resources requested via the "include" query parameter.', @@ -386,6 +390,13 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ] + $included; } + private function createDefinitionName(string $className, string $format, ?string $inputOrOutputClass, ?Operation $operation, ?array $serializerContext, string $type): string + { + $name = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, ($serializerContext ?? []) + ['schema_type' => $type]); + + return Schema::TYPE_INPUT === $type ? $name.'.'.self::INPUT_SCHEMA_POSTFIX : $name; + } + private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []); diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 7ae7dee890..fc63a7f685 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -138,6 +138,44 @@ public function testOpenApiReferenceInResponsesUsesDollarRef(): void $this->assertArrayNotHasKey('ref', $responses['401']); } + public function testNonJsonLdRequestBodiesKeepTheirFormatDefinition(): void + { + $json = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ])->toArray(); + + $content = $json['paths']['/dummies']['post']['requestBody']['content']; + + $this->assertSame('#/components/schemas/Dummy', $content['application/xml']['schema']['$ref']); + $this->assertSame('#/components/schemas/Dummy', $content['application/ld+json']['schema']['$ref']); + } + + public function testJsonApiRequestBodyUsesItsOwnInputDefinition(): void + { + $json = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ])->toArray(); + $schemas = $json['components']['schemas']; + + $inputName = $this->definitionName($json['paths']['/dummies']['post']['requestBody']['content']['application/vnd.api+json']['schema']['$ref']); + $outputName = $this->definitionName($json['paths']['/dummies/{id}']['get']['responses']['200']['content']['application/vnd.api+json']['schema']['$ref']); + + $this->assertSame('Dummy.jsonapi', $outputName); + $this->assertNotSame($outputName, $inputName); + + $input = $schemas[$inputName]; + $this->assertSame(['type'], $input['properties']['data']['required']); + $this->assertArrayHasKey('attributes', $input['properties']['data']['properties']); + $this->assertArrayNotHasKey('included', $input['properties']); + + $this->assertSame(['type', 'id'], $schemas[$outputName]['properties']['data']['required']); + } + + private function definitionName(string $ref): string + { + return substr($ref, \strlen('#/components/schemas/')); + } + public function testDeprecatedPutDoesNotLeakIntoNestedResourceSchema(): void { $response = self::createClient()->request('GET', '/docs', [ From 06df752e63e4cbe9a8814845ea56de475144f540 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 19 Oct 2026 14:08:29 +0200 Subject: [PATCH 2/2] fix(hydra): inline input definition name --- src/JsonApi/JsonSchema/SchemaFactory.php | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index 53a2dbe285..141feb286f 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -46,7 +46,6 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI */ public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups'; - private const INPUT_SCHEMA_POSTFIX = 'input'; private const COLLECTION_BASE_SCHEMA_NAME = 'JsonApiCollectionBaseSchema'; private const COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION = 'JsonApiCollectionBaseSchemaNoPagination'; @@ -128,7 +127,10 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin } $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); - $definitionName = $this->createDefinitionName($className, $format, $inputOrOutputClass, $operation, $jsonApiSerializerContext, $type); + $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $jsonApiSerializerContext + ['schema_type' => $type]); + if (Schema::TYPE_INPUT === $type) { + $definitionName .= '.input'; + } $prefix = $this->getSchemaUriPrefix($schema->getVersion()); $definitions = $schema->getDefinitions(); $collectionKey = $schema->getItemsDefinitionKey(); @@ -390,13 +392,6 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ] + $included; } - private function createDefinitionName(string $className, string $format, ?string $inputOrOutputClass, ?Operation $operation, ?array $serializerContext, string $type): string - { - $name = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, ($serializerContext ?? []) + ['schema_type' => $type]); - - return Schema::TYPE_INPUT === $type ? $name.'.'.self::INPUT_SCHEMA_POSTFIX : $name; - } - private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []);