.. _web_response:
WebResponse concept
===================
A REST-ful API will expose collection and item entry-points for each resource. But in both case, you need to know your
resource type or your resource identifier **before** executing your API call.
Roadiz introduces a special resource named **WebResponse** which can be called using a ``path`` query param in order
to reduce as much as possible API calls and address `N+1 problem `_.
.. code-block:: http
GET /api/web_response_by_path?path=/contact
API will expose a WebResponse single item containing:
* An item
* Item breadcrumbs
* Head object
* Item blocks tree-walker
* Item realms
* and if blocks are hidden by Realm configuration
.. note::
Roadiz *WebResponse* is used in `Rezo Zero Nuxt Starter `_
to populate all data during the ``asyncData()`` routine in ``_.vue`` page
.. code-block:: json
{
"@context": "/api/contexts/WebResponse",
"@id": "/api/web_response_by_path?path=/contact",
"@type": "WebResponse",
"item": {
"@id": "/api/pages/7",
"@type": "Page",
"content": "Magni deleniti ut eveniet. Aliquam aut et excepturi vitae placeat molestiae. Molestiae asperiores nihil sed temporibus quibusdam. Non magnam fuga at. sdf",
"subTitle": null,
"overTitle": null,
"headerImage": [],
"test": null,
"pictures": [],
"nodeReferences": [],
"stickytest": false,
"sticky": false,
"customForm": [],
"title": "Contact",
"publishedAt": "2021-09-10T15:56:00+02:00",
"metaTitle": "",
"metaKeywords": "",
"metaDescription": "",
"users": [],
"node": {
"@type": "Node",
"@id": "/api/nodes/7",
"visible": true,
"position": 3,
"tags": []
},
"slug": "contact",
"url": "/contact"
},
"breadcrumbs": {
"@type": "Breadcrumbs",
"@id": "_:14750",
"items": []
},
"head": {
"@type": "NodesSourcesHead",
"@id": "_:14679",
"googleAnalytics": null,
"googleTagManager": null,
"matomoUrl": null,
"matomoSiteId": null,
"siteName": "Roadiz dev website",
"metaTitle": "Contact – Roadiz dev website",
"metaDescription": "Contact, Roadiz dev website",
"policyUrl": null,
"mainColor": null,
"facebookUrl": null,
"instagramUrl": null,
"twitterUrl": null,
"youtubeUrl": null,
"linkedinUrl": null,
"homePageUrl": "/",
"shareImage": null
},
"blocks": [],
"realms": [],
"hidingBlocks": false
}
Configure WebResponse endpoints
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
WebResponse endpoints are contextualized using their ``item`` type. For example, you can change any normalization context options according to your node-type. To achieve this, Roadiz call a dedicated controller for ``/web_response_by_path`` endpoint (``RZ\Roadiz\CoreBundle\Api\Controller\GetWebResponseByPathController``) and will look for a ``********_get_by_path`` operation name in your app to override ApiPlatform ``_api_operation`` and ``_api_operation_name`` request parameters.
If you manage your node-types from your back-office, new node-types web-response endpoints will be appended automatically to the ``config/api_resources/web_response.yaml`` folder. Only reachable node-types will be exposed.
Example of a ``WebResponse`` resource configuration in your ``config/api_resources/web_response.yaml`` configuration file containing two operations for ``blogpost`` and ``page`` node-types:
.. code-block:: yaml
resources:
RZ\Roadiz\CoreBundle\Api\Model\WebResponse:
operations:
blogpost_get_by_path:
method: GET
class: ApiPlatform\Metadata\Get
uriTemplate: /web_response_by_path
read: false
controller: RZ\Roadiz\CoreBundle\Api\Controller\GetWebResponseByPathController
normalizationContext:
pagination_enabled: false
enable_max_depth: true
groups:
- nodes_sources
- node_listing
- urls
- tag_base
- tag_parent
- translation_base
- document_display
- document_thumbnails
- document_display_sources
- nodes_sources_lien
- web_response
- walker
- children
openapiContext:
tags:
- WebResponse
summary: 'Get a resource by its path wrapped in a WebResponse object'
description: 'Get a resource by its path wrapped in a WebResponse'
parameters:
- { type: string, name: path, in: query, required: true, description: 'Resource path, or `/` for home page', schema: { type: string } }
page_get_by_path:
method: GET
class: ApiPlatform\Metadata\Get
uriTemplate: /web_response_by_path
read: false
controller: RZ\Roadiz\CoreBundle\Api\Controller\GetWebResponseByPathController
normalizationContext:
pagination_enabled: false
enable_max_depth: true
groups:
- nodes_sources
- node_listing
- urls
- tag_base
- tag_parent
- translation_base
- document_display
- document_thumbnails
- document_display_sources
- nodes_sources_mise_en_forme
- nodes_sources_lien
- web_response
- walker
- children
openapiContext:
tags:
- WebResponse
summary: 'Get a resource by its path wrapped in a WebResponse object'
description: 'Get a resource by its path wrapped in a WebResponse'
parameters:
- { type: string, name: path, in: query, required: true, description: 'Resource path, or `/` for home page', schema: { type: string } }
Override WebResponse block walker
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Imagine you have a block (*ArticleFeedBlock*) which should list latest news (*Article*). You can use tree-walker mechanism to fetch latest news and
expose them as if they were children of your article feed block. This requires to create a custom definition:
.. code-block:: php
context instanceof NodeSourceWalkerContext) {
$this->context->getStopwatch()->start(self::class);
if (!$source instanceof NSArticleFeedBlock) {
throw new \InvalidArgumentException('Source must be instance of ' . NSArticleFeedBlock::class);
}
$criteria = [
'node.visible' => true,
'publishedAt' => ['<=', new \DateTime()],
'translation' => $source->getTranslation(),
'node.nodeTypeName' => 'Article'
];
// Prevent Article feed to list root Article again
$root = $walker->getRoot()->getItem();
if ($root instanceof NSArticle) {
$criteria['id'] = ['!=', $root->getId()];
}
if (null !== $source->getNode() && \count($source->getNode()->getTags()) > 0) {
$criteria['tags'] = $source->getNode()->getTags();
$criteria['tagExclusive'] = true;
}
$count = (int) ($source->getListingCount() ?? 4);
$children = $this->context->getNodeSourceApi()->getBy($criteria, [
'publishedAt' => 'DESC'
], $count);
if ($children instanceof Paginator) {
$iterator = $children->getIterator();
if ($iterator instanceof \ArrayIterator) {
$children = $iterator->getArrayCopy();
} else {
throw new \RuntimeException('Unexpected iterator type');
}
}
$this->context->getStopwatch()->stop(self::class);
return $children;
}
throw new \InvalidArgumentException('Context should be instance of ' . NodeSourceWalkerContext::class);
}
}
Then create a definition factory which will be injected using Symfony autoconfigure tag ``roadiz_core.tree_walker_definition_factory``.
``roadiz_core.tree_walker_definition_factory`` tag must include a ``classname`` attribute which will be used to match your definition factory with the right node source class.
.. code-block:: php
NSArticleFeedBlock::class]
)]
final class ArticleFeedBlockDefinitionFactory implements DefinitionFactoryInterface
{
public function create(WalkerContextInterface $context, bool $onlyVisible = true): callable
{
return new ArticleFeedBlockDefinition($context);
}
}
This way, all tree-walkers will be able to use your custom definition anytime a ``NSArticleFeedBlock`` is encountered.
You can debug all registered definition factories using ``bin/console debug:container --tag=roadiz_core.tree_walker_definition_factory`` command.
Retrieve common content
-----------------------
Now that we can fetch each page data, we need to get all unique content for building Menus, Homepage reference, headers, footers, etc.
We could extend our _WebResponse_ to inject theses common data to each request, but it would bloat HTTP responses, and
affect API performances.
For these common content, you can create a ``/api/common_content`` API endpoint in your project which will fetched only once in your
frontend application.
.. code-block:: yaml
resources:
# config/api_resources/common_content.yml
App\Api\Model\CommonContent:
operations:
getCommonContent:
class: ApiPlatform\Metadata\Get
method: 'GET'
uriTemplate: '/common_content'
read: false
controller: App\Controller\GetCommonContentController
pagination_enabled: false
normalizationContext:
enable_max_depth: true
pagination_enabled: false
groups:
- get
- common_content
- web_response
- walker
- walker_level
- children
- children_count
- nodes_sources_base
- nodes_sources_default
- urls
- blocks_urls
- tag_base
- translation_base
- document_display
- document_folders
.. note::
Keep in mind that ``/api/common_content`` endpoint uses ``nodes_sources_base`` normalization group which **will
only include essential node sources data**. You can add more groups to include more data, such as ``nodes_sources_default``
or ``nodes_sources_cta`` if you grouped some fields into a *CTA* label.
Then create you own custom resource to hold your menus tree-walkers and common content. Tree-walkers will be created using
``RZ\Roadiz\CoreBundle\Api\TreeWalker\TreeWalkerGenerator`` service.
TreeWalkerGenerator will create a ``App\TreeWalker\MenuNodeSourceWalker`` instance for each node source of type ``Menu`` located
on your website root.
.. code-block:: php
requestStack = $requestStack;
$this->managerRegistry = $managerRegistry;
$this->nodesSourcesHeadFactory = $nodesSourcesHeadFactory;
$this->previewResolver = $previewResolver;
$this->treeWalkerGenerator = $treeWalkerGenerator;
}
public function __invoke(): ?CommonContent
{
try {
$request = $this->requestStack->getMainRequest();
$translation = $this->getTranslationFromRequest($request);
$resource = new CommonContent();
$request?->attributes->set('data', $resource);
$resource->head = $this->nodesSourcesHeadFactory->createForTranslation($translation);
$resource->home = $resource->head->getHomePage();
$resource->menus = $this->treeWalkerGenerator->getTreeWalkersForTypeAtRoot(
'Menu',
MenuNodeSourceWalker::class,
$translation,
3
);
return $resource;
} catch (ResourceNotFoundException $exception) {
throw new NotFoundHttpException($exception->getMessage(), $exception);
}
}
protected function getTranslationFromRequest(?Request $request): TranslationInterface
{
$locale = null;
if (null !== $request) {
$locale = $request->query->get('_locale');
/*
* If no _locale query param is defined check Accept-Language header
*/
if (null === $locale) {
$locale = $request->getPreferredLanguage($this->getTranslationRepository()->getAllLocales());
}
}
/*
* Then fallback to default CMS locale
*/
if (null === $locale) {
$translation = $this->getTranslationRepository()->findDefault();
} elseif ($this->previewResolver->isPreview()) {
$translation = $this->getTranslationRepository()
->findOneByLocaleOrOverrideLocale((string) $locale);
} else {
$translation = $this->getTranslationRepository()
->findOneAvailableByLocaleOrOverrideLocale((string) $locale);
}
if (null === $translation) {
throw new NotFoundHttpException('No translation for locale ' . $locale);
}
return $translation;
}
protected function getTranslationRepository(): TranslationRepository
{
$repository = $this->managerRegistry->getRepository(TranslationInterface::class);
if (!$repository instanceof TranslationRepository) {
throw new \RuntimeException(
'Translation repository must be instance of ' .
TranslationRepository::class
);
}
return $repository;
}
}
Then, the following resource will be exposed:
.. code-block:: json
{
"@context": "/api/contexts/CommonContent",
"@id": "/api/common_content",
"@type": "CommonContent",
"home": {
"@id": "/api/pages/11",
"@type": "Page",
"content": null,
"image": [],
"title": "Accueil",
"publishedAt": "2022-04-12T16:24:00+02:00",
"node": {
"@type": "Node",
"@id": "/api/nodes/10",
"visible": true,
"tags": []
},
"slug": "accueil",
"url": "/fr"
},
"menus": {
"mainMenuWalker": {
"@type": "MenuNodeSourceWalker",
"@id": "_:3341",
"children": [],
"childrenCount": 0,
"item": {
"@id": "/api/menus/2",
"@type": "Menu",
"title": "Menu principal",
"publishedAt": "2022-04-12T00:39:00+02:00",
"node": {
"@type": "Node",
"@id": "/api/nodes/1",
"visible": false,
"tags": []
},
"slug": "main-menu"
},
"level": 0,
"maxLevel": 3
},
"footerMenuWalker": {
"@type": "MenuNodeSourceWalker",
"@id": "_:2381",
"children": [],
"childrenCount": 0,
"item": {
"@id": "/api/menus/3",
"@type": "Menu",
"linkInternalReference": [],
"title": "Menu du pied de page",
"publishedAt": "2022-04-12T11:18:12+02:00",
"node": {
"@type": "Node",
"@id": "/api/nodes/2",
"visible": false,
"tags": []
},
"slug": "footer-menu"
},
"level": 0,
"maxLevel": 3
},
"footerWalker": {
"@type": "AutoChildrenNodeSourceWalker",
"@id": "_:2377",
"children": [],
"childrenCount": 0,
"item": {
"@id": "/api/footers/16",
"@type": "Footer",
"content": "",
"title": "Pied de page",
"publishedAt": "2022-04-12T19:02:47+02:00",
"node": {
"@type": "Node",
"@id": "/api/nodes/15",
"visible": false,
"tags": []
},
"slug": "footer"
},
"level": 0,
"maxLevel": 3
}
},
"head": {
"@type": "NodesSourcesHead",
"@id": "_:14679",
"googleAnalytics": null,
"googleTagManager": null,
"matomoUrl": null,
"matomoSiteId": null,
"siteName": "Roadiz dev website",
"metaTitle": "Contact – Roadiz dev website",
"metaDescription": "Contact, Roadiz dev website",
"policyUrl": null,
"mainColor": null,
"facebookUrl": null,
"instagramUrl": null,
"twitterUrl": null,
"youtubeUrl": null,
"linkedinUrl": null,
"homePageUrl": "/",
"shareImage": null
}
}
Decorate WebResponse with custom properties
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
You can decorate WebResponse to add custom properties. This will require transformation using a custom transformer and your own ``App\Api\Model\WebResponse`` model object.
Your _transformer_ must implement ``RZ\Roadiz\CoreBundle\Api\DataTransformer\WebResponseDataTransformerInterface``.
First, override _WebResponse_ class and declare it in Roadiz Core configuration:
.. code-block:: php
dataTransformer->transform($object, $to, $context, $this->createWebResponse());
if ($output instanceof WebResponse) {
// Inject your custom properties data here
$output->fooBar = 'Test';
}
return $output;
}
}
And declare your new transformer in your services configuration:
.. code-block:: yaml
# config/services.yaml
services:
App\Api\DataTransformer\WebResponseDataTransformer:
decorates: RZ\Roadiz\CoreBundle\Api\DataTransformer\WebResponseDataTransformerInterface
arguments:
- '@App\Api\DataTransformer\WebResponseDataTransformer.inner'