Skip to content

Creating new REST resource

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 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. It's available through GET and POST methods. GET sets default values while POST allows inputting custom values.

Route

New REST routes should use the REST URI prefix for consistency. To ensure that they do, in the config/routes.yaml file, while importing a REST routing file, use ibexa.rest.path_prefix parameter as a prefix.

1
2
3
app.rest:
    resource: routes_rest.yaml
    prefix: '%ibexa.rest.path_prefix%'

The config/routes_rest.yaml file imported above is created with the following configuration:

1
2
3
4
app.rest.greeting:
    path: '/greet'
    controller: App\Rest\Controller\DefaultController::greet
    methods: [GET]

CSRF protection

If a REST route is designed to be used with unsafe methods, the CSRF protection is enabled by default like for built-in routes. You can disable it by using the route parameter csrf_protection.

1
2
3
4
5
6
app.rest.greeting:
    path: '/greet'
    controller: App\Rest\Controller\DefaultController::greet
    methods: [GET,POST]
    defaults:
        csrf_protection: false

Controller

Controller service

You can use the following configuration to have all controllers from the App\Rest\Controller\ namespace (files in the src/Rest/Controller/ folder) to be set as REST controller services.

1
2
3
4
5
6
7
8
services:
    #…
    App\Rest\Controller\:
        resource: '../src/Rest/Controller/'
        parent: Ibexa\Rest\Server\Controller
        autowire: true
        autoconfigure: true
        tags: ['controller.service_arguments', 'ibexa.api_platform.resource']

The controller.service_arguments 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 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 repository or router
    • to be part of the OpenAPI Description
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php declare(strict_types=1);

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\Server\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface;

class DefaultController extends Controller
{
    public const DEFAULT_FORMAT = 'xml';

    public const AVAILABLE_FORMATS = ['json', 'xml'];

    public function __construct(private SerializerInterface $serializer)
    {
    }

    public function greet(Request $request): Response|Greeting
    {
        $contentType = $request->headers->get('Content-Type');
        if ($contentType) {
            preg_match('@.*[/+](?P<format>[^/+]+)@', $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();
        }

        $accept = $request->headers->get('Accept', 'application/' . self::DEFAULT_FORMAT);
        preg_match('@.*[/+](?P<format>[^/+]+)@', $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"]);
    }
}
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:
1
2
3
4
return new CachedValue(
    new MyValue($args…),
    ['locationId'=> $locationId]
);

Value and Normalizer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php declare(strict_types=1);

namespace App\Rest\Values;

class Greeting
{
    public string $salutation;

    public string $recipient;

    public function __construct(string $salutation = 'Hello', string $recipient = 'World')
    {
        $this->salutation = $salutation;
        $this->recipient = $recipient;
    }
}
1
2
3
4
5
6
services:
    #…

    App\Rest\Serializer\:
        resource: '../src/Rest/Serializer/'
        tags: ['ibexa.rest.serializer.normalizer']

A normalizer must implement the supportsNormalization and normalize methods.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php declare(strict_types=1);

namespace App\Rest\Serializer;

use App\Rest\Values\Greeting;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class GreetingNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Greeting;
    }

    /** @param \App\Rest\Values\Greeting $object */
    public function normalize(mixed $object, ?string $format = null, array $context = []): array|\ArrayObject|bool|float|int|null|string
    {
        $data = [
            'Salutation' => $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,
        ];
    }
}

Input denormalizer

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php declare(strict_types=1);

namespace App\Rest\Serializer;

use App\Rest\Values\Greeting;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

class GreetingInputDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
    use DenormalizerAwareTrait;

    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
    {
        if ('json' === $format) {
            $data = $data[array_key_first($data)];
        }
        $data = array_change_key_case($data);

        $salutation = $data['salutation'] ?? 'Hello';
        $recipient = $data['recipient'] ?? 'World';

        return new Greeting($salutation, $recipient);
    }

    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
    {
        if (!is_array($data)) {
            return false;
        }

        if ('json' === $format) {
            $data = $data[array_key_first($data)];
        }
        $data = array_change_key_case($data);

        return in_array($type, $this->getSupportedTypes($format), true) &&
            (array_key_exists('salutation', $data) || array_key_exists('recipient', $data));
    }

    public function getSupportedTypes(?string $format): array
    {
        return [
            Greeting::class => true,
        ];
    }
}

Testing the new resource

