diff --git a/code_samples/api/rest_api/config/routes_rest.yaml b/code_samples/api/rest_api/config/routes_rest.yaml index 967abc848a..f6ae852e68 100644 --- a/code_samples/api/rest_api/config/routes_rest.yaml +++ b/code_samples/api/rest_api/config/routes_rest.yaml @@ -1,6 +1,6 @@ app.rest.greeting: path: '/greet' - controller: App\Rest\Controller\DefaultController::helloWorld + controller: App\Rest\Controller\DefaultController::greet methods: [GET,POST] defaults: csrf_protection: false diff --git a/code_samples/api/rest_api/config/services.yaml b/code_samples/api/rest_api/config/services.yaml index e8c592606f..8cc7741c89 100644 --- a/code_samples/api/rest_api/config/services.yaml +++ b/code_samples/api/rest_api/config/services.yaml @@ -39,14 +39,9 @@ services: parent: Ibexa\Rest\Server\Controller autowire: true autoconfigure: true - tags: [ 'controller.service_arguments' ] + tags: [ 'controller.service_arguments', 'ibexa.api_platform.resource' ] - App\Rest\ValueObjectVisitor\Greeting: - parent: Ibexa\Contracts\Rest\Output\ValueObjectVisitor - tags: - - { name: ibexa.rest.output.value_object.visitor, type: App\Rest\Values\Greeting } - App\Rest\InputParser\GreetingInput: - parent: Ibexa\Rest\Server\Common\Parser - tags: - - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.GreetingInput } + App\Rest\Serializer\: + resource: '../src/Rest/Serializer/' + tags: ['ibexa.rest.serializer.normalizer'] diff --git a/code_samples/api/rest_api/src/Rest/Controller/DefaultController.php b/code_samples/api/rest_api/src/Rest/Controller/DefaultController.php index 0cfa960a63..9542de3fbe 100644 --- a/code_samples/api/rest_api/src/Rest/Controller/DefaultController.php +++ b/code_samples/api/rest_api/src/Rest/Controller/DefaultController.php @@ -2,24 +2,283 @@ namespace App\Rest\Controller; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Factory\OpenApiFactory; +use ApiPlatform\OpenApi\Model; use App\Rest\Values\Greeting; -use Ibexa\Rest\Message; use Ibexa\Rest\Server\Controller; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Encoder\XmlEncoder; +use Symfony\Component\Serializer\SerializerInterface; +#[Get( + uriTemplate: '/greet', + extraProperties: [OpenApiFactory::OVERRIDE_OPENAPI_RESPONSES => false], + openapi: new Model\Operation( + summary: 'Greet', + description: 'Greets a recipient with a salutation', + tags: [ + 'App', + ], + parameters: [ + new Model\Parameter( + name: 'Accept', + in: 'header', + required: false, + description: 'If set, the greeting is returned in XML or JSON format.', + schema: [ + 'type' => 'string', + ], + example: 'application/vnd.ibexa.api.Greeting+json', + ), + ], + responses: [ + Response::HTTP_OK => [ + 'description' => 'OK - Return a greeting', + 'content' => [ + 'application/vnd.ibexa.api.Greeting+xml' => [ + 'schema' => [ + 'xml' => [ + 'name' => 'Greeting', + 'wrapped' => false, + ], + 'properties' => [ + 'salutation' => [ + 'type' => 'string', + ], + 'recipient' => [ + 'type' => 'string', + ], + 'sentence' => [ + 'type' => 'string', + 'description' => 'Composed sentence using salutation and recipient.', + ], + ], + ], + 'example' => [ + 'salutation' => 'Hello', + 'recipient' => 'World', + 'sentence' => 'Hello World', + ], + ], + 'application/vnd.ibexa.api.Greeting+json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'Greeting' => [ + 'type' => 'object', + 'properties' => [ + 'salutation' => [ + 'type' => 'string', + ], + 'recipient' => [ + 'type' => 'string', + ], + 'sentence' => [ + 'type' => 'string', + 'description' => 'Composed sentence using salutation and recipient.', + ], + ], + ], + ], + ], + 'example' => [ + 'Greeting' => [ + 'salutation' => 'Hello', + 'recipient' => 'World', + 'sentence' => 'Hello World', + ], + ], + ], + ], + ], + ], + ), +)] +#[Post( + uriTemplate: '/greet', + extraProperties: [OpenApiFactory::OVERRIDE_OPENAPI_RESPONSES => false], + openapi: new Model\Operation( + summary: 'Greet', + description: 'Greets a recipient with a salutation', + tags: [ + 'App', + ], + parameters: [ + new Model\Parameter( + name: 'Content-Type', + in: 'header', + required: false, + description: 'The greeting input schema encoded in XML or JSON.', + schema: [ + 'type' => 'string', + ], + example: 'application/vnd.ibexa.api.GreetingInput+json', + ), + new Model\Parameter( + name: 'Accept', + in: 'header', + required: false, + description: 'If set, the greeting is returned in XML or JSON format.', + schema: [ + 'type' => 'string', + ], + example: 'application/vnd.ibexa.api.Greeting+json', + ), + ], + requestBody: new Model\RequestBody( + required: false, + content: new \ArrayObject([ + 'application/vnd.ibexa.api.GreetingInput+xml' => [ + 'schema' => [ + 'type' => 'object', + 'xml' => [ + 'name' => 'GreetingInput', + 'wrapped' => false, + ], + 'properties' => [ + 'salutation' => [ + 'type' => 'string', + 'required' => false, + ], + 'recipient' => [ + 'type' => 'string', + 'required' => false, + ], + ], + ], + 'example' => [ + 'salutation' => 'Good morning', + ], + ], + 'application/vnd.ibexa.api.GreetingInput+json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'GreetingInput' => [ + 'type' => 'object', + 'properties' => [ + 'salutation' => [ + 'type' => 'string', + 'required' => false, + ], + 'recipient' => [ + 'type' => 'string', + 'required' => false, + ], + ], + ], + ], + ], + 'example' => [ + 'GreetingInput' => [ + 'salutation' => 'Good day', + 'recipient' => 'Earth', + ], + ], + ], + ]), + ), + responses: [ + Response::HTTP_OK => [ + 'description' => 'OK - Return a greeting', + 'content' => [ + 'application/vnd.ibexa.api.Greeting+xml' => [ + 'schema' => [ + 'xml' => [ + 'name' => 'Greeting', + 'wrapped' => false, + ], + 'properties' => [ + 'salutation' => [ + 'type' => 'string', + ], + 'recipient' => [ + 'type' => 'string', + ], + 'sentence' => [ + 'type' => 'string', + 'description' => 'Composed sentence using salutation and recipient.', + ], + ], + ], + 'example' => [ + 'salutation' => 'Good morning', + 'recipient' => 'World', + 'sentence' => 'Good Morning World', + ], + ], + 'application/vnd.ibexa.api.Greeting+json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'Greeting' => [ + 'type' => 'object', + 'properties' => [ + 'salutation' => [ + 'type' => 'string', + ], + 'recipient' => [ + 'type' => 'string', + ], + 'sentence' => [ + 'type' => 'string', + 'description' => 'Composed sentence using salutation and recipient.', + ], + ], + ], + ], + ], + 'example' => [ + 'Greeting' => [ + 'salutation' => 'Good day', + 'recipient' => 'Earth', + 'sentence' => 'Good day Earth', + ], + ], + ], + ], + ], + ], + ), +)] class DefaultController extends Controller { - public function greet(Request $request): Greeting + public const DEFAULT_FORMAT = 'xml'; + + public const AVAILABLE_FORMATS = ['json', 'xml']; + + public function __construct(private SerializerInterface $serializer) + { + } + + public function greet(Request $request): Response|Greeting { - if ('POST' === $request->getMethod()) { - return $this->inputDispatcher->parse( - new Message( - ['Content-Type' => $request->headers->get('Content-Type')], - $request->getContent() - ) - ); + $contentType = $request->headers->get('Content-Type'); + if ($contentType) { + preg_match('@.*[/+](?P[^/+]+)@', $contentType, $matches); + $format = empty($matches['format']) ? self::DEFAULT_FORMAT : $matches['format']; + $input = $request->getContent(); + $greeting = $this->serializer->deserialize($input, Greeting::class, $format); + } else { + $greeting = new Greeting(); } - return new Greeting(); + //return $greeting; + + $accept = $request->headers->get('Accept', 'application/' . self::DEFAULT_FORMAT); + preg_match('@.*[/+](?P[^/+]+)@', $accept, $matches); + $format = empty($matches['format']) ? self::DEFAULT_FORMAT : $matches['format']; + if (!in_array($format, self::AVAILABLE_FORMATS)) { + $format = self::DEFAULT_FORMAT; + } + + $serialized = $this->serializer->serialize($greeting, $format, [ + XmlEncoder::ROOT_NODE_NAME => 'Greeting', + ]); + + return new Response($serialized, Response::HTTP_OK, ['Content-Type' => "application/vnd.ibexa.api.Greeting+$format"]); } } diff --git a/code_samples/api/rest_api/src/Rest/InputParser/GreetingInput.php b/code_samples/api/rest_api/src/Rest/InputParser/GreetingInput.php deleted file mode 100644 index b3936d50e6..0000000000 --- a/code_samples/api/rest_api/src/Rest/InputParser/GreetingInput.php +++ /dev/null @@ -1,20 +0,0 @@ - $object->salutation, + 'Recipient' => $object->recipient, + 'Sentence' => "{$object->salutation} {$object->recipient}", + ]; + if ('json' === $format) { + $data = ['Greeting' => $data]; + } + + return $this->normalizer->normalize($data, $format, $context); + } +} diff --git a/code_samples/api/rest_api/src/Rest/ValueObjectVisitor/Greeting.php b/code_samples/api/rest_api/src/Rest/ValueObjectVisitor/Greeting.php deleted file mode 100644 index f909993490..0000000000 --- a/code_samples/api/rest_api/src/Rest/ValueObjectVisitor/Greeting.php +++ /dev/null @@ -1,21 +0,0 @@ -setHeader('Content-Type', $generator->getMediaType('Greeting')); - $generator->startObjectElement('Greeting'); - $generator->attribute('href', $this->router->generate('app.rest.greeting')); - $generator->valueElement('Salutation', $data->salutation); - $generator->valueElement('Recipient', $data->recipient); - $generator->valueElement('Sentence', "{$data->salutation} {$data->recipient}"); - $generator->endObjectElement('Greeting'); - } -} diff --git a/docs/api/rest_api/extending_rest_api/creating_new_rest_resource.md b/docs/api/rest_api/extending_rest_api/creating_new_rest_resource.md index d057cb05ad..176d93578c 100644 --- a/docs/api/rest_api/extending_rest_api/creating_new_rest_resource.md +++ b/docs/api/rest_api/extending_rest_api/creating_new_rest_resource.md @@ -8,8 +8,8 @@ To create a new REST resource, you need to prepare: - the REST route leading to a controller action - the controller and its action -- one or several `InputParser` objects if the controller needs to receive a payload to treat, one or several value classes to represent this payload and potentially one or several new media types to type this payload in the `Content-Type` header (optional) -- one or several new value classes to represent the controller action result, their `ValueObjectVisitor` to help the generator to turn this into XML or JSON and potentially one or several new media types to claim in the `Accept` header the desired value (optional) +- one or several input denormalizers if the controller needs to receive a payload to treat, one or several value classes to represent this payload, and potentially one or several new media types to type this payload in the `Content-Type` header (optional) +- one or several new value classes to represent the controller action result, their normalizers to help the generator to turn this into XML or JSON, and potentially one or several new media types to claim in the `Accept` header the desired value (optional) - the addition of this resource route to the REST root (optional) In the following example, you add a greeting resource to the REST API. @@ -51,19 +51,26 @@ services: [[= include_file('code_samples/api/rest_api/config/services.yaml', 36, 42) =]] ``` -Having the REST controllers set as services enables using features such as the `InputDispatcher` service in the [Controller action](#controller-action). +Having the REST controllers set as services enables using features such as + +- TODO: `controller.service_arguments`?? +- TODO: `ibexa.api_platform.resource` tag is needed to have the route available in live doc (/api/ibexa/v2/doc#/App/api_greet_get) ### Controller action A REST controller should: -- return a value object and have a `Generator` and `ValueObjectVisitor`s producing the XML or JSON output -- extend `Ibexa\Rest\Server\Controller` to inherit utils methods and properties like `InputDispatcher` or `RequestParser` +- return an object (passed automatically to a normaliser) or a `Response` (to customize it further) +- TODO: extend `Ibexa\Rest\Server\Controller` to inherit utils methods and properties like `InputDispatcher` or `RequestParser` ``` php -[[= include_file('code_samples/api/rest_api/src/Rest/Controller/DefaultController.php') =]] +[[= include_file('code_samples/api/rest_api/src/Rest/Controller/DefaultController.php', 0, 14) =]] +[[= include_file('code_samples/api/rest_api/src/Rest/Controller/DefaultController.php', 246) =]] ``` +
+TODO + If the returned value was depending on a location, it could have been wrapped in a `CachedValue` to be cached by the reverse proxy (like Varnish) for future calls. `CachedValue` is used in the following way: @@ -75,52 +82,34 @@ return new CachedValue( ); ``` -## Value and ValueObjectVisitor - -``` php -[[= include_file('code_samples/api/rest_api/src/Rest/Values/Greeting.php') =]] -``` - -A `ValueObjectVisitor` must implement the `visit` method. +
-| Argument | Description | -|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| -| `$visitor` | The output visitor.
Can be used to set custom response headers (`setHeader`), HTTP status code ( `setStatus`) | -| `$generator` | The actual response generator. It provides you with a DOM-like API. | -| `$data` | The visited data. The exact object that you returned from the controller.
It can't have a type declaration because the method signature is shared. | +## Value and Normalizer ``` php -[[= include_file('code_samples/api/rest_api/src/Rest/ValueObjectVisitor/Greeting.php') =]] +[[= include_file('code_samples/api/rest_api/src/Rest/Values/Greeting.php') =]] ``` -The `Values/Greeting` class is linked to its `ValueObjectVisitor` through the service tag. - ``` yaml services: #… [[= include_file('code_samples/api/rest_api/config/services.yaml', 43, 48) =]] ``` -Here, the media type is `application/vnd.ibexa.api.Greeting` plus a format. -To have a different vendor than the default, you could create a new `Output\Generator` or hard-code it in the `ValueObjectVisitor` like in the [`RestLocation` example](adding_custom_media_type.md#new-restlocation-valueobjectvisitor). - -## InputParser - -A REST resource could use route parameters to handle input, but this example illustrates the usage of an input parser. - -For this example, the structure is a `GreetingInput` root node with two leaf nodes, `Salutation` and `Recipient`. +A normalizer must implement the `supportsNormalization` and `normalize` methods. ``` php -[[= include_file('code_samples/api/rest_api/src/Rest/InputParser/GreetingInput.php') =]] +[[= include_file('code_samples/api/rest_api/src/Rest/Serializer/GreetingNormalizer.php') =]] ``` -Here, this `InputParser` directly returns the right value object. -In other cases, it could return whatever object is needed to represent the input for the controller to perform its action, like arguments to use with a Repository service. +## Input denormalizer -``` yaml -services: - #… -[[= include_file('code_samples/api/rest_api/config/services.yaml', 48, 53) =]] +A REST resource could use route parameters to handle input, but this example illustrates the usage of denormalized payload. + +For this example, the structure is a `GreetingInput` root node with two leaf nodes, `salutation` and `recipient`. + +``` php +[[= include_file('code_samples/api/rest_api/src/Rest/Serializer/GreetingInputDenormalizer.php') =]] ``` ## Testing the new resource @@ -138,25 +127,25 @@ curl https://api.example.com/api/ibexa/v2/greet --include --request POST \ --header 'Accept: application/vnd.ibexa.api.Greeting+json'; ``` -``` +```http HTTP/1.1 200 OK -Content-Type: application/vnd.ibexa.api.greeting+xml +Content-Type: application/vnd.ibexa.api.Greeting+xml - + Hello World Hello World HTTP/1.1 200 OK -Content-Type: application/vnd.ibexa.api.greeting+xml +Content-Type: application/vnd.ibexa.api.Greeting+xml - - - Good morning - World - Good morning World + + + Good morning + World + Good morning World HTTP/1.1 200 OK @@ -164,8 +153,6 @@ Content-Type: application/vnd.ibexa.api.greeting+json { "Greeting": { - "_media-type": "application\/vnd.ibexa.api.Greeting+json", - "_href": "\/api\/ibexa\/v2\/greet", "Salutation": "Good day", "Recipient": "Earth", "Sentence": "Good day Earth" @@ -173,8 +160,21 @@ Content-Type: application/vnd.ibexa.api.greeting+json } ``` +## Describe resource in OpenAPI schema + +TODO + +```php +[[= include_file('code_samples/api/rest_api/src/Rest/Controller/DefaultController.php', 0, 246) =]] +``` + +
+ + ## Registering resources in REST root + + You can add the new resource to the [root resource](rest_api_usage.md#rest-root) through a configuration with the following pattern: ```yaml @@ -211,3 +211,5 @@ The above example adds the following entry to the root XML output: ```xml ``` + +
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ab3610cd9e..454283cda3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -204,12 +204,6 @@ parameters: count: 1 path: code_samples/api/rest_api/src/Rest/Output/ValueObjectVisitorDispatcher.php - - - message: '#^Method App\\Rest\\ValueObjectVisitor\\Greeting\:\:visit\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: code_samples/api/rest_api/src/Rest/ValueObjectVisitor/Greeting.php - - message: '#^Method App\\Rest\\ValueObjectVisitor\\RestLocation\:\:visit\(\) has no return type specified\.$#' identifier: missingType.return