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.
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
{
"@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
}
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.
# config/api_resources/common_content.yml
App\Api\Model\CommonContent:
collectionOperations: {}
itemOperations:
getCommonContent:
method: 'GET'
path: '/common_content'
read: false
controller: App\Controller\GetCommonContentController
pagination_enabled: false
normalization_context:
pagination_enabled: false
groups:
- get
- common_content
- web_response
- walker
- walker_level
- children
- children_count
- nodes_sources_base
- nodes_sources_default
- urls
- tag_base
- translation_base
- document_display
Then create you own custom resource to hold your menus tree-walkers and common content:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Model\CommonContent;
use App\TreeWalker\MenuNodeSourceWalker;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Cache\CacheItemPoolInterface;
use RZ\Roadiz\CoreBundle\Api\Model\NodesSourcesHeadFactory;
use RZ\Roadiz\Core\AbstractEntities\TranslationInterface;
use RZ\Roadiz\CoreBundle\Api\TreeWalker\AutoChildrenNodeSourceWalker;
use RZ\Roadiz\CoreBundle\Bag\Settings;
use RZ\Roadiz\CoreBundle\EntityApi\NodeSourceApi;
use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface;
use RZ\Roadiz\CoreBundle\Repository\TranslationRepository;
use RZ\TreeWalker\WalkerContextInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
final class GetCommonContentController extends AbstractController
{
private RequestStack $requestStack;
private ManagerRegistry $managerRegistry;
private WalkerContextInterface $walkerContext;
private Settings $settingsBag;
private NodeSourceApi $nodeSourceApi;
private CacheItemPoolInterface $cacheItemPool;
private NodesSourcesHeadFactory $nodesSourcesHeadFactory;
private PreviewResolverInterface $previewResolver;
public function __construct(
RequestStack $requestStack,
ManagerRegistry $managerRegistry,
WalkerContextInterface $walkerContext,
Settings $settingsBag,
NodeSourceApi $nodeSourceApi,
NodesSourcesHeadFactory $nodesSourcesHeadFactory,
CacheItemPoolInterface $cacheItemPool,
PreviewResolverInterface $previewResolver
) {
$this->requestStack = $requestStack;
$this->walkerContext = $walkerContext;
$this->cacheItemPool = $cacheItemPool;
$this->nodeSourceApi = $nodeSourceApi;
$this->managerRegistry = $managerRegistry;
$this->nodesSourcesHeadFactory = $nodesSourcesHeadFactory;
$this->settingsBag = $settingsBag;
$this->previewResolver = $previewResolver;
}
public function __invoke(): ?CommonContent
{
try {
$request = $this->requestStack->getMainRequest();
$translation = $this->getTranslationFromRequest($request);
$home = $this->nodeSourceApi->getOneBy([
'node.home' => true,
'translation' => $translation
]);
$mainMenu = $this->nodeSourceApi->getOneBy([
'node.nodeName' => 'main-menu',
'translation' => $translation
]);
$footerMenu = $this->nodeSourceApi->getOneBy([
'node.nodeName' => 'footer-menu',
'translation' => $translation
]);
$errorPage = $this->nodeSourceApi->getOneBy([
'node.nodeName' => 'error-page',
'translation' => $translation
]);
$resource = new CommonContent();
if (null !== $home) {
$resource->home = $home;
}
if (null !== $mainMenu) {
$resource->mainMenuWalker = MenuNodeSourceWalker::build(
$mainMenu,
$this->walkerContext,
3,
$this->cacheItemPool
);
}
if (null !== $footerMenu) {
$resource->footerMenuWalker = MenuNodeSourceWalker::build(
$footerMenu,
$this->walkerContext,
3,
$this->cacheItemPool
);
}
if (null !== $footer) {
$resource->footerWalker = AutoChildrenNodeSourceWalker::build(
$footer,
$this->walkerContext,
3,
$this->cacheItemPool
);
}
if (null !== $errorPage) {
$resource->errorPageWalker = AutoChildrenNodeSourceWalker::build(
$errorPage,
$this->walkerContext,
3,
$this->cacheItemPool
);
}
if (null !== $request) {
$request->attributes->set('data', $resource);
}
$resource->head = $this->nodesSourcesHeadFactory->createForTranslation($translation);
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
{
return $this->managerRegistry->getRepository(TranslationInterface::class);
}
}
Then, the following resource will be exposed:
{
"@context": "/api/contexts/CommonContent",
"@id": "/api/common_content?id=unique",
"@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"
},
"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
},
"errorPageWalker": {
"@type": "AutoChildrenNodeSourceWalker",
"@id": "_:3465",
"children": [],
"childrenCount": 0,
"item": {
"@id": "/api/pages/153",
"@type": "Page",
"title": "Page d'erreur",
"publishedAt": "2022-05-12T17:16:40+02:00",
"node": {
"@type": "Node",
"@id": "/api/nodes/146",
"visible": false,
"tags": []
},
"slug": "error-page",
"url": "/fr/error-page"
},
"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
}
}