Now you can test both GET and POST methods, and both XML and JSON format for inputs and outputs.

1
2
3
4
5
6
7
8
curl https://api.example.com/api/ibexa/v2/greet --include;
curl https://api.example.com/api/ibexa/v2/greet --include --request POST \
    --header 'Content-Type: application/vnd.ibexa.api.GreetingInput+xml' \
    --data '<GreetingInput><Salutation>Good morning</Salutation></GreetingInput>';
curl https://api.example.com/api/ibexa/v2/greet --include --request POST \
    --header 'Content-Type: application/vnd.ibexa.api.GreetingInput+json' \
    --data '{"GreetingInput": {"Salutation": "Good day", "Recipient": "Earth"}}' \
    --header 'Accept: application/vnd.ibexa.api.Greeting+json';
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
HTTP/1.1 200 OK
Content-Type: application/vnd.ibexa.api.Greeting+xml

<?xml version="1.0" encoding="UTF-8"?>
<Greeting>
 <Salutation>Hello</Salutation>
 <Recipient>World</Recipient>
 <Sentence>Hello World</Sentence>
</Greeting>

HTTP/1.1 200 OK
Content-Type: application/vnd.ibexa.api.Greeting+xml

<?xml version="1.0"?>
<Greeting>
  <Salutation>Good morning</Salutation>
  <Recipient>World</Recipient>
  <Sentence>Good morning World</Sentence>
</Greeting>

HTTP/1.1 200 OK
Content-Type: application/vnd.ibexa.api.greeting+json

{
    "Greeting": {
        "Salutation": "Good day",
        "Recipient": "Earth",
        "Sentence": "Good day Earth"
    }
}

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 <dev-domain>/api/ibexa/v2/doc#/App/api_greet_get.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
<?php declare(strict_types=1);

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\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: [],
        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: [],
        requestBody: new Model\RequestBody(
            description: 'Set salutation or recipient.',
            required: false,
            content: new \ArrayObject([
                'application/vnd.ibexa.api.GreetingInput+xml' => [
                    'schema' => [
                        'type' => 'object',
                        'xml' => [
                            'name' => 'GreetingInput',
                            'wrapped' => false,
                        ],
                        'required' => [],
                        'properties' => [
                            'salutation' => [
                                'type' => 'string',
                            ],
                            'recipient' => [
                                'type' => 'string',
                            ],
                        ],
                    ],
                    'example' => [
                        'salutation' => 'Good morning',
                        'recipient' => 'Planet',
                    ],
                ],
                'application/vnd.ibexa.api.GreetingInput+json' => [
                    'schema' => [
                        'type' => 'object',
                        'properties' => [
                            'GreetingInput' => [
                                'type' => 'object',
                                'required' => [],
                                'properties' => [
                                    'salutation' => [
                                        'type' => 'string',
                                    ],
                                    'recipient' => [
                                        'type' => 'string',
                                    ],
                                ],
                            ],
                        ],
                    ],
                    '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
//…

The resource can be tested from the live documentation.

For example, the POST /greet at <dev-domain>/api/ibexa/v2/doc#/App/api_greet_post can be tested this way:

  • Click the Try it out button
  • In Request body section, choose the Content-Type from the drop-down menu. For example, the JSON format application/vnd.ibexa.api.GreetingInput+json
  • In the text area, edit the JSON to make your own test
  • In the Responses section, choose the desired response format for successful 200
  • Click the Execute button

Example of test from live doc

Eventually, remove properties to see if default values work, inject parse errors, or empty the whole text area to see error handling.

Registering resources in REST root

You can add the new resource to the root resource through a configuration with the following pattern:

1
2
3
4
5
6
7
ibexa_rest:
    system:
        <scope>:
            rest_root_resources:
                <resourceName>:
                    mediaType: <MediaType>
                    href: 'router.generate("<resource_route_name>", {routeParameter: value})'

The router.generate renders a URI based on the name of the route and its parameters. 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, an extensible component that allows limited/readable scripting to be used outside the code context.

In the following example, app.rest.greeting is available in every SiteAccess (default):

1
2
3
4
5
6
7
ibexa_rest:
    system:
        default:
            rest_root_resources:
                greeting:
                    mediaType: Greeting
                    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.

This example adds the following entry to the root XML output:

1
<greeting media-type="application/vnd.ibexa.api.Greeting+xml" href="/api/ibexa/v2/greet"/>