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 da1bb5a346..0000000000 --- a/code_samples/api/rest_api/src/Rest/InputParser/GreetingInput.php +++ /dev/null @@ -1,20 +0,0 @@ -getSupportedTypes($format), true) && + (array_key_exists('salutation', $data) || array_key_exists('recipient', $data)); + } + + public function getSupportedTypes(?string $format): array + { + return [ + Greeting::class => true, + ]; + } +} diff --git a/code_samples/api/rest_api/src/Rest/Serializer/GreetingNormalizer.php b/code_samples/api/rest_api/src/Rest/Serializer/GreetingNormalizer.php new file mode 100644 index 0000000000..03d0eb9853 --- /dev/null +++ b/code_samples/api/rest_api/src/Rest/Serializer/GreetingNormalizer.php @@ -0,0 +1,40 @@ + $object->salutation, + 'Recipient' => $object->recipient, + 'Sentence' => "{$object->salutation} {$object->recipient}", + ]; + if ('json' === $format) { + $data = ['Greeting' => $data]; + } + + return $this->normalizer->normalize($data, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return [ + Greeting::class => true, + ]; + } +} 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..48a8e625b6 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,76 +51,65 @@ 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). +The [`controller.service_arguments` tag]([[= symfony_doc =]]/controller/service.html#using-the-controller-service-arguments-service-tag) declares the controller as a service receiving injections. +It helps the autowiring of the serializer in the constructor. + +The `ibexa.api_platform.resource` tag declares the service as an API Platform resource. ### 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 normalizer) or a `Response` (to customize it further) +- extend `Ibexa\Rest\Server\Controller` to inherit useful methods and properties like `InputDispatcher` or `RequestParser` ``` php -[[= include_file('code_samples/api/rest_api/src/Rest/Controller/DefaultController.php') =]] -``` - -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: - -```php -return new CachedValue( - new MyValue($args…), - ['locationId'=> $locationId] -); -``` - -## Value and ValueObjectVisitor +[[= 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) =]] +``` + +
+ HTTP Cache + + 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 or Fastly) for future calls. + + CachedValue is used as following: + + ``` php + return new CachedValue( + new MyValue($args…), + ['locationId'=> $locationId] + ); + ``` +
+ +## Value and Normalizer ``` 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. | - -``` php -[[= include_file('code_samples/api/rest_api/src/Rest/ValueObjectVisitor/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,6 +160,17 @@ Content-Type: application/vnd.ibexa.api.greeting+json } ``` +## Describe resource in OpenAPI schema + +Thanks to API Platform, you can document the OpenAPI resource directly from its controller through annotations. +The resource is added to the OpenAPI Description dumped with `ibexa:openapi` command. +In `dev` mode, the resource appears in the live documentation at `/api/ibexa/v2/doc#/App/api_greet_get`. + +``` php hl_lines="5 6 16 100" +[[= include_file('code_samples/api/rest_api/src/Rest/Controller/DefaultController.php', 0, 247) =]] +//… +``` + ## 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: @@ -192,7 +190,7 @@ The parameter values can be a real value or a placeholder. For example, `'router.generate("ibexa.rest.load_location", {locationPath: "1/2"})'` results in `/api/ibexa/v2/content/locations/1/2` while `'router.generate("ibexa.rest.load_location", {locationPath: "{locationPath}"})'` gives `/api/ibexa/v2/content/locations/{locationPath}`. This syntax is based on Symfony's [expression language]([[= symfony_doc =]]/components/expression_language/index.html), an extensible component that allows limited/readable scripting to be used outside the code context. -In this example, `app.rest.greeting` is available in every SiteAccess (`default`): +In the following example, `app.rest.greeting` is available in every SiteAccess (`default`): ```yaml ibexa_rest: @@ -204,9 +202,11 @@ ibexa_rest: href: 'router.generate("app.rest.greeting")' ``` -You can place this configuration in any regular config file, like the existing `config/packages/ibexa.yaml`, or a new `config/packages/ibexa_rest.yaml` file. +You can place this configuration in any regular config file, +like the existing `config/packages/ibexa.yaml`, +or a new `config/packages/ibexa_rest.yaml` file. -The above example adds the following entry to the root XML output: +This example adds the following entry to the root XML output: ```xml diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c19c052141..fd5c2af693 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: '#^Parameter \#1 \.\.\.\$arrays of function array_merge expects array, iterable\ given\.$#' identifier: argument.type