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=changeme
Your 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
AbstractApiThemeTrait
in 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_API
andROLE_API
to enable API access and administration section - Update your database schema to add
Applications
table.
bin/roadiz orm:schema-tool:update --dump-sql --force
If 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_credentials
grantauthorization_code
grant (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.
preview
scope will be converted toROLE_BACKEND_USER
which is the required role name to access unpublished nodes.
/api/1.0
entry point will list all available routes
GET /authorize
for authorization code grant flow (part one)GET /token
for authorization code grant flow (part two) andclient_credential
grant 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/me
entry 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": "https://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:
string
If _locale is not set, Roadiz will negotiate with existingAccept-Language
header - search:
string
- order:
array
Exampleorder[publishedAt]: DESC
with values:ASC
DESC
- properties:
array
Filters serialized properties by their names - archive:
string
Examplearchive: 2019-02
orarchive: 2019
. This parameter only works onpublishedAt
field
On NodesSources
content:
- path:
string
Filters nodes-sources against a valid path (based on node' name or alias), example:/home
. Path does require_locale
filter to fetch right translation. Path filter can resolve any Redirection too if it is linked to a valid node-source. - id:
id
Nodes-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:
DateTime
orarray
with :after
before
strictly_after
strictly_before
- tags:
array<string>
filter by tags (cannot be used withsearch
) - tagExclusive:
bool
filter by tags with AND logic (cannot be used withsearch
) - node.parent:
int|string
numeric 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:
string
Filter node references by a node-type field name (optional, if not set,node.aNodes.nodeA
filter will apply on any node reference) - node.bNodes.field.name:
string
Filter node references by a node-type field name (optional, if not set,node.bNodes.nodeB
filter will apply on any node reference) - node.visible:
bool
- node.home:
bool
- node.nodeType:
array|string
Filter 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 asearch
param 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:
string
If _locale is not set, Roadiz will negotiate with existingAccept-Language
header - search:
string
- tags:
array<string>
- node.parent:
int
orstring
(node-name) - node.visible:
bool
- node.nodeType:
array|string
Filter nodes-sources search by their type - properties:
array
Filters 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:
string
If _locale is not set, Roadiz will negotiate with existingAccept-Language
header - search:
string
: This will search ontagName
and translationname
- order:
array
Exampleorder[position]: ASC
with values:ASC
DESC
- node.parent:
int
orstring
(node-name) - node.tags.tagName:
int
orstring
, orarray
(tag-name) - parent:
int
orstring
(tag-name) - properties:
array
Filters serialized properties by their names
On Tag
content:
- tagName:
string
- parent:
int
orstring
(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:
string
If _locale is not set, Roadiz will negotiate with existingAccept-Language
header - tags:
array<string>
- tagExclusive:
bool
- node.parent:
int
orstring
(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@id
field./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 (nodeName
orurlAlias
)
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:
array
Filters 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 (ornull
if 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_base
document_display
thumbnail
tag_base
nodes_sources_default
urls
meta
- 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.