roadiz/abstract-api-theme

将 Roadiz 内容暴露为公共 REST API。

4.0.18 2023-09-07 14:30 UTC

README

将 Roadiz 内容暴露为公共 REST API。 主要用于 Roadiz Headless edition

Build Status

OAuth2 类和逻辑高度基于 trikoder/oauth2-bundle,该包实现了 thephpleague/oauth2-server 以集成到 Symfony 生态系统。

配置

使用 .env 文件

此中间件主题使用 symfony/dotenv.env 变量导入到您的项目中。请确保创建一个至少包含以下配置的文件

JWT_PASSPHRASE=changeme
# vendor/bin/generate-defuse-key
DEFUSE_KEY=changeme

您的 Roadiz 入口点必须初始化 DotEnv 对象,以从 .env 文件或系统环境(例如您的 Docker 容器环境)中获取此配置。

注册 API 主题

  • 将 API 基础服务添加到您的项目 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());
}

或您的 config.yml

additionalServiceProviders:
    - \Themes\AbstractApiTheme\Services\AbstractApiServiceProvider
  • 您不需要注册此抽象主题 来启用其路由或翻译
  • 通过扩展 AbstractApiThemeApp 创建一个新的主题,其中包含您的 API 逻辑
  • 在您继承自其他中间件主题的自定义主题应用中使用 AbstractApiThemeTrait
  • 并将 API 认证方案添加到 Roadiz 的 firewall-map...

选择简单的 API-Key 或完整的 OAuth2 认证方案

  • API-key 方案旨在通过 Referer 正则表达式和 非过期 api-key 来控制您的 公共 API 使用。这是一种非常轻的保护措施,仅适用于浏览器,并且仅应与公共数据一起使用。
  • OAuth2 方案将使用短暂的 访问令牌 通过身份验证和授权中间件来保护您的 API。
<?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);
    }
}
  • 创建新的角色 ROLE_ADMIN_APIROLE_API 以启用 API 访问和管理部分
  • 更新您的数据库模式以添加 Applications 表。
bin/roadiz orm:schema-tool:update --dump-sql --force

为您的网站启用授权类型

如果您选择了 OAuth2 应用程序,在继续之前必须为授权服务器启用授权类型:只需如下扩展 AuthorizationServer::class Roadiz 服务。

AbstractApiTheme 当前支持

  • client_credentials 授权
  • authorization_code 授权(不包含 刷新令牌)
