diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9215dce42..e9a1ceaddf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,10 @@ jobs: composer remove --dev --no-interaction --no-progress --no-update --ansi \ doctrine/mongodb-odm \ doctrine/mongodb-odm-bundle + # https://github.com/doctrine/dbal/issues/5570 + - name: Fix Doctrine dependencies + if: (startsWith(matrix.php, '7.1') || startsWith(matrix.php, '7.2') || startsWith(matrix.php, '7.3')) + run: composer require "doctrine/orm:<2.13" --dev --no-interaction --no-progress --ansi - name: Update project dependencies run: composer update --no-interaction --no-progress --ansi - name: Require Symfony components @@ -230,6 +234,10 @@ jobs: doctrine/mongodb-odm-bundle - name: Update project dependencies run: composer update --no-interaction --no-progress --ansi + # https://github.com/doctrine/dbal/issues/5570 + - name: Fix Doctrine dependencies + if: (startsWith(matrix.php, '7.1') || startsWith(matrix.php, '7.2') || startsWith(matrix.php, '7.3')) + run: composer require "doctrine/orm:<2.13" --dev --no-interaction --no-progress --ansi - name: Require Symfony components if: (!startsWith(matrix.php, '7.1')) run: composer require symfony/uid --dev --no-interaction --no-progress --ansi diff --git a/composer.json b/composer.json index 5826a3a7400..a5ddcde857a 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "justinrainbow/json-schema": "^5.2.1", "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.1", "phpdocumentor/type-resolver": "^0.3 || ^0.4 || ^1.4", + "phpspec/prophecy": "^1.10", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.1", "phpstan/phpstan-doctrine": "^1.0", diff --git a/features/hal/item_uri_template.feature b/features/hal/item_uri_template.feature new file mode 100644 index 00000000000..5c949d07161 --- /dev/null +++ b/features/hal/item_uri_template.feature @@ -0,0 +1,128 @@ +@php8 +@v3 +Feature: Exposing a collection of objects should use the specified operation to generate the IRI + + Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/cars" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON HAL schema + And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["_links", "_embedded", "totalItems"], + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": {"href": {"pattern": "^/cars$"}} + }, + "item": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "properties": {"href": {"pattern": "^/cars/.+$"}} + } + } + } + }, + "totalItems": {"type":"number", "minimum": 2, "maximum": 2}, + "_embedded": { + "type": "object", + "properties": { + "item": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": {"href": {"pattern": "^/cars/.+$"}} + } + } + }, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + } + """ + + Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/brands/renault/cars" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["_links", "_embedded", "totalItems"], + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": {"href": {"pattern": "^/brands/renault/cars$"}} + }, + "item": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "properties": {"href": {"pattern": "^/brands/renault/cars/.+$"}} + } + } + } + }, + "totalItems": {"type":"number", "minimum": 2, "maximum": 2}, + "_embedded": { + "type": "object", + "properties": { + "item": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": {"href": {"pattern": "^/brands/renault/cars/.+$"}} + } + } + }, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + } + """ diff --git a/features/hydra/item_uri_template.feature b/features/hydra/item_uri_template.feature new file mode 100644 index 00000000000..4f615ccfaae --- /dev/null +++ b/features/hydra/item_uri_template.feature @@ -0,0 +1,131 @@ +@php8 +@v3 +Feature: Exposing a collection of objects should use the specified operation to generate the IRI + + Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation + When I send a "GET" request to "/cars" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Car$"}, + "@id": {"pattern": "^/cars$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/cars/.+$"}, + "@type": {"pattern": "^Car$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation + When I send a "GET" request to "/brands/renault/cars" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Car$"}, + "@id": {"pattern": "^/brands/renault/cars$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/brands/renault/cars/.+$"}, + "@type": {"pattern": "^Car$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + Scenario: Create an object without an itemUriTemplate should generate the IRI from the first Get operation + When I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/cars" with body: + """ + { + "owner": "Vincent" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@context": {"pattern": "^/contexts/Car$"}, + "@id": {"pattern": "^/cars/.+$"}, + "@type": {"pattern": "^Car$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + """ + + Scenario: Create an object with an itemUriTemplate should generate the IRI from the correct operation + When I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/brands/renault/cars" with body: + """ + { + "owner": "Vincent" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@context": {"pattern": "^/contexts/Car$"}, + "@id": {"pattern": "^/brands/renault/cars/.+$"}, + "@type": {"pattern": "^Car$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + """ diff --git a/features/jsonapi/item_uri_template.feature b/features/jsonapi/item_uri_template.feature new file mode 100644 index 00000000000..7dbfb223580 --- /dev/null +++ b/features/jsonapi/item_uri_template.feature @@ -0,0 +1,200 @@ +@php8 +@v3 +Feature: Exposing a collection of objects should use the specified operation to generate the IRI + + Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/cars" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON HAL schema + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["links", "meta", "data"], + "properties": { + "links": { + "type": "object", + "additionalProperties": false, + "required": ["self"], + "properties": { + "self": {"pattern": "^/cars$"} + } + }, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["totalItems"], + "properties": { + "totalItems": {"type": "number", "minimum": 2, "maximum": 2} + } + }, + "data": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "attributes"], + "properties": { + "id": {"pattern": "^/cars/.+$"}, + "type": {"pattern": "^Car$"}, + "attributes": { + "type": "object", + "additionalProperties": false, + "required": ["_id", "owner"], + "properties": { + "_id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + } + """ + + Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/brands/renault/cars" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["links", "meta", "data"], + "properties": { + "links": { + "type": "object", + "additionalProperties": false, + "required": ["self"], + "properties": { + "self": {"pattern": "^/brands/renault/cars$"} + } + }, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["totalItems"], + "properties": { + "totalItems": {"type": "number", "minimum": 2, "maximum": 2} + } + }, + "data": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "attributes"], + "properties": { + "id": {"pattern": "^/brands/renault/cars/.+$"}, + "type": {"pattern": "^Car$"}, + "attributes": { + "type": "object", + "additionalProperties": false, + "required": ["_id", "owner"], + "properties": { + "_id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + } + """ + + Scenario: Create an object without an itemUriTemplate should generate the IRI from the first Get operation + When I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/cars" with body: + """ + { + "owner": "Vincent" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["data"], + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "attributes"], + "properties": { + "id": {"pattern": "^/cars/.+$"}, + "type": {"pattern": "^Car$"}, + "attributes": { + "type": "object", + "additionalProperties": false, + "required": ["_id", "owner"], + "properties": { + "_id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + """ + + Scenario: Create an object with an itemUriTemplate should generate the IRI from the correct operation + When I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/brands/renault/cars" with body: + """ + { + "owner": "Vincent" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["data"], + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "attributes"], + "properties": { + "id": {"pattern": "^/brands/renault/cars/.+$"}, + "type": {"pattern": "^Car$"}, + "attributes": { + "type": "object", + "additionalProperties": false, + "required": ["_id", "owner"], + "properties": { + "_id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + """ diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature index ad7ee0344e8..3acdae05d48 100644 --- a/features/jsonld/input_output.feature +++ b/features/jsonld/input_output.feature @@ -321,7 +321,7 @@ Feature: JSON-LD DTO input and output """ @createSchema - Scenario: Initialize input data with a DataTransformerInitializer + Scenario: Initialize input data with a DataTransformerInitializer Given there is an InitializeInput object with id 1 When I send a "PUT" request to "/initialize_inputs/1" with body: """ @@ -348,7 +348,7 @@ Feature: JSON-LD DTO input and output """ { "foo": "test", - "bar": "test" + "bar": "test" } """ Then the response status code should be 400 @@ -356,7 +356,7 @@ Feature: JSON-LD DTO input and output And the JSON node "hydra:description" should be equal to "The input data is misformatted." @!mongodb - Scenario: Reset password through an input DTO without DataTransformer + Scenario: Reset password through an input DTO without DataTransformer When I send a "POST" request to "/user-reset-password" with body: """ { @@ -369,7 +369,7 @@ Feature: JSON-LD DTO input and output And the JSON node "email" should be equal to "user@example.com" @!mongodb - Scenario: Reset password with an invalid payload through an input DTO without DataTransformer + Scenario: Reset password with an invalid payload through an input DTO without DataTransformer And I send a "POST" request to "/user-reset-password" with body: """ { @@ -378,3 +378,122 @@ Feature: JSON-LD DTO input and output """ Then the response status code should be 422 And the response should be in JSON + + @v3 + Scenario: Get a collection with a custom output and without item operations, from a resource without identifier + When I send a "GET" request to "/dummy_collection_dtos" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/DummyCollectionDto$"}, + "@id": {"pattern": "^/dummy_collection_dtos$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "foo", "bar"], + "properties": { + "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@type": {"pattern": "^DummyCollectionDto$"}, + "foo": {"type": "string"}, + "bar": {"type": "integer"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + @v3 + # Cannot generate proper IRI because DTO does not support resource yet + # todo Change member IRI to `/dummy_foos/bar` once DTO support resource + Scenario: Get a collection with a custom output and itemUriTemplate, from a resource without identifier + When I send a "GET" request to "/dummy_foo_collection_dtos" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/DummyFooCollectionDto$"}, + "@id": {"pattern": "^/dummy_foo_collection_dtos$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "foo", "bar"], + "properties": { + "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@type": {"pattern": "^DummyFooCollectionDto$"}, + "foo": {"type": "string"}, + "bar": {"type": "integer"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + @v3 + # Cannot generate proper IRI because DTO does not support output yet + # todo Change member IRI to `/dummy_id_collection_dtos/.+` once DTO support @ApiProperty + Scenario: Get a collection with a custom output and without item operations, from a resource with an identifier + When I send a "GET" request to "/dummy_id_collection_dtos" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/DummyIdCollectionDto$"}, + "@id": {"pattern": "^/dummy_id_collection_dtos$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "foo", "bar"], + "properties": { + "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@type": {"pattern": "^DummyIdCollectionDto$"}, + "id": {"type": "integer"}, + "foo": {"type": "string"}, + "bar": {"type": "integer"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ diff --git a/features/main/not_exposed.feature b/features/main/not_exposed.feature new file mode 100644 index 00000000000..964e4fa45df --- /dev/null +++ b/features/main/not_exposed.feature @@ -0,0 +1,193 @@ +@php8 +@v3 +Feature: Expose only a collection of objects + + # A NotExposed operation with "routeName: api_genid" is automatically added to this resource. + Scenario: Get a collection of objects without identifiers from a single resource with a single collection + When I send a "GET" request to "/chairs" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Chair$"}, + "@id": {"pattern": "^/chairs$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@type": {"pattern": "^Chair$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + # A NotExposed operation with a valid path (e.g.: "/tables/{id}") is automatically added to this resource. + Scenario: Get a collection of objects with identifiers from a single resource with a single collection + When I send a "GET" request to "/tables" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Table$"}, + "@id": {"pattern": "^/tables$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/tables/.+$"}, + "@type": {"pattern": "^Table$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + # A NotExposed operation with a valid path (e.g.: "/forks/{id}") is automatically added to the last resource. + # This operation does not inherit from the resource uriTemplate as it's not intended to. + Scenario Outline: Get a collection of objects with identifiers from a multiple resources class with multiple collections + When I send a "GET" request to "" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Fork$"}, + "@id": {"pattern": "^"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/forks/.+$"}, + "@type": {"pattern": "^Fork$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + Examples: + | uri | + | /forks | + | /fourchettes | + + + # A NotExposed operation is not automatically added. + Scenario Outline: Get a collection of objects with identifiers from a multiple resources class with multiple collections and an item operation + When I send a "GET" request to "" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Spoon$"}, + "@id": {"pattern": "^"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/cuillers/.+$"}, + "@type": {"pattern": "^Spoon$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + Examples: + | uri | + | /spoons | + | /cuillers | + + Scenario Outline: Get a not exposed route returns a 404 with an explanation + When I send a "GET" request to "" + Then the response status code should be 404 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON node "hydra:description" should be equal to "" + Examples: + | uri | hydra:description | + | /.well-known/genid/12345 | This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation. | + | /tables/12345 | This route does not aim to be called. | + | /forks/12345 | This route does not aim to be called. | + + Scenario: Get a single item still works + When I send a "GET" request to "/cuillers/12345" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Spoon", + "@id": "/cuillers/12345", + "@type": "Spoon", + "id": "12345", + "owner": "Vincent" + } + """ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 10526cd36a0..4b0b14b4298 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -286,10 +286,14 @@ parameters: paths: - tests/Fixtures/TestBundle/Document/ - tests/Fixtures/TestBundle/Entity/ + - '#Call to an undefined method ApiPlatform\\Metadata\\.+::getItemUriTemplate\(\)\.#' - '#Access to an undefined property Prophecy\\Prophecy\\ObjectProphecy<(\\?[a-zA-Z0-9_]+)+>::\$[a-zA-Z0-9_]+#' - message: '#Call to an undefined method Doctrine\\Persistence\\ObjectManager::getConnection\(\)#' path: src/Core/Bridge/Doctrine/Common/Util/IdentifierManagerTrait.php + - + message: '#Property Doctrine\\ORM\\Mapping\\ClassMetadataInfo<.+>::\$associationMappings .+ does not accept array#' + path: tests/Doctrine/ # https://github.com/willdurand/Negotiation/issues/89#issuecomment-513283286 - message: '#Call to an undefined method Negotiation\\AcceptHeader::getType\(\)\.#' diff --git a/src/Action/NotExposedAction.php b/src/Action/NotExposedAction.php new file mode 100644 index 00000000000..cfee48b3ffe --- /dev/null +++ b/src/Action/NotExposedAction.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Action; + +use ApiPlatform\Exception\NotExposedHttpException; +use Symfony\Component\HttpFoundation\Request; + +/** + * An action which always returns HTTP 404 Not Found with an explanation for why the operation is not exposed. + */ +final class NotExposedAction +{ + public function __invoke(Request $request): never + { + $message = 'This route does not aim to be called.'; + if ('api_genid' === $request->attributes->get('_route')) { + $message = 'This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation.'; + } + + throw new NotExposedHttpException($message); + } +} diff --git a/src/Core/Swagger/Serializer/DocumentationNormalizer.php b/src/Core/Swagger/Serializer/DocumentationNormalizer.php index 7ab1e3d3781..8e7a153511e 100644 --- a/src/Core/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Core/Swagger/Serializer/DocumentationNormalizer.php @@ -274,6 +274,11 @@ private function addPaths(bool $v3, \ArrayObject $paths, \ArrayObject $definitio } foreach ($operations as $operationName => $operation) { + // Skolem IRI + if ('api_genid' === ($operation['route_name'] ?? null)) { + continue; + } + if (isset($operation['uri_template'])) { $path = str_replace('.{_format}', '', $operation['uri_template']); if (0 !== strpos($path, '/')) { @@ -408,7 +413,7 @@ private function updateGetOperation(bool $v3, \ArrayObject $pathOperation, array $parametersMemory = []; $pathOperation['parameters'] = []; - foreach ($resourceMetadata->getAttributes()['identifiers'] as $parameterName => [$class, $identifier]) { + foreach ($resourceMetadata->getCollectionOperations()[$operationName]['identifiers'] as $parameterName => [$class, $identifier]) { $parameter = ['name' => $parameterName, 'in' => 'path', 'required' => true]; $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string'; $pathOperation['parameters'][] = $parameter; diff --git a/src/Exception/NotExposedHttpException.php b/src/Exception/NotExposedHttpException.php new file mode 100644 index 00000000000..5092738d046 --- /dev/null +++ b/src/Exception/NotExposedHttpException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Exception; + +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * @author Vincent Chalamon + */ +class NotExposedHttpException extends NotFoundHttpException +{ +} diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index acc3586023b..551adbed391 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Hal\Serializer; +use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Core\Api\IriConverterInterface as LegacyIriConverterInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Metadata\ApiProperty; @@ -70,7 +71,7 @@ public function normalize($object, $format = null, array $context = []) } $context = $this->initContext($resourceClass, $context); - $iri = $this->iriConverter instanceof LegacyIriConverterInterface ? $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($object); + $iri = $this->iriConverter instanceof LegacyIriConverterInterface ? $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); $context['iri'] = $iri; $context['api_normalize'] = true; diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index ca2243f3e1c..91a2903edca 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -20,6 +20,8 @@ use ApiPlatform\Core\Api\OperationType; use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\Pagination\PartialPaginatorInterface; @@ -46,11 +48,12 @@ final class CollectionNormalizer implements NormalizerInterface, NormalizerAware private $contextBuilder; private $resourceClassResolver; private $iriConverter; + private $resourceMetadataCollectionFactory; private $defaultContext = [ self::IRI_ONLY => false, ]; - public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, $iriConverter, array $defaultContext = []) + public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, $iriConverter, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, array $defaultContext = []) { $this->contextBuilder = $contextBuilder; $this->resourceClassResolver = $resourceClassResolver; @@ -59,6 +62,7 @@ public function __construct(ContextBuilderInterface $contextBuilder, ResourceCla trigger_deprecation('api-platform/core', '2.7', sprintf('Use an implementation of "%s" instead of "%s".', IriConverterInterface::class, LegacyIriConverterInterface::class)); } $this->iriConverter = $iriConverter; + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -95,7 +99,13 @@ public function normalize($object, $format = null, array $context = []): array $data['@type'] = 'hydra:Collection'; $data['hydra:member'] = []; $iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY]; - unset($context['operation'], $context['operation_name'], $context['uri_variables']); + + if ($this->resourceMetadataCollectionFactory && ($operation = $context['operation'] ?? null) instanceof CollectionOperationInterface && ($itemUriTemplate = $operation->getItemUriTemplate())) { + $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($operation->getItemUriTemplate()); + } else { + unset($context['operation']); + } + unset($context['operation_name'], $context['uri_variables']); foreach ($object as $obj) { if ($iriOnly) { diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index ca8184274af..024c9f3a9ae 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -14,6 +14,7 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Api\ResourceClassResolverInterface; +use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Core\Api\IriConverterInterface as LegacyIriConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; @@ -86,7 +87,7 @@ public function normalize($object, $format = null, array $context = []) } $context = $this->initContext($resourceClass, $context); - $iri = $this->iriConverter instanceof LegacyIriConverterInterface ? $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($object); + $iri = $this->iriConverter instanceof LegacyIriConverterInterface ? $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); $context['iri'] = $iri; $context['api_normalize'] = true; diff --git a/src/JsonApi/Serializer/ObjectNormalizer.php b/src/JsonApi/Serializer/ObjectNormalizer.php index dd69748218f..640439ac2de 100644 --- a/src/JsonApi/Serializer/ObjectNormalizer.php +++ b/src/JsonApi/Serializer/ObjectNormalizer.php @@ -101,8 +101,9 @@ public function normalize($object, $format = null, array $context = []) 'type' => $this->getResourceShortName($resourceClass), ]; } else { + // Not using an IriConverter here is deprecated in 2.7, avoid spl_object_hash as it may collide $resourceData = [ - 'id' => '/.well-known/genid/'.bin2hex(random_bytes(10)), + 'id' => $this->iriConverter instanceof LegacyIriConverterInterface ? '/.well-known/genid/'.bin2hex(random_bytes(10)) : $this->iriConverter->getIriFromResource($object), 'type' => (new \ReflectionClass($this->getObjectClass($object)))->getShortName(), ]; } diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 533105afe45..ea04f293d3f 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonLd; +use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface as LegacyPropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface as LegacyPropertyNameCollectionFactoryInterface; @@ -59,7 +60,9 @@ final class ContextBuilder implements AnonymousContextBuilderInterface */ private $nameConverter; - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, NameConverterInterface $nameConverter = null) + private $iriConverter; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, NameConverterInterface $nameConverter = null, IriConverterInterface $iriConverter = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -67,6 +70,7 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->propertyMetadataFactory = $propertyMetadataFactory; $this->urlGenerator = $urlGenerator; $this->nameConverter = $nameConverter; + $this->iriConverter = $iriConverter; if ($resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) { trigger_deprecation('api-platform/core', '2.7', sprintf('Use "%s" instead of "%s".', ResourceMetadataCollectionFactoryInterface::class, ResourceMetadataFactoryInterface::class)); @@ -188,7 +192,12 @@ public function getAnonymousResourceContext($object, array $context = [], int $r ]; if (!isset($context['iri']) || false !== $context['iri']) { - $jsonLdContext['@id'] = $context['iri'] ?? '/.well-known/genid/'.bin2hex(random_bytes(10)); + // Not using an IriConverter here is deprecated in 2.7, avoid spl_object_hash as it may collide + if (isset($this->iriConverter)) { + $jsonLdContext['@id'] = $context['iri'] ?? $this->iriConverter->getIriFromResource($object); + } else { + $jsonLdContext['@id'] = $context['iri'] ?? '/.well-known/genid/'.bin2hex(random_bytes(10)); + } } if ($context['has_context'] ?? false) { diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 1fc82acc3d1..ec5a91f48b4 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 35b83cd9aea..a13ae2df583 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -14,9 +14,11 @@ namespace ApiPlatform\Metadata\Extractor; use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\Post; use Symfony\Component\Config\Util\XmlUtils; /** @@ -294,7 +296,12 @@ private function buildOperations(\SimpleXMLElement $resource, array $root): ?arr } } + if (\in_array((string) $operation['class'], [GetCollection::class, Post::class], true)) { + $datum['itemUriTemplate'] = $this->phpize($operation, 'itemUriTemplate', 'string'); + } + $data[] = array_merge($datum, [ + 'openapi' => $this->phpize($operation, 'openapi', 'bool'), 'collection' => $this->phpize($operation, 'collection', 'bool'), 'class' => (string) $operation['class'], 'method' => $this->phpize($operation, 'method', 'string'), diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index c2473369807..04539a6360a 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -14,11 +14,13 @@ namespace ApiPlatform\Metadata\Extractor; use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GraphQl\DeleteMutation; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\Post; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -265,9 +267,14 @@ private function buildOperations(array $resource, array $root): ?array } } + if (\in_array((string) $class, [GetCollection::class, Post::class], true)) { + $datum['itemUriTemplate'] = $this->phpize($operation, 'itemUriTemplate', 'string'); + } + $data[] = array_merge($datum, [ 'read' => $this->phpize($operation, 'read', 'bool'), 'deserialize' => $this->phpize($operation, 'deserialize', 'bool'), + 'openapi' => $this->phpize($operation, 'openapi', 'bool'), 'validate' => $this->phpize($operation, 'validate', 'bool'), 'write' => $this->phpize($operation, 'write', 'bool'), 'serialize' => $this->phpize($operation, 'serialize', 'bool'), @@ -283,12 +290,16 @@ private function buildOperations(array $resource, array $root): ?array private function buildGraphQlOperations(array $resource, array $root): ?array { - if (!\array_key_exists('graphQlOperations', $resource)) { + if (!\array_key_exists('graphQlOperations', $resource) || !\is_array($resource['graphQlOperations'])) { return null; } $data = []; foreach (['mutations' => Mutation::class, 'queries' => Query::class, 'subscriptions' => Subscription::class] as $type => $class) { + if (!\array_key_exists($type, $resource['graphQlOperations'])) { + continue; + } + foreach ($resource['graphQlOperations'][$type] as $operation) { $datum = $this->buildBase($operation); foreach ($datum as $key => $value) { @@ -322,6 +333,6 @@ private function buildGraphQlOperations(array $resource, array $root): ?array } } - return $data; + return $data ?: null; } } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 7202feb23c7..02cf1182ed7 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -44,6 +44,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index b599284199b..d515e8e57b3 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 5723b671ba3..b35d1ed4004 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -16,6 +16,8 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class GetCollection extends HttpOperation implements CollectionOperationInterface { + private $itemUriTemplate; + /** * {@inheritdoc} */ @@ -43,6 +45,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -90,8 +93,23 @@ public function __construct( ?string $name = null, $provider = null, $processor = null, - array $extraProperties = [] + array $extraProperties = [], + ?string $itemUriTemplate = null ) { parent::__construct(self::METHOD_GET, ...\func_get_args()); + $this->itemUriTemplate = $itemUriTemplate; + } + + public function getItemUriTemplate(): ?string + { + return $this->itemUriTemplate; + } + + public function withItemUriTemplate(string $itemUriTemplate): self + { + $self = clone $this; + $self->itemUriTemplate = $itemUriTemplate; + + return $self; } } diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 78074df33cc..5adea99e60e 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -69,6 +69,7 @@ class HttpOperation extends Operation */ protected $hydraContext; protected $openapiContext; + protected $openapi; protected $exceptionToStatus; @@ -145,6 +146,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -225,6 +227,7 @@ public function __construct( $this->denormalizationContext = $denormalizationContext; $this->hydraContext = $hydraContext; $this->openapiContext = $openapiContext; + $this->openapi = $openapi; $this->validationContext = $validationContext; $this->filters = $filters; $this->elasticsearch = $elasticsearch; @@ -586,6 +589,19 @@ public function withOpenapiContext(array $openapiContext): self return $self; } + public function getOpenapi(): ?bool + { + return $this->openapi; + } + + public function withOpenapi(bool $openapi): self + { + $self = clone $this; + $self->openapi = $openapi; + + return $self; + } + public function getExceptionToStatus(): ?array { return $this->exceptionToStatus; diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php new file mode 100644 index 00000000000..4d0ac70758a --- /dev/null +++ b/src/Metadata/NotExposed.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +/** + * A NotExposed operation is an operation declared for internal usage, + * for example to generate an IRI on a resource without item operations. + * It is ignored from OpenApi documentation and must return a HTTP 404. + * + * @internal + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class NotExposed extends HttpOperation +{ + /** + * {@inheritdoc} + */ + public function __construct( + string $method = self::METHOD_GET, + ?string $uriTemplate = null, + ?array $types = null, + $formats = null, + $inputFormats = null, + $outputFormats = null, + $uriVariables = null, + ?string $routePrefix = null, + ?string $routeName = null, + ?array $defaults = null, + ?array $requirements = null, + ?array $options = null, + ?bool $stateless = null, + ?string $sunset = null, + ?string $acceptPatch = null, + $status = null, + ?string $host = null, + ?array $schemes = null, + ?string $condition = null, + ?string $controller = 'api_platform.action.not_exposed', + ?array $cacheHeaders = null, + + ?array $hydraContext = null, + ?array $openapiContext = null, + ?bool $openapi = false, + ?array $exceptionToStatus = null, + + ?bool $queryParameterValidationEnabled = null, + + ?string $shortName = null, + ?string $class = null, + ?bool $paginationEnabled = null, + ?string $paginationType = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?bool $paginationFetchJoinCollection = null, + ?bool $paginationUseOutputWalkers = null, + ?array $paginationViaCursor = null, + ?array $order = null, + ?string $description = null, + ?array $normalizationContext = null, + ?array $denormalizationContext = null, + ?string $security = null, + ?string $securityMessage = null, + ?string $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + ?string $securityPostValidation = null, + ?string $securityPostValidationMessage = null, + ?string $deprecationReason = null, + ?array $filters = null, + ?array $validationContext = null, + $input = null, + $output = false, + $mercure = null, + $messenger = null, + ?bool $elasticsearch = null, + ?int $urlGenerationStrategy = null, + ?bool $read = false, + ?bool $deserialize = null, + ?bool $validate = null, + ?bool $write = null, + ?bool $serialize = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?int $priority = null, + ?string $name = null, + $provider = null, + $processor = null, + array $extraProperties = [] + ) { + parent::__construct(...\func_get_args()); + + // Declare overridden parameters because "func_get_args" does not handle default values + $this->controller = $controller; + $this->output = $output; + $this->read = $read; + $this->openapi = $openapi; + } +} diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 1ec586fce53..f78335b3727 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index c041c2a1b7b..3a920671f61 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -16,6 +16,8 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Post extends HttpOperation { + private $itemUriTemplate; + /** * {@inheritdoc} */ @@ -43,6 +45,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -91,8 +94,23 @@ public function __construct( ?string $name = null, $provider = null, $processor = null, - array $extraProperties = [] + array $extraProperties = [], + ?string $itemUriTemplate = null ) { parent::__construct(self::METHOD_POST, ...\func_get_args()); + $this->itemUriTemplate = $itemUriTemplate; + } + + public function getItemUriTemplate(): ?string + { + return $this->itemUriTemplate; + } + + public function withItemUriTemplate(string $itemUriTemplate): self + { + $self = clone $this; + $self->itemUriTemplate = $itemUriTemplate; + + return $self; } } diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 4ad7e902d08..94063c42995 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..621c82afae9 --- /dev/null +++ b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Symfony\Routing\SkolemIriConverter; + +/** + * Adds a {@see NotExposed} operation with {@see NotFoundAction} on a resource which only has a GetCollection. + * This operation helps to generate resource IRI for items. + * + * @author Vincent Chalamon + * @experimental + */ +final class NotExposedOperationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + private $linkFactory; + private $decorated; + + public function __construct(LinkFactoryInterface $linkFactory, ?ResourceMetadataCollectionFactoryInterface $decorated = null) + { + $this->linkFactory = $linkFactory; + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass); + if ($this->decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } + + // Do not add a NotExposed operation on a resourceClass with no resource + if (!$resourceMetadataCollection->count()) { + return $resourceMetadataCollection; + } + + /** @var ApiResource $resource */ + foreach ($resourceMetadataCollection as $resource) { + $operations = $resource->getOperations(); + + foreach ($operations as $operation) { + // Ignore collection and GraphQL operations + if ($operation instanceof CollectionOperationInterface || $operation instanceof GraphQlOperation) { + continue; + } + + // An item operation has been found, nothing to do anymore in this factory + return $resourceMetadataCollection; + } + } + + // No item operation has been found on all resources for resource class: generate one on the last resource + // Helpful to generate an IRI for a resource without declaring the Get operation + /** @var HttpOperation $operation */ + $operation = (new NotExposed())->withClass($resource->getClass())->withShortName($resource->getShortName()); // @phpstan-ignore-line $resource is defined if count > 0 + + if (!$this->linkFactory->createLinksFromIdentifiers($operation)) { + $operation = $operation->withUriTemplate(SkolemIriConverter::$skolemUriTemplate); + } + + $operations->add(sprintf('_api_%s_get', $operation->getShortName()), $operation)->sort(); // @phpstan-ignore-line $operation exists + + return $resourceMetadataCollection; + } +} diff --git a/src/Metadata/Resource/ResourceMetadataCollection.php b/src/Metadata/Resource/ResourceMetadataCollection.php index e5f9652c9c3..479b18bb979 100644 --- a/src/Metadata/Resource/ResourceMetadataCollection.php +++ b/src/Metadata/Resource/ResourceMetadataCollection.php @@ -49,7 +49,7 @@ public function getOperation(?string $operationName = null, bool $forceCollectio $metadata = null; while ($it->valid()) { - /** @var ApiResource */ + /** @var ApiResource $metadata */ $metadata = $it->current(); foreach ($metadata->getOperations() ?? [] as $name => $operation) { @@ -61,6 +61,10 @@ public function getOperation(?string $operationName = null, bool $forceCollectio if ($name === $operationName) { return $this->operationCache[$operationName] = $operation; } + + if ($operation->getUriTemplate() === $operationName) { + return $this->operationCache[$operationName] = $operation; + } } foreach ($metadata->getGraphQlOperations() ?? [] as $name => $operation) { diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 79d9e16e1db..b435d991b34 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -141,6 +141,11 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } + // Operation ignored from OpenApi + if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { + continue; + } + $uriVariables = $operation->getUriVariables(); $resourceClass = $operation->getClass() ?? $resource->getClass(); $routeName = $operation->getRouteName() ?? $operation->getName(); @@ -392,6 +397,11 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection continue; } + // Operation ignored from OpenApi + if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { + continue; + } + $operationUriVariables = $operation->getUriVariables(); foreach ($currentOperation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) { if (!isset($operationUriVariables[$parameterName])) { diff --git a/src/Serializer/AbstractCollectionNormalizer.php b/src/Serializer/AbstractCollectionNormalizer.php index d91fe5ff0b4..6b66d41a96b 100644 --- a/src/Serializer/AbstractCollectionNormalizer.php +++ b/src/Serializer/AbstractCollectionNormalizer.php @@ -15,7 +15,10 @@ use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\Pagination\PartialPaginatorInterface; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; @@ -87,12 +90,19 @@ public function normalize($object, $format = null, array $context = []) $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']); $context = $this->initContext($resourceClass, $context); $data = []; + $paginationData = $this->getPaginationData($object, $context); + + /** @var ResourceMetadata|ResourceMetadataCollection */ + $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? ''); + if ($metadata instanceof ResourceMetadataCollection && ($operation = $context['operation'] ?? null) instanceof CollectionOperationInterface && ($itemUriTemplate = $operation->getItemUriTemplate())) { + $context['operation'] = $metadata->getOperation($itemUriTemplate); + } else { + unset($context['operation']); + } + unset($context['operation_type'], $context['operation_name']); + $itemsData = $this->getItemsData($object, $format, $context); - return array_merge_recursive( - $data, - $this->getPaginationData($object, $context), - $this->getItemsData($object, $format, $context) - ); + return array_merge_recursive($data, $paginationData, $itemsData); } /** diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 12734a5e28a..279edf8fd3a 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -188,9 +188,10 @@ public function normalize($object, $format = null, array $context = []) return $this->serializer->normalize($transformed, $format, $context); } - unset($context['output']); - unset($context['operation']); - unset($context['operation_name']); + unset($context['output'], $context['operation_name']); + if ($this->resourceMetadataFactory instanceof ResourceMetadataCollectionFactoryInterface && !isset($context['operation'])) { + $context['operation'] = $this->resourceMetadataFactory->create($context['resource_class'])->getOperation(); + } $context['resource_class'] = $outputClass; $context['api_sub_level'] = true; $context[self::ALLOW_EXTRA_ATTRIBUTES] = false; diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 30b0043ce66..a3d499e572f 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -104,7 +104,9 @@ + + @@ -140,10 +142,14 @@ - null + + + + + diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index 6710e7b7eb4..bdd2351d42e 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -56,6 +56,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/jsonld.xml b/src/Symfony/Bundle/Resources/config/jsonld.xml index a7437a579cb..38042c0b34e 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Symfony/Bundle/Resources/config/jsonld.xml @@ -11,6 +11,8 @@ + null + diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index dcefe35db1d..3e011de0d62 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -23,6 +23,11 @@ %api_platform.defaults% + + + + + diff --git a/src/Symfony/Bundle/Resources/config/routing/genid.xml b/src/Symfony/Bundle/Resources/config/routing/genid.xml new file mode 100644 index 00000000000..5f1a120f94a --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/routing/genid.xml @@ -0,0 +1,13 @@ + + + + + + api_platform.action.not_exposed + true + + + diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index 5a7459efdd6..8d4dd76e488 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -111,6 +111,10 @@ public function load($data, $type = null): RouteCollection continue; } + if (SkolemIriConverter::$skolemUriTemplate === $operation->getUriTemplate()) { + continue; + } + $legacyDefaults = []; if ($operation->getExtraProperties()['is_legacy_subresource'] ?? false) { @@ -183,6 +187,8 @@ public function supports($resource, $type = null): bool */ private function loadExternalFiles(RouteCollection $routeCollection): void { + $routeCollection->addCollection($this->fileLoader->load('genid.xml')); + if ($this->entrypointEnabled) { $routeCollection->addCollection($this->fileLoader->load('api.xml')); } diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 848943f3c31..6c9963352f9 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -52,8 +52,9 @@ final class IriConverter implements IriConverterInterface private $router; private $identifiersExtractor; private $resourceMetadataCollectionFactory; + private $decorated; - public function __construct(ProviderInterface $provider, RouterInterface $router, IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, UriVariablesConverterInterface $uriVariablesConverter = null) + public function __construct(ProviderInterface $provider, RouterInterface $router, IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, UriVariablesConverterInterface $uriVariablesConverter = null, IriConverterInterface $decorated = null) { $this->provider = $provider; $this->router = $router; @@ -63,6 +64,7 @@ public function __construct(ProviderInterface $provider, RouterInterface $router // For the ResourceClassInfoTrait $this->resourceClassResolver = $resourceClassResolver; $this->resourceMetadataFactory = $resourceMetadataCollectionFactory; + $this->decorated = $decorated; } /** @@ -134,6 +136,11 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter // Custom resources should have the same IRI as requested, it was not the case pre 2.7 $isLegacyCustomResource = ($operation->getExtraProperties()['is_legacy_resource_metadata'] ?? false) && ($operation->getExtraProperties()['user_defined_uri_template'] ?? false); + // FIXME: to avoid the method_exists we could create an interface for the Post operation, we can't guarantee that the user extended our ApiPlatform\Metadata\Post + if ($operation instanceof HttpOperation && HttpOperation::METHOD_POST === $operation->getMethod() && method_exists($operation, 'getItemUriTemplate') && ($itemUriTemplate = $operation->getItemUriTemplate())) { + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($itemUriTemplate); + } + // In symfony the operation name is the route name, try to find one if none provided if ( !$operation->getName() @@ -154,6 +161,15 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter $identifiers = []; } + if (!$operation->getName() || ($operation instanceof HttpOperation && SkolemIriConverter::$skolemUriTemplate === $operation->getUriTemplate())) { + if (!$this->decorated) { + throw new InvalidArgumentException(sprintf('Unable to generate an IRI for the item of type "%s"', $resourceClass)); + } + + // Use a skolem iri, the route is defined in genid.xml random bytes as a hash map + virer les operation name == uri template sauf en interne dans symfony + return $this->decorated->getIriFromResource($item, $referenceType = UrlGeneratorInterface::ABS_PATH, $operation, $context); + } + if (\is_object($item)) { try { $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item, $operation); @@ -165,11 +181,6 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter } } - // TODO: call the Skolem IRI generator - if (!$operation->getName()) { - return null; - } - try { return $this->router->generate($operation->getName(), $identifiers, $operation->getUrlGenerationStrategy() ?? $referenceType); } catch (RoutingExceptionInterface $e) { diff --git a/src/Symfony/Routing/SkolemIriConverter.php b/src/Symfony/Routing/SkolemIriConverter.php new file mode 100644 index 00000000000..1a6760991d1 --- /dev/null +++ b/src/Symfony/Routing/SkolemIriConverter.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Routing; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Operation; +use SplObjectStorage; +use Symfony\Component\Routing\RouterInterface; + +/** + * {@inheritdoc} + * + * @experimental + * + * @author Antoine Bluchet + */ +final class SkolemIriConverter implements IriConverterInterface +{ + public static $skolemUriTemplate = '/.well-known/genid/{id}'; + + private $objectHashMap; + private $classHashMap = []; + private $router; + + public function __construct(RouterInterface $router) + { + $this->router = $router; + $this->objectHashMap = new SplObjectStorage(); + } + + /** + * {@inheritdoc} + */ + public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null) + { + throw new ItemNotFoundException(sprintf('Item not found for "%s".', $iri)); + } + + /** + * {@inheritdoc} + */ + public function getIriFromResource($item, int $referenceType = UrlGeneratorInterface::ABS_PATH, Operation $operation = null, array $context = []): ?string + { + $referenceType = $operation ? ($operation->getUrlGenerationStrategy() ?? $referenceType) : $referenceType; + if (($isObject = \is_object($item)) && $this->objectHashMap->contains($item)) { + return $this->router->generate('api_genid', ['id' => $this->objectHashMap[$item]], $referenceType); + } + + if (\is_string($item) && isset($this->classHashMap[$item])) { + return $this->router->generate('api_genid', ['id' => $this->classHashMap[$item]], $referenceType); + } + + $id = bin2hex(random_bytes(10)); + + if ($isObject) { + $this->objectHashMap[$item] = $id; + } else { + $this->classHashMap[$item] = $id; + } + + return $this->router->generate('api_genid', ['id' => $id], $referenceType); + } +} diff --git a/src/Test/DoctrineMongoDbOdmTestCase.php b/src/Test/DoctrineMongoDbOdmTestCase.php index b2bec59a0f2..71f613c9dd1 100644 --- a/src/Test/DoctrineMongoDbOdmTestCase.php +++ b/src/Test/DoctrineMongoDbOdmTestCase.php @@ -20,7 +20,6 @@ use Doctrine\ODM\MongoDB\Mapping\Driver\AnnotationDriver; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; - use function sys_get_temp_dir; /** diff --git a/tests/Fixtures/TestBundle/Dto/DummyCollectionDtoOutput.php b/tests/Fixtures/TestBundle/Dto/DummyCollectionDtoOutput.php new file mode 100644 index 00000000000..119584c5ded --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/DummyCollectionDtoOutput.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Dto; + +class DummyCollectionDtoOutput +{ + /** + * @var string + */ + public $foo; + + /** + * @var int + */ + public $bar; + + public function __construct(string $foo, int $bar) + { + $this->foo = $foo; + $this->bar = $bar; + } +} diff --git a/tests/Fixtures/TestBundle/Dto/DummyIdCollectionDtoOutput.php b/tests/Fixtures/TestBundle/Dto/DummyIdCollectionDtoOutput.php new file mode 100644 index 00000000000..68e86959c7e --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/DummyIdCollectionDtoOutput.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Dto; + +class DummyIdCollectionDtoOutput extends DummyCollectionDtoOutput +{ + /** + * @var int + */ + public $id; + + public function __construct(int $id, string $foo, int $bar) + { + parent::__construct($foo, $bar); + + $this->id = $id; + } +} diff --git a/tests/Fixtures/TestBundle/Model/Car.php b/tests/Fixtures/TestBundle/Model/Car.php new file mode 100644 index 00000000000..5d3100b7a45 --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Car.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Model; + +/** + * Class with multiple resources, each with a GetCollection, a Get and a Post operations. + * Using itemUriTemplate on GetCollection and Post operations should specify which operation to use to generate the IRI. + * + * @author Vincent Chalamon + */ +class Car +{ + public $id; + public $owner; + + public function __construct($id = null, $owner = null) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Model/Chair.php b/tests/Fixtures/TestBundle/Model/Chair.php new file mode 100644 index 00000000000..15bdd74b799 --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Chair.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Model; + +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; + +/** + * Single resource without identifiers and with a single collection. + * A NotExposed operation with "routeName: api_genid" is automatically added to this resource. + * + * @see NotExposedOperationResourceMetadataCollectionFactory::create() + * + * @author Vincent Chalamon + */ +class Chair +{ + public $id; + public $owner; + + public function __construct($id, $owner) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Model/DummyCollectionDto.php b/tests/Fixtures/TestBundle/Model/DummyCollectionDto.php new file mode 100644 index 00000000000..12de88fbb1f --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/DummyCollectionDto.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Model; + +/** + * Dummy resource without identifier, with a GetCollection with an output, and without any other operations. + */ +class DummyCollectionDto +{ + public $text; + + public function __construct($text) + { + $this->text = $text; + } +} diff --git a/tests/Fixtures/TestBundle/Model/DummyFooCollectionDto.php b/tests/Fixtures/TestBundle/Model/DummyFooCollectionDto.php new file mode 100644 index 00000000000..a100722951d --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/DummyFooCollectionDto.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Model; + +/** + * Dummy resource without identifier, with a GetCollection with an output and itemUriTemplate. + */ +class DummyFooCollectionDto +{ + public $text; + + public function __construct($text) + { + $this->text = $text; + } +} diff --git a/tests/Fixtures/TestBundle/Model/DummyIdCollectionDto.php b/tests/Fixtures/TestBundle/Model/DummyIdCollectionDto.php new file mode 100644 index 00000000000..229dc1a31cf --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/DummyIdCollectionDto.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Model; + +/** + * Dummy resource with an identifier, with a GetCollection with an output, and without any other operations. + */ +class DummyIdCollectionDto extends DummyCollectionDto +{ + public $id; + + public function __construct($id, $text) + { + parent::__construct($text); + + $this->id = $id; + } +} diff --git a/tests/Fixtures/TestBundle/Model/Fork.php b/tests/Fixtures/TestBundle/Model/Fork.php new file mode 100644 index 00000000000..c8ffc30134a --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Fork.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Model; + +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; + +/** + * Multiple resources with an identifier and multiple collections. + * A NotExposed operation with a valid path (e.g.: "/forks/{id}") is automatically added to the last resource. + * This operation does not inherit from the resource uriTemplate as it's not intended to. + * + * @see NotExposedOperationResourceMetadataCollectionFactory::create() + * + * @author Vincent Chalamon + */ +class Fork +{ + public $id; + public $owner; + + public function __construct($id, $owner) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Model/Spoon.php b/tests/Fixtures/TestBundle/Model/Spoon.php new file mode 100644 index 00000000000..6bad032f025 --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Spoon.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Model; + +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; + +/** + * Multiple resources with an identifier and multiple collections and an item operation. + * A NotExposed operation is not automatically added. + * + * @see NotExposedOperationResourceMetadataCollectionFactory::create() + * + * @author Vincent Chalamon + */ +class Spoon +{ + public $id; + public $owner; + + public function __construct($id, $owner) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Model/Table.php b/tests/Fixtures/TestBundle/Model/Table.php new file mode 100644 index 00000000000..bf21dfb405b --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Table.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Model; + +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; + +/** + * Single resource with an identifier and a single collection. + * A NotExposed operation with a valid path (e.g.: "/tables/{id}") is automatically added to this resource. + * + * @see NotExposedOperationResourceMetadataCollectionFactory::create() + * + * @author Vincent Chalamon + */ +class Table +{ + public $id; + public $owner; + + public function __construct($id, $owner) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml b/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml index 9346211022b..1e84954e151 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml @@ -3,6 +3,10 @@ properties: foo: identifier: true + ApiPlatform\Tests\Fixtures\TestBundle\Model\Chair: + id: + identifier: false + resources: ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyAddress: operations: @@ -20,4 +24,87 @@ resources: ApiPlatform\Metadata\GetCollection: ~ ApiPlatform\Metadata\Get: ~ + ApiPlatform\Tests\Fixtures\TestBundle\Model\Chair: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + + ApiPlatform\Tests\Fixtures\TestBundle\Model\Fork: + - graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + - uriTemplate: /fourchettes + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + + ApiPlatform\Tests\Fixtures\TestBundle\Model\Spoon: + - graphQlOperations: null + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + - graphQlOperations: ~ + operations: + ApiPlatform\Metadata\GetCollection: + uriTemplate: /cuillers + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + ApiPlatform\Metadata\Get: + uriTemplate: /cuillers/{id} + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + + ApiPlatform\Tests\Fixtures\TestBundle\Model\Table: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + ApiPlatform\Tests\Fixtures\TestBundle\Entity\FlexConfig: ~ + + ApiPlatform\Tests\Fixtures\TestBundle\Model\Car: + - graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + ApiPlatform\Metadata\Post: + processor: ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor + ApiPlatform\Metadata\Get: ~ + - graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + uriTemplate: /brands/renault/cars + itemUriTemplate: /brands/renault/cars/{id} + ApiPlatform\Metadata\Post: + processor: ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor + uriTemplate: /brands/renault/cars + itemUriTemplate: /brands/renault/cars/{id} + ApiPlatform\Metadata\Get: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + uriTemplate: /brands/renault/cars/{id} + + ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyCollectionDto: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + output: ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyCollectionDtoOutput + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider + + ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyFooCollectionDto: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + output: ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyCollectionDtoOutput + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider + itemUriTemplate: /dummy_foos/bar + ApiPlatform\Metadata\Get: + uriTemplate: /dummy_foos/bar + + ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyIdCollectionDto: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + output: ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyIdCollectionDtoOutput + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider diff --git a/tests/Fixtures/TestBundle/State/CarProcessor.php b/tests/Fixtures/TestBundle/State/CarProcessor.php new file mode 100644 index 00000000000..f2e5e4c1e4a --- /dev/null +++ b/tests/Fixtures/TestBundle/State/CarProcessor.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; + +class CarProcessor implements ProcessorInterface +{ + /** + * {@inheritdoc} + */ + public function process($data, Operation $operation, array $uriVariables = [], array $context = []) + { + $data->id = (string) random_int(1, 10); + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php b/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php new file mode 100644 index 00000000000..7aa5703655e --- /dev/null +++ b/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyCollectionDtoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyIdCollectionDtoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyFooCollectionDto; + +class DummyCollectionDtoProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + $class = $operation->getOutput()['class']; + switch ($class) { + case DummyCollectionDtoOutput::class: + case DummyFooCollectionDto::class: + return [ + new $class('lorem', 1), + new $class('ipsum', 2), + ]; + case DummyIdCollectionDtoOutput::class: + return [ + new $class(1, 'lorem', 1), + new $class(2, 'ipsum', 2), + ]; + default: + return []; + } + } +} diff --git a/tests/Fixtures/TestBundle/State/FakeProvider.php b/tests/Fixtures/TestBundle/State/FakeProvider.php new file mode 100644 index 00000000000..34bc7600383 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/FakeProvider.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; + +final class FakeProvider implements ProviderInterface +{ + /** + * {@inheritDoc} + * + * @return array|object|null + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []) + { + $className = $operation->getClass(); + $data = [ + '12345' => new $className('12345', 'Vincent'), + '67890' => new $className('67890', 'Grégoire'), + ]; + + if (isset($uriVariables['id'])) { + return $data[$uriVariables['id']] ?? null; + } + + return array_values($data); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index b8b19af66dd..21568e81d20 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -107,6 +107,12 @@ services: tags: - { name: 'api_platform.state_provider' } + ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider' + public: false + tags: + - { name: 'api_platform.state_provider' } + # related_questions.state_provider: # class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\RelatedQuestionsProvider' # public: false @@ -134,6 +140,15 @@ services: tags: - { name: 'api_platform.state_provider' } + ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider' + tags: + - { name: 'api_platform.state_provider' } + + ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor' + tags: + - { name: 'api_platform.state_processor' } ApiPlatform\Tests\Fixtures\TestBundle\State\ResourceInterfaceImplementationProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\ResourceInterfaceImplementationProvider' diff --git a/tests/Metadata/Extractor/XmlExtractorTest.php b/tests/Metadata/Extractor/XmlExtractorTest.php index c04ae36ae69..dc1f9b45e8f 100644 --- a/tests/Metadata/Extractor/XmlExtractorTest.php +++ b/tests/Metadata/Extractor/XmlExtractorTest.php @@ -262,6 +262,8 @@ public function testValidXML(): void 'priority' => null, 'processor' => null, 'provider' => null, + 'openapi' => null, + 'itemUriTemplate' => null, ], [ 'name' => null, @@ -357,6 +359,7 @@ public function testValidXML(): void 'priority' => null, 'processor' => null, 'provider' => null, + 'openapi' => null, ], ], 'graphQlOperations' => null, diff --git a/tests/Metadata/Extractor/YamlExtractorTest.php b/tests/Metadata/Extractor/YamlExtractorTest.php index 89927829ac1..843df41f723 100644 --- a/tests/Metadata/Extractor/YamlExtractorTest.php +++ b/tests/Metadata/Extractor/YamlExtractorTest.php @@ -299,6 +299,8 @@ public function testValidYaml(): void 'priority' => null, 'processor' => null, 'provider' => null, + 'openapi' => null, + 'itemUriTemplate' => null, ], [ 'name' => null, @@ -375,6 +377,7 @@ public function testValidYaml(): void 'priority' => null, 'processor' => null, 'provider' => null, + 'openapi' => null, ], ], 'graphQlOperations' => null, diff --git a/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php new file mode 100644 index 00000000000..a2c59090926 --- /dev/null +++ b/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AttributeResource; +use ApiPlatform\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; + +/** + * @author Vincent Chalamon + */ +class NotExposedOperationResourceMetadataCollectionFactoryTest extends TestCase +{ + use ProphecyTrait; + + public function testItIgnoresClassesWithoutResources() + { + $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(HttpOperation::class))->shouldNotBeCalled(); + + $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( + new ResourceMetadataCollection(AttributeResource::class, []), + ); + + $factory = new NotExposedOperationResourceMetadataCollectionFactory($linkFactoryProphecy->reveal(), $resourceCollectionMetadataFactoryProphecy->reveal()); + $this->assertEquals( + new ResourceMetadataCollection(AttributeResource::class, []), + $factory->create(AttributeResource::class) + ); + } + + public function testItIgnoresResourcesWithAnItemOperation() + { + $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(HttpOperation::class))->shouldNotBeCalled(); + + $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get' => new Get(uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + ], + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], + class: AttributeResource::class + ), + ]), + ); + + $factory = new NotExposedOperationResourceMetadataCollectionFactory($linkFactoryProphecy->reveal(), $resourceCollectionMetadataFactoryProphecy->reveal()); + $this->assertEquals( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get' => new Get(uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + ], + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], + class: AttributeResource::class + ), + ]), + $factory->create(AttributeResource::class) + ); + } + + public function testItAddsANotExposedOperationWithoutRouteNameOnTheLastResource() + { + $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(HttpOperation::class))->willReturn([new Link()])->shouldBeCalled(); + + $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + ], + class: AttributeResource::class + ), + ]), + ); + + $factory = new NotExposedOperationResourceMetadataCollectionFactory($linkFactoryProphecy->reveal(), $resourceCollectionMetadataFactoryProphecy->reveal()); + $this->assertEquals( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + '_api_AttributeResource_get' => new NotExposed(controller: 'api_platform.action.not_exposed', shortName: 'AttributeResource', class: AttributeResource::class, output: false, read: false), + ], + class: AttributeResource::class + ), + ]), + $factory->create(AttributeResource::class) + ); + } + + public function testItAddsANotExposedOperationWithRouteNameOnTheLastResource() + { + $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(HttpOperation::class))->willReturn([])->shouldBeCalled(); + + $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + ], + class: AttributeResource::class + ), + ]), + ); + + $factory = new NotExposedOperationResourceMetadataCollectionFactory($linkFactoryProphecy->reveal(), $resourceCollectionMetadataFactoryProphecy->reveal()); + $this->assertEquals( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + '_api_AttributeResource_get' => new NotExposed(uriTemplate: '/.well-known/genid/{id}', controller: 'api_platform.action.not_exposed', shortName: 'AttributeResource', class: AttributeResource::class, output: false, read: false), + ], + class: AttributeResource::class + ), + ]), + $factory->create(AttributeResource::class) + ); + } +} diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 170e031134e..74c8d25f0c0 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -25,6 +25,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -69,6 +70,8 @@ public function testInvoke(): void 'class' => OutputDto::class, ])->withPaginationClientItemsPerPage(true)->withShortName('Dummy')->withDescription('This is a dummy'); $dummyResource = (new ApiResource())->withOperations(new Operations([ + 'ignored' => new NotExposed(), + 'ignoredWithUriTemplate' => (new NotExposed())->withUriTemplate('/dummies/{id}'), 'getDummyItem' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 'putDummyItem' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 'deleteDummyItem' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), diff --git a/tests/Symfony/Routing/IriConverterTest.php b/tests/Symfony/Routing/IriConverterTest.php index 6befb079397..96453c9b60d 100644 --- a/tests/Symfony/Routing/IriConverterTest.php +++ b/tests/Symfony/Routing/IriConverterTest.php @@ -24,11 +24,13 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\ProviderInterface; use ApiPlatform\Symfony\Routing\IriConverter; +use ApiPlatform\Symfony\Routing\SkolemIriConverter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -157,6 +159,22 @@ public function testGetCollectionIri() $this->assertEquals('/dummies', $iriConverter->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation)); } + public function testGetGenidIriFromUnnamedOperation() + { + $operation = new NotExposed(); + $route = '/.well-known/genid/42'; + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('api_genid', Argument::type('array'), UrlGeneratorInterface::ABS_PATH)->shouldBeCalled()->willReturn($route); + + $skolemIriConverter = new SkolemIriConverter($routerProphecy->reveal()); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [])); + + $iriConverter = $this->getIriConverter(null, $routerProphecy, null, $resourceMetadataCollectionFactoryProphecy, null, $skolemIriConverter); + $this->assertEquals($route, $iriConverter->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation)); + } + public function testGetIriFromResourceClassWithIdentifiers() { $operationName = 'operation_name'; @@ -265,7 +283,7 @@ private function getResourceClassResolver() return $resourceClassResolver->reveal(); } - private function getIriConverter($stateProviderProphecy = null, $routerProphecy = null, $identifiersExtractorProphecy = null, $resourceMetadataCollectionFactoryProphecy = null, $uriVariablesConverter = null) + private function getIriConverter($stateProviderProphecy = null, $routerProphecy = null, $identifiersExtractorProphecy = null, $resourceMetadataCollectionFactoryProphecy = null, $uriVariablesConverter = null, $decorated = null) { if (!$stateProviderProphecy) { $stateProviderProphecy = $this->prophesize(ProviderInterface::class); @@ -283,6 +301,6 @@ private function getIriConverter($stateProviderProphecy = null, $routerProphecy $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); } - return new IriConverter($stateProviderProphecy->reveal(), $routerProphecy->reveal(), $identifiersExtractorProphecy->reveal(), $this->getResourceClassResolver(), $resourceMetadataCollectionFactoryProphecy->reveal(), $uriVariablesConverter); + return new IriConverter($stateProviderProphecy->reveal(), $routerProphecy->reveal(), $identifiersExtractorProphecy->reveal(), $this->getResourceClassResolver(), $resourceMetadataCollectionFactoryProphecy->reveal(), $uriVariablesConverter, $decorated); } }