Exposes Roadiz content as a public REST API. Mainly used in Roadiz Headless edition.
OAuth2 classes and logic are highly based on trikoder/oauth2-bundle which implemented thephpleague/oauth2-server to Symfony ecosystem.
- Configuration
- Create a new application
- Generic Roadiz API
- API Route listing
- OAuth2 entry points
- User detail entry point
- Listing nodes-sources
- Search nodes-sources
- Listing tags per node-types
- Listing archives per node-types
- Getting node-source details
- Getting node-source details directly from its path
- Listing node-source children
- Serialization context
- Breadcrumbs
- Errors
- Using Etags
This middleware theme uses symfony/dotenv to import .env variables to your project.
Be sure to create one with at least this configuration:
JWT_PASSPHRASE=changeme
# vendor/bin/generate-defuse-key
DEFUSE_KEY=changemeYour Roadiz entry points must initialize DotEnv object to fetch this configuration from a .env file
our from your system environment (i.e. your Docker container environment).
- Add API base services to your project
app/AppKernel.php:
# AppKernel.php
/**
* {@inheritdoc}
*/
public function register(\Pimple\Container $container)
{
parent::register($container);
/*
* Add your own service providers.
*/
$container->register(new \Themes\AbstractApiTheme\Services\AbstractApiServiceProvider());
}or in your config.yml:
additionalServiceProviders:
- \Themes\AbstractApiTheme\Services\AbstractApiServiceProvider- You do not need to register this abstract theme to enable its routes or translations
- Create a new theme with your API logic by extending
AbstractApiThemeApp - or use
AbstractApiThemeTraitin your custom theme app if you already inherits from an other middleware theme, - and add the API authentication scheme to Roadiz’ firewall-map…
- API-key scheme is meant to control your public API usage using a Referer regex and non-expiring api-key. This is a very light protection that will only work from a browser and should only be used with public data.
- OAuth2 scheme will secure your API behind Authentication and Authorization middlewares with a short-living access-token.
<?php
declare(strict_types=1);
namespace Themes\MyApiTheme;
use Symfony\Component\HttpFoundation\RequestMatcher;
use Pimple\Container;
use Themes\AbstractApiTheme\AbstractApiThemeTrait;
class MyApiThemeApp extends FrontendController
{
use AbstractApiThemeTrait;
protected static $themeName = 'My API theme';
protected static $themeAuthor = 'REZO ZERO';
protected static $themeCopyright = 'REZO ZERO';
protected static $themeDir = 'MyApiTheme';
protected static $backendTheme = false;
public static $priority = 10;
/**
* @inheritDoc
*/
public static function addDefaultFirewallEntry(Container $container)
{
/*
* API MUST be the first request matcher
*/
$requestMatcher = new RequestMatcher(
'^'.preg_quote($container['api.prefix']).'/'.preg_quote($container['api.version'])
);
$container['accessMap']->add(
$requestMatcher,
[$container['api.base_role']]
);
/*
* Add default API firewall entry.
*/
$container['firewallMap']->add(
$requestMatcher, // launch firewall rules for any request within /api/1.0 path
[$container['api.firewall_listener']],
$container['api.exception_listener'] // do not forget to add exception listener to enforce accessMap rules
);
/*
* OR add OAuth2 API firewall entry.
*/
// $container['firewallMap']->add(
// $requestMatcher, // launch firewall rules for any request within /api/1.0 path
// [$container['api.oauth2_firewall_listener']],
// $container['api.exception_listener'] // do not forget to add exception listener to enforce accessMap rules
// );
// Do not forget to register default frontend entries
// AFTER API not to lose preview feature
parent::addDefaultFirewallEntry($container);
}
}- Create new roles
ROLE_ADMIN_APIandROLE_APIto enable API access and administration section - Update your database schema to add
Applicationstable.
bin/roadiz orm:schema-tool:update --dump-sql --forceIf you opted for OAuth2 applications, you must enable grant-type(s) for the Authorization server before
going further: just extend the AuthorizationServer::class Roadiz service as below.
AbstractApiTheme currently supports:
client_credentialsgrantauthorization_codegrant (without refresh token)
/*
* Enable grant types
*/
$container->extend(AuthorizationServer::class, function (AuthorizationServer $server, Container $c) {
// Enable the client credentials grant on the server
$server->enableGrantType(
new \League\OAuth2\Server\Grant\ClientCredentialsGrant(),
new \DateInterval('PT1H') // access tokens will expire after 1 hour
);
// Enable the authorization grant on the server
$authCodeGrant = new \League\OAuth2\Server\Grant\AuthCodeGrant(
$c[AuthCodeRepositoryInterface::class],
$c[RefreshTokenRepositoryInterface::class],
new \DateInterval('PT10M') // authorization_codes will expire after 10 min
);
$server->enableGrantType(
$authCodeGrant,
new \DateInterval('PT3H') // access tokens will expire after 3 hours
);
return $server;
});CORS handling is highly based on nelmio/NelmioCorsBundle, options
are just handled as a service you can extend for your website.
This will automatically intercept requests containing an Origin header. Pre-flight requests must be performed
using OPTIONS verb and must contain Origin and Access-Control-Request-Method headers.
/**
* @return array
*/
$container['api.cors_options'] = [
'allow_credentials' => true,
'allow_origin' => ['*'],
'allow_headers' => true,
'origin_regex' => false,
'allow_methods' => ['GET'],
'expose_headers' => ['link', 'etag'],
'max_age' => 60*60*24
];Serialization context can gather every nodes ID, documents ID and tags ID they find during requests, as known as cache tags.
// In your application/theme service provider
$container['api.use_cache_tags'] = true;Cache tags will be appended to response X-Cache-Tags header and will allow you to clear your reverse-proxy caches
more selectively. Here are cache tags syntax:
n{node.id}(i.e:n98) for a nodet{tag.id}(i.e:t32) for a tagd{document.id}(i.e:d291) for a document
Cache-tags syntax is the shortest possible to avoid hitting maximum header size limit in your Nginx configuration.
Applications hold your API keys and control incoming requests Referer against a regex pattern.
previewscope will be converted toROLE_BACKEND_USERwhich is the required role name to access unpublished nodes.
/api/1.0entry point will list all available routes
GET /authorizefor authorization code grant flow (part one)GET /tokenfor authorization code grant flow (part two) andclient_credentialgrant flow (only part)
For authorization code grant you will find more detail on ThePHPLeague OAuth2 Server documentation
Authorization code grant flow will redirect non-authenticated users to GET /oauth2-login with the classic
Roadiz login form. You can call GET /authorize/logout to force user logout.
Note that authorization code grant won't give each application' roles if logged-in user does not have them before
(except for ROLE_SUPERADMIN). User will be asked to grant permission on application role but
he won't benefit from them for security reasons (permissions escalation). Make sure your users have the right
roles before inviting them to use your OAuth2 application.
/api/1.0/meentry point will display details about your Application / User
/api/1.0/nodes-sources: list all nodes-sources no matter type they are./api/1.0/{node-type-name}: list nodes-sources by type
If you created a Event node-type, API content will be available at /api/1.0/event endpoint.
Serialization context will automatically add @id, @type, slug and url fields in your API resource:
{
"hydra:member": [
{
"slug": "home",
"@type": "Page",
"node": {
"nodeName": "accueil",
"tags": []
},
"title": "Accueil",
"publishedAt": "2021-01-18T23:32:39+01:00",
"@id": "http://example.test/dev.php/api/1.0/page/2/fr",
"url": "/dev.php/home"
}
],
"hydra:totalItems": 1,
"@id": "/api/1.0/page",
"@type": "hydra:Collection",
"hydra:view": {
"@id": "/api/1.0/page",
"@type": "hydra:PartialCollectionView"
}
}Note: In listing context, only node-type-fields from default group will be exposed. If you want to prevent some node-type fields to be serialized during listing you can give them a Group name. This can be helpful for avoiding document or node reference fields to bloat your JSON responses.
- itemsPerPage:
int - page:
int - _locale:
stringIf _locale is not set, Roadiz will negotiate with existingAccept-Languageheader - search:
string - order:
arrayExampleorder[publishedAt]: DESCwith values:ASCDESC
- properties:
arrayFilters serialized properties by their names - archive:
stringExamplearchive: 2019-02orarchive: 2019. This parameter only works onpublishedAtfield
On NodesSources content:
- path:
stringFilters nodes-sources against a valid path (based on node' name or alias), example:/home. Path does require_localefilter to fetch right translation. Path filter can resolve any Redirection too if it is linked to a valid node-source. - id:
idNodes-sources ID - title:
string - not:
array<int|string>|int|string, filters out one or many nodes using their numeric ID, node-name or @id - publishedAt:
DateTimeorarraywith :afterbeforestrictly_afterstrictly_before
- tags:
array<string>filter by tags (cannot be used withsearch) - tagExclusive:
boolfilter by tags with AND logic (cannot be used withsearch) - node.parent:
int|stringnumeric ID, node-name or @id - node.aNodes.nodeA:
int|string(numeric ID, node-name or @id) Filter by a node reference (finds nodes which are referenced) - node.bNodes.nodeB:
int|string(numeric ID, node-name or @id) Filter by a node reference (finds node which owns reference) - node.aNodes.field.name:
stringFilter node references by a node-type field name (optional, if not set,node.aNodes.nodeAfilter will apply on any node reference) - node.bNodes.field.name:
stringFilter node references by a node-type field name (optional, if not set,node.bNodes.nodeBfilter will apply on any node reference) - node.visible:
bool - node.home:
bool - node.nodeType:
array|stringFilter nodes-sources by their type - node.nodeType.reachable:
bool
Plus any date, datetime and boolean node-type fields which are indexed.
_locale filter set Roadiz main translation for all database lookups, make sure to always set it to
the right locale, or you won't get any result with search or path filters against French queries.
path filter uses Roadiz internal router to search only one result to match against your query. You can use:
- node-source canonical path, i.e:
/about-us - node-source nodeName path: i.e:
/en/about-us - a redirected path, i.e:
/old-about-us
If you get one result, you'll find canonical path in hydra:member > 0 > url field to create a redirection in
your frontend framework and advertise node-source new URL.
Using path filter with / value only, you can send Accept-Language header to the API to let it decide with
translation is best for your consumer. If a valid data is found, API will respond with Content-Language header
contain accepted locale.
To enable this behaviour, you must enable force_locale Roadiz setting to make sure each home page path
displays its locale and to avoid infinite redirection loops.
/api/1.0/nodes-sources/search: Search all nodes-sources against asearchparam using Apache Solr engine
If your search parameter is longer than 3 characters, each API result item will be composed with:
{
"nodeSource": {
...
},
"highlighting": {
"collection_txt": [
"In aliquam at dignissimos quasi in. Velit et vero non ut quidem. Sunt est <span class=\"solr-highlight\">tempora</span> sed. Rem nam asperiores modi in quidem quia voluptatum. Aliquid ut doloribus sit et ea eum natus. Eius commodi porro"
]
}
}- itemsPerPage:
int - page:
int - _locale:
stringIf _locale is not set, Roadiz will negotiate with existingAccept-Languageheader - search:
string - tags:
array<string> - node.parent:
intorstring(node-name) - node.visible:
bool - node.nodeType:
array|stringFilter nodes-sources search by their type - properties:
arrayFilters serialized properties by their names
/api/1.0/{node-type-name}/tags: Fetch all tags used in nodes-sources from a given type.
If you created a Event node-type, you may want to list any Tags attached to events, API will be available at
/api/1.0/event/tags endpoint. Be careful, this endpoint will display all tags, visible or not, unless you filter them.
- itemsPerPage:
int - page:
int - _locale:
stringIf _locale is not set, Roadiz will negotiate with existingAccept-Languageheader - search:
string: This will search ontagNameand translationname - order:
arrayExampleorder[position]: ASCwith values:ASCDESC
- node.parent:
intorstring(node-name) - node.tags.tagName:
intorstring, orarray(tag-name) - parent:
intorstring(tag-name) - properties:
arrayFilters serialized properties by their names
On Tag content:
- tagName:
string - parent:
intorstring(tag-name) - visible:
bool
/api/1.0/{node-type-name}/archives: Fetch all publication months used in nodes-sources from a given type.
If you created a Event node-type, you may want to list any archives from events, API will be available at
/api/1.0/event/archives endpoint. Here is a response example which list all archives grouped by year:
{
"hydra:member": {
"2021": {
"2021-01": "2021-01-01T00:00:00+01:00"
},
"2020": {
"2020-12": "2020-12-01T00:00:00+01:00",
"2020-10": "2020-10-01T00:00:00+02:00",
"2020-07": "2020-07-01T00:00:00+02:00"
}
},
"@id": "/api/1.0/event/archives",
"@type": "hydra:Collection",
"hydra:view": {
"@id": "/api/1.0/event/archives",
"@type": "hydra:PartialCollectionView"
}
}- _locale:
stringIf _locale is not set, Roadiz will negotiate with existingAccept-Languageheader - tags:
array<string> - tagExclusive:
bool - node.parent:
intorstring(node-name)
/api/1.0/{node-type-name}/{id}/{_locale}: fetch a node-source with its node' ID and translationlocale. This is the default route used to generate your content JSON-LD@idfield./api/1.0/{node-type-name}/{id}: fetch a node-source with its node' ID and system default locale (or query string one)/api/1.0/{node-type-name}/by-slug/{slug}: fetch a node-source with its slug (nodeNameorurlAlias)
For each node-source, API will expose detailed content on /api/1.0/event/{id} and /api/1.0/event/by-slug/{slug} endpoints.
/api/1.0/nodes-sources/by-path/?path={path}: fetch one node-source details against itspath(including homepages root paths)
- properties:
arrayFilters serialized properties by their names
Any node-source detail response will have a Link header carrying URLs for all alternate translations.
For example a legal page which is translated in English and French will have this Link header data:
<https://api.mysite.test/api/1.0/page/23/en>; rel="alternate"; hreflang="en"; type="application/json",
<https://api.mysite.test/api/1.0/page/23/fr>; rel="alternate"; hreflang="fr"; type="application/json",
</mentions-legales>; rel="alternate"; hreflang="fr"; type="text/html",
</legal>; rel="alternate"; hreflang="en"; type="text/html"
text/html resources URL will always be absolute paths instead of absolute URL in order to generate your own URL in your front-end framework without carrying API scheme.
For safety reasons, we do not embed node-sources children automatically. We invite you to use TreeWalker library to extend your JSON serialization to build a safe graph for each of your node-types. Create a JMS\Serializer\EventDispatcher\EventSubscriberInterface subscriber to extend
serializer.post_serialize event with StaticPropertyMetadata.
# Any JMS\Serializer\EventDispatcher\EventSubscriberInterface implementation…
$exclusionStrategy = $context->getExclusionStrategy() ??
new \JMS\Serializer\Exclusion\DisjunctExclusionStrategy();
/** @var array<string> $groups */
$groups = $context->hasAttribute('groups') ?
$context->getAttribute('groups') :
[];
$groups = array_unique(array_merge($groups, [
'walker',
'children'
]));
$propertyMetadata = new \JMS\Serializer\Metadata\StaticPropertyMetadata(
'Collection',
'children',
[],
$groups
);
# Check if virtual property children has been requested with properties[] filter…
if (!$exclusionStrategy->shouldSkipProperty($propertyMetadata, $context)) {
$blockWalker = BlockNodeSourceWalker::build(
$nodeSource,
$this->get(NodeSourceWalkerContext::class),
4, // max graph level
$this->get('nodesSourcesUrlCacheProvider')
);
$visitor->visitProperty(
$propertyMetadata,
$blockWalker->getChildren()
);
}For each request, serialization context holds many useful objects during serializer.post_serialize events:
request: Symfony current request objectnodeType: Initial node-source type (ornullif not applicable)cache-tags: Cache-tags collection which is filled up during serialization graphtranslation: Current request translationgroups: Serialization groups for current request- Serialization groups during a listing nodes-sources request:
nodes_sources_basedocument_displaythumbnailtag_basenodes_sources_defaulturlsmeta
- Serialization groups during a single node-source request:
-
walker: rezozero tree-walker -children: rezozero tree-walker -nodes_sources-nodes_sources_single: for displaying custom objects only on main entity -document_display-thumbnail-url_alias-tag_base-urls-meta-breadcrumbs: only allows breadcrumbs on detail requests
- Serialization groups during a listing nodes-sources request:
# Any JMS\Serializer\EventDispatcher\EventSubscriberInterface implementation…
public function onPostSerialize(\JMS\Serializer\EventDispatcher\ObjectEvent $event): void
{
$context = $event->getContext();
/** @var \Symfony\Component\HttpFoundation\Request $request */
$request = $context->hasAttribute('request') ? $context->getAttribute('request') : null;
/** @var \RZ\Roadiz\Contracts\NodeType\NodeTypeInterface|null $nodeType */
$nodeType = $context->hasAttribute('nodeType') ? $context->getAttribute('nodeType') : null;
/** @var \RZ\Roadiz\Core\AbstractEntities\TranslationInterface|null $translation */
$translation = $context->hasAttribute('translation') ? $context->getAttribute('translation') : null;
/** @var array<string> $groups */
$groups = $context->hasAttribute('groups') ? $context->getAttribute('groups') : [];
}If you want your API to provide breadcrumbs for each reachable nodes-sources, you can implement
Themes\AbstractApiTheme\Breadcrumbs\BreadcrumbsFactoryInterface and register it in your AppServiceProvider.
For each NodeTypeSingle API request (i.e. not in listing context), a breadcrumbs will be injected with all your node parents as defined in your BreadcrumbsFactoryInterface.
Here is a vanilla implementation which respects Roadiz node tree structure:
<?php
declare(strict_types=1);
namespace App\Breadcrumbs;
use RZ\Roadiz\Core\Entities\NodesSources;
use Themes\AbstractApiTheme\Breadcrumbs\BreadcrumbsFactoryInterface;
use Themes\AbstractApiTheme\Breadcrumbs\BreadcrumbsInterface;
use Themes\AbstractApiTheme\Breadcrumbs\Breadcrumbs;
final class BreadcrumbsFactory implements BreadcrumbsFactoryInterface
{
/**
* @param NodesSources|null $nodesSources
* @return BreadcrumbsInterface|null
*/
public function create(?NodesSources $nodesSources): ?BreadcrumbsInterface
{
if (null === $nodesSources ||
null === $nodesSources->getNode() ||
null === $nodesSources->getNode()->getNodeType() ||
!$nodesSources->getNode()->getNodeType()->isReachable()) {
return null;
}
$parents = [];
while (null !== $nodesSources = $nodesSources->getParent()) {
if (null !== $nodesSources->getNode() &&
$nodesSources->getNode()->isPublished() &&
$nodesSources->getNode()->isVisible()) {
$parents[] = $nodesSources;
}
}
return new Breadcrumbs(array_reverse($parents));
}
}# App\AppServiceProvider
$container[BreadcrumbsFactoryInterface::class] = function (Container $c) {
return new BreadcrumbsFactory();
};If you want to get detailed errors in JSON, do not forget to add the header: Accept: application/json to
every request you make. You'll get message such as:
{
"error": "general_error",
"error_message": "Search engine does not respond.",
"message": "Search engine does not respond.",
"exception": "Symfony\\Component\\HttpKernel\\Exception\\HttpException",
"humanMessage": "A problem occurred on our website. We are working on this to be back soon.",
"status": "danger"
}with the right status code (40x or 50x). Make sure to catch and read your response data from your frontend framework when your request fails to know more about errors.
Every NodeSources based response will contain a ETag header calculated on API response content checksum.
You can setup your API consumer to send a If-None-Match header containing the latest ETag found. API will return
an empty 304 Not Modified response if content has not changed, or the whole response if it changed with a new ETag header.