/*
 * 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

CORS 处理高度基于 nelmio/NelmioCorsBundle,选项仅作为您可以扩展以用于您网站的服务来处理。
这将自动拦截包含 Origin 标头的请求。预检请求必须使用 OPTIONS 动词执行,并且必须包含 OriginAccess-Control-Request-Method 标头。

/**
 * @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
];

使用缓存标签

序列化上下文可以收集在请求期间找到的每个 nodes ID、documents ID 和 tags ID,称为 缓存标签

// In your application/theme service provider
$container['api.use_cache_tags'] = true;

缓存标签将被附加到响应 X-Cache-Tags 标头,并允许您更选择性地清除您的反向代理缓存。以下是缓存标签语法

  • n{node.id}(例如:n98)用于节点
  • t{tag.id}(例如:t32)用于标签
  • d{document.id}(例如:d291)用于文档

缓存标签语法是最短的,以避免在Nginx配置中达到最大头部大小限制。

创建新应用程序

应用程序保存您的API密钥,并使用正则表达式模式控制对Referer的传入请求。

机密应用程序:OAuth2

保留角色/作用域

  • preview作用域将转换为ROLE_BACKEND_USER,这是访问未发布节点的必需角色名称。

通用 Roadiz API

API 路由列表

  • /api/1.0入口点将列出所有可用路由

OAuth2 入口点

  • GET /authorize用于授权码授予流程(第一部分)
  • GET /token用于授权码授予流程(第二部分)和client_credential授予流程(仅部分)

有关授权码授予的更多详细信息,请参阅ThePHPLeague OAuth2 Server文档

授权码授予流程将非认证用户重定向到带有经典Roadiz登录表单的GET /oauth2-login。您可以调用GET /authorize/logout强制用户注销。请注意,授权码授予不会给予每个应用程序角色(除非登录用户之前已经有了它们)(除ROLE_SUPERADMIN外)。用户将被要求授权应用程序角色的权限,但出于安全原因(权限升级),他将不会从中受益。确保在邀请用户使用您的OAuth2应用程序之前,您的用户拥有正确的角色。

用户详情入口点

  • /api/1.0/me入口点将显示有关您的应用程序/用户的详细信息

列出节点源

  • /api/1.0/nodes-sources:列出所有节点源,无论其类型如何。
  • /api/1.0/{node-type-name}:按类型列出节点源

如果您创建了一个Event节点类型,API内容将在/api/1.0/event端点提供。序列化上下文将自动在您的API资源中添加@id@typeslugurl字段。

{
    "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"
    }
}

注意:在列表上下文中,仅公开默认组中的节点类型字段。如果您想防止在列表期间序列化某些节点类型字段,可以给它们一个组名。这可以有助于避免文档节点引用字段使您的JSON响应膨胀。

过滤器

  • itemsPerPage: int
  • page: int
  • _locale: string 如果未设置_locale,Roadiz将与现有的Accept-Language头协商
  • search: string
  • order: array 示例:order[publishedAt]: DESC,值包括
    • ASC
    • DESC
  • properties: array 通过名称过滤序列化属性
  • archive: string 示例:archive: 2019-02archive: 2019。此参数仅在publishedAt字段上使用

NodesSources内容上

  • path: string 根据节点名称或别名对节点源进行过滤,例如:/home。路径需要_locale过滤器来获取正确的翻译。路径过滤器还可以解析任何重定向,如果它与有效的节点源相关联。
  • id: id 节点源ID
  • title: string
  • not: array<int|string>|int|string,通过它们的数字ID、节点名称或@id过滤出一个或多个节点
  • publishedAt: DateTimearray 包含
    • after
    • before
    • strictly_after
    • strictly_before
  • tags: array<string> 通过标签过滤(不能与search一起使用)
  • tagExclusive: bool 通过标签进行AND逻辑过滤(不能与search一起使用)
  • node.parent: int|string 数字ID、节点名称或@id
  • node.aNodes.nodeA: int|string(数字ID、节点名称或@id)通过节点引用进行过滤(查找被引用的节点)
  • node.bNodes.nodeB: int|string(数字ID、节点名称或@id)通过节点引用进行过滤(查找拥有引用的节点)
  • node.aNodes.field.name: string 通过节点类型字段名称过滤节点引用(可选,如果没有设置,则将对任何节点引用应用node.aNodes.nodeA过滤器)
  • node.bNodes.field.name: string 通过节点类型字段名筛选节点引用(可选,未设置时,将对任何节点引用应用 node.bNodes.nodeB 过滤)
  • node.visible: bool
  • node.home: bool
  • node.nodeType: array|string 通过节点类型筛选节点源
  • node.nodeType.reachable: bool

以及任何已 索引 的日期、日期时间布尔节点类型字段

区域设置筛选

_locale 过滤 设置 Roadiz 主要翻译,用于所有数据库查找,确保始终将其设置为正确的区域设置,否则在针对法语查询使用 searchpath 过滤时不会得到任何结果。

路径筛选

path 过滤 使用 Roadiz 内部路由器 仅搜索与您的查询匹配的结果。您可以使用

  • 节点源规范路径,例如:/about-us
  • 节点源 nodeName 路径:例如:/en/about-us
  • 重定向路径,例如:/old-about-us

如果您得到一个结果,您将在 hydra:member > 0 > url 字段中找到规范路径以在您的前端框架中创建重定向并宣传节点源的新 URL。

使用 Accept-Language 重定向主页路径

使用 path 过滤器与 /,您可以将 Accept-Language 标头发送到 API 以让它决定对您的消费者最好的翻译。如果找到有效数据,API 将以包含接受的区域设置的 Content-Language 标头响应。要启用此行为,您必须启用 force_locale Roadiz 设置以确保每个主页路径显示其区域设置并避免无限重定向循环。

搜索节点源

  • /api/1.0/nodes-sources/search:使用 Apache Solr 引擎针对 search 参数搜索所有节点源

如果您的搜索参数长度超过 3 个字符,则每个 API 结果项将包含

{
    "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 如果未设置_locale,Roadiz将与现有的Accept-Language头协商
  • search: string
  • 标签:array<string>
  • node.parent: intstring(节点名称)
  • node.visible: bool
  • node.nodeType: array|string 通过类型筛选节点源搜索
  • properties: array 通过名称过滤序列化属性

按节点类型列出标签

  • /api/1.0/{node-type-name}/tags:从给定类型中获取节点源中使用的所有标签。

如果您创建了 Event 节点类型,您可能想要列出任何附加到 事件Tags,API 可在 /api/1.0/event/tags 端点处使用。请注意,此端点将显示所有标签,无论是可见的还是不可见的,除非您对它们进行筛选。

过滤器

  • itemsPerPage: int
  • page: int
  • _locale: string 如果未设置_locale,Roadiz将与现有的Accept-Language头协商
  • search: string:这将搜索 tagName 和翻译 name
  • order: array 示例 order[position]: ASC
    • ASC
    • DESC
  • node.parent: intstring(节点名称)
  • node.tags.tagName: intstring,或 array(标签名称)
  • parent: intstring(标签名称)
  • properties: array 通过名称过滤序列化属性

Tag 内容上

  • tagName: string
  • parent: intstring(标签名称)
  • visible: bool

按节点类型列出存档

  • /api/1.0/{node-type-name}/archives:从给定类型中获取节点源中使用的所有出版月份。

如果您创建了 Event 节点类型,您可能想要列出任何 事件 的存档,API 可在 /api/1.0/event/archives 端点处使用。以下是一个示例响应,按年份列出所有存档

{
    "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 如果未设置_locale,Roadiz将与现有的Accept-Language头协商
  • 标签:array<string>
  • tagExclusive: bool
  • node.parent: intstring(节点名称)

获取节点源详情

  • /api/1.0/{node-type-name}/{id}/{_locale}:获取具有节点 ID 和翻译 locale 的节点源。这是用于生成您的内容 JSON-LD @id 字段的默认路由。
  • /api/1.0/{node-type-name}/{id}:获取具有节点 ID 和系统 默认 区域设置(或查询字符串)的节点源。
  • /api/1.0/{node-type-name}/by-slug/{slug}:获取具有其 slug(nodeNameurlAlias)的节点源

对于每个节点源,API 将在 /api/1.0/event/{id}/api/1.0/event/by-slug/{slug} 端点处公开详细内容。

从其 path 直接获取节点源详细信息

  • /api/1.0/nodes-sources/by-path/?path={path}:根据其 path(包括主页根路径)获取一个节点源的详细信息

过滤器

  • properties: array 通过名称过滤序列化属性

备用资源 URL

任何节点源详细响应都将包含一个带有所有替代翻译URL的Link头。例如,一个翻译成英语和法语的法律页面将包含这个Link头数据。

<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资源的URL始终是绝对路径,而不是绝对URL,以便在不携带API方案的情况下,在你的前端框架中生成自己的URL。

列出节点源子项

出于安全考虑,我们不会自动嵌入节点源子项。我们邀请您使用TreeWalker库来扩展您的JSON序列化,为您的每个节点类型构建一个安全的图。创建一个JMS\Serializer\EventDispatcher\EventSubscriberInterface订阅者来扩展serializer.post_serialize事件,并使用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()
    );
}

序列化上下文

serializer.post_serialize事件期间,序列化上下文在每次请求中都会持有许多有用的对象。

  • request:Symfony当前请求对象
  • nodeType:初始节点源类型(如果不适用则为null
  • cache-tags:在序列化图期间填充的缓存标签集合
  • translation:当前请求的翻译
  • groups:当前请求的序列化组
    • 列出节点源请求期间的序列化组
      • nodes_sources_base
      • document_display
      • thumbnail
      • tag_base
      • nodes_sources_default
      • urls
      • meta
    • 在单个节点源请求期间的序列化组:单个节点源请求:- walker:rezozero树遍历器 - children:rezozero树遍历器 - nodes_sources - nodes_sources_single:仅用于在主实体上显示自定义对象 - document_display - thumbnail - url_alias - tag_base - urls - meta - breadcrumbs:仅在详细请求上允许面包屑
# 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') : [];
}

面包屑

如果您希望API为每个可达的节点源提供面包屑,您可以实现Themes\AbstractApiTheme\Breadcrumbs\BreadcrumbsFactoryInterface并将其注册在您的AppServiceProvider中。对于每个NodeTypeSingle API请求(即在列表上下文之外),将注入一个包含在您的BreadcrumbsFactoryInterface中定义的所有节点父级的breadcrumbs

以下是一个尊重Roadiz节点树结构的纯实现。

<?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();
};

错误

如果您想在JSON中获取详细的错误信息,请不要忘记在每个请求中添加头:Accept: application/json。您将得到类似的消息

{
    "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"
}

带有正确的状态码(40x或50x)。确保在请求失败时从您的客户端框架中捕获并读取您的响应数据,以了解更多错误信息。

使用 Etags

每个基于NodeSources的响应都将包含一个基于API响应内容校验和计算的ETag头。

您可以将您的API消费者设置为发送包含最新ETag的If-None-Match头。如果内容未更改,API将返回一个空的304 Not Modified响应;如果内容已更改,则返回带有新ETag头的整个响应。