awtyklo/carve-api

为 Symfony 构建REST API端点的统一且可重用的方式

2.0.5 2024-04-25 13:26 UTC

README

为 Symfony 构建REST API端点的统一且可重用的方式。

重要!正在进行中。

提供统一且可重用方式来构建REST API端点 允许单个端点定制 自动生成OpenAPI文档 引入拒绝功能以简化访问控制,包括反馈消息 添加具有REST API友好信息的约束层

构建工具

  • FOSRestBundle
  • Symfony 序列化器
  • OpenAPI

配置

config/packages/doctrine.yaml 中添加。这将确保 Types::DATETIME_MUTABLE 总是在UTC时区存储。

doctrine:
    dbal:
        types:
            datetime: Carve\ApiBundle\DBAL\Types\UTCDateTimeType

config/services.yaml 中添加。这将覆盖默认的 FormErrorNormalizer,以从错误消息中传递额外的参数。

services:
    fos_rest.serializer.form_error_normalizer:
        class: Carve\ApiBundle\Serializer\Normalizer\FormErrorNormalizer

config/services.yaml 中添加。这将覆盖默认的 ViewResponseListener,以处理视图导出。

services:
    fos_rest.view_response_listener:
        class: Carve\ApiBundle\EventListener\ViewResponseListener

config/packages/framework.yaml 中添加。这将添加默认的循环引用处理。

framework:
    serializer:
        circular_reference_handler: carve_api.serializer.circular_reference_handler

修改 src/Kernel.php 以覆盖 FormModelDescriber 类。

<?php

namespace App;

use Carve\ApiBundle\ModelDescriber\FormModelDescriber;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel implements CompilerPassInterface
{
    use MicroKernelTrait;

    public function process(ContainerBuilder $container): void
    {
        $formModelDescriberService = $container->getDefinition('nelmio_api_doc.model_describers.form');
        $formModelDescriberService->setClass(FormModelDescriber::class);
    }
}

请求执行错误报告

旨在在控制器中的操作只能部分执行时在响应中提供额外信息。抛出 RequestExecutionException 将导致 409 HTTP代码。

以下可以找到更多有关默认HTTP代码以及如何将 RequestExecutionException409 结合使用的信息。

  • 200 - 请求已成功执行。可以在响应中包含附加数据(例如更新后的对象)。
  • 204 - 请求已成功执行。响应中不包含任何附加数据。
  • 400 - 由于无效的有效负载,请求无法执行(通常与表单一起使用)。表单错误由 Carve\ApiBundle\Serializer\Normalizer\FormErrorNormalizer 序列化。
  • 409 - 请求仅部分执行(有效负载正确)。在响应中包含附加信息。
  • 500 - 出现意外错误。

响应结构

以下是一个示例结构(TypeScript)。

type RequestExecutionSeverity = "warning" | "error";

// eslint-disable-next-line
type RequestExecutionExceptionPayload = any;

interface TranslateVariablesInterface {
    [index: string]: any;
}

interface RequestExecutionExceptionErrorType {
    message: string;
    parameters?: TranslateVariablesInterface;
    severity: RequestExecutionSeverity;
}

interface RequestExecutionExceptionType {
    code: number;
    payload: RequestExecutionExceptionPayload;
    severity: RequestExecutionSeverity;
    errors: RequestExecutionExceptionErrorType[];
}

第一层的 severity 将采用消息中最高 severity 的值。

{
    "code": 409,
    "severity": "error",
    "payload": null,
    "errors": [
        {
            "message": "functionality.error.processingWarning",
            "parameters": { "userName": "coolUser", "areaNo": 2 },
            "severity": "warning"
        },
        {
            "message": "functionality.error.somethingFailed",
            "parameters": { "deviceId": 123 },
            "severity": "error"
        }
    ]
}

严重性解释

error 表示在操作执行过程中发生错误,阻止了剩余步骤的执行。一个好的例子是无法连接到第三方系统(例如Google服务)。

warning 表示在操作执行过程中出现了一个问题,不应该发生,但它已被管理,并且剩余步骤已执行。一个好的例子是未从第三方系统中删除资源的操作,导致该资源缺失(我们的应用程序预计该资源存在并尝试删除它,但该资源在第三方系统中不存在)。

使用示例

待修复(目前有一些示例是过时的,其中一些是正确的。扩展mergeAsX函数示例)

error.requestExecutionFailed - 是默认消息值 - 可以通过在 RequestExecutionException 构造函数中设置第3个参数来更改。构造函数消息(第1个参数)被添加到错误数组的第一个对象中,其他可以添加使用 addError 方法。

以下是一个示例

$exception = new RequestExecutionException('functionality.error.somethingFailed', ['userName' => 'coolUser', 'areaNo' => 2]);
$exception->addError('functionality.error.somethingElseFailed', ['deviceId' => 123]);
throw $exception;

另一个示例

throw new RequestExecutionException('functionality.error.somethingFailed', ['userName' => 'coolUser', 'areaNo' => 2]);

与 forge 集成

默认情况下,使用handleCatchErrorContext来创建ErrorDialog前端,将显示对话框中的响应。翻译后的message将用作对话框标题,errors数组将以多个Alert的形式显示错误严重性。文本将使用message作为键,使用parameters作为翻译参数进行翻译。ErrorDialog需要添加到应用程序布局中。

批处理

批处理旨在处理可以通过列表端点查询的结果。

    #[Rest\Post('/batch/disable')]
    // TODO Rest API docs
    public function batchDisableAction(Request $request)
    {
        $process = function (Device $device) {
            $device->setEnabled(false);
        };

        return $this->handleBatchForm($process, $request);
    }

您可以在$process函数中返回自定义的BatchResult来自定义返回的结果。如果没有返回任何内容,则将返回一个带有SUCCESS状态的BatchResult(由getDefaultBatchProcessEmptyResult函数控制)。

    $this->handleBatchForm($process, $request, DeviceDeny::DISABLE);
use Carve\ApiBundle\Model\BatchResult;
use Carve\ApiBundle\Enum\BatchResultStatus;

    $process = function (Device $device) {
        $device->setEnabled(false);

        // Your logic
        if (true) {
            return new BatchResult($device, BatchResultStatus::SKIPPED, 'batch.device.variableDelete.skipped.missing');
        }
    };

您还可以使用denyKey跳过任何不应处理的结果(将返回带有SKIPPED状态和基于denyKey的消息的BatchResult)。

您可以使用以下模式来在BatchQueryType表单中定义额外的字段(该表单仅包含sortingids字段)。

定义包含任何所需字段的表单并扩展BatchQueryType。字段不应进行映射,否则您将需要更新表单的数据模型(这也是一个好解决方案)。

<?php

declare(strict_types=1);

namespace App\Form;

use Carve\ApiBundle\Form\BatchQueryType;
use Carve\ApiBundle\Validator\Constraints\NotBlank;
use Symfony\Component\Form\FormBuilderInterface;

class BatchVariableDeleteType extends BatchQueryType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        parent::buildForm($builder, $options);

        $builder->add('name', null, ['mapped' => false, 'constraints' => [
            new NotBlank(),
        ]]);
    }
}

在控制器中准备自定义逻辑。

    #[Rest\Post('/batch/variable/delete')]
    // TODO Rest API docs
    public function batchVariableDeleteAction(Request $request)
    {
        $process = function (Device $device, FormInterface $form) {
            $name = $form->get('name')->getData();

            // My custom logic
        };

        return $this->handleBatchForm($process, $request, DeviceDeny::VARIABLE_DELETE, null, BatchVariableDeleteType::class);
    }

handleBatchForm函数的注释。

/**
 * Callable $process has following definition:
 * ($object, FormInterface $form): ?BatchResult.
 * Empty result from $process will be populated with getDefaultBatchProcessEmptyResult().
 * By default it will be BatchResult with success status.
 *
 * Callable $postProcess has following definition:
 * (array $objects, FormInterface $form): void.
 */

导出(CSV和Excel)

当使用Carve\ApiBundle\EventListener\ViewResponseListener并从控制器返回Carve\ApiBundle\View\ExportCsvViewCarve\ApiBundle\View\ExportExcelView时,结果将自动序列化并以csvxlsx文件的形式返回。

示例用法

    use Carve\ApiBundle\View\ExportCsvView;
    use Carve\ApiBundle\Model\ExportQueryField;
    // ...
    public function customExportAction()
    {
        $results = $this->getRepository(Task::class)->findAll();
        $fields = [];

        // fields will most likely come from a POST request
        $field = new ExportQueryField();
        // What field should be included in the export
        $field->setField('name');
        // What label should be added for this field
        $field->setLabel('Name');
        $fields[] = $field;

        $filename = 'custom_export.csv';

        return new ExportCsvView($results, $fields, $filename);
    }

枚举翻译

默认情况下,导出中的每个枚举都将进行翻译。翻译字符串的结构如下:enum.entityName.fieldName.enumValue。您可以通过添加一个Carve\ApiBundle\Attribute\Export\ExportEnumPrefix属性来覆盖前缀。

在下面的示例中,翻译字符串将是enum.common.sourceType.enumValue

    /**
     * Source type (upload or external url).
     */
    #[ExportEnumPrefix('enum.common.sourceType.')]
    #[ORM\Column(type: Types::STRING, enumType: SourceType::class)]
    private ?SourceType $sourceType = null;

导出定制

您可以使用类似于Carve\ApiBundle\Serializer\ExportEnumNormalizer的模式来定制常见的导出情况。

本地开发

将以下行添加到您的项目中的composer.json

    "repositories": [
        {
            "type": "path",
            "url": "/var/www/carve-api"
        }
    ],

"/var/www/carve-api"更改为您的本地包路径。它应指向carve-api的根目录(这意味着carve-apicomposer.json位于/var/www/carve-api/composer.json)。

之后执行

composer require "awtyklo/carve-api @dev"

它应链接本地包而不是远程包。

注意!它将更改composer.json。请记住,在提交更改时。

TODO:如何撤销此操作

REST API文档

注意!每个端点仅支持一种方法。对端点使用多种方法可能会导致意外结果(例如,在/api/config上同时有GET和POST)。

主题参数

一些提到的属性支持主题参数,这意味着字符串(例如摘要、描述)可以包含将用Describer\ApiDescriber替换的参数。

主题参数根据Api\Resource属性中的subject进行准备。

以下为主题参数。以下为subject = "User"的示例。

  • subjectLower即"user"
  • subjectTitle即"User"
  • subjectPluralLower即"users"
  • subjectPluralTitle即"Users"

属性

  • #[Api\Summary] - 将摘要附加到操作。摘要支持主题参数。
  • #[Api\Parameter] - 具有描述的参数,支持主题参数。
  • #[Api\ParameterPathId] - 具有描述的预配置路径ID参数,支持主题参数。
  • #[Api\RequestBody] - 具有描述的请求体,支持主题参数。
  • #[Api\RequestBodyBatch] - 支持主题参数的请求体描述。当没有内容时(期望使用 Nelmio\ApiDocBundle\Annotation\Model),则将其设置为 ApibatchFormClass>。它还会将 'sorting_field_choices' 添加到内容选项中。
  • #[Api\RequestBodyCreate] - 内容设置为 ApicreateFormClass> 和支持主题参数的描述的请求体。
  • #[Api\RequestBodyEdit] - 内容设置为 ApieditFormClass> 和支持主题参数的描述的请求体。
  • #[Api\RequestBodyList] - 内容设置为 ApilistFormClass>(带有 'sorting_field_choices' 和 'filter_filterBy_choices' 选项)和支持主题参数的描述的请求体。
  • #[Api\RequestBodyExportCsv] - 内容设置为 ApiexportCsvFormClass>(带有 'sorting_field_choices','filter_filterBy_choices' 和 'fields_field_choices' 选项)和支持主题参数的描述的请求体。
  • #[Api\RequestBodyExportExcel] - 内容设置为 ApiexportExcelFormClass>(带有 'sorting_field_choices','filter_filterBy_choices' 和 'fields_field_choices' 选项)和支持主题参数的描述的请求体。
  • #[Api\Response200] - 预配置的带有代码 200 的响应,并支持主题参数的描述。
  • #[Api\Response200ArraySubjectGroups] - 预配置的带有代码 200 的响应,默认描述支持主题参数,并将内容设置为给定类和序列化组的 Nelmio\ApiDocBundle\Annotation\Model 数组。
  • #[Api\Response200BatchResults] - 预配置的带有代码 200 的列表响应,支持主题参数的描述,并将内容设置为 Carve\ApiBundle\Model\BatchResult 数组。
  • #[Api\Response200Groups] - 预配置的带有代码 200 的响应,支持主题参数的描述,并将序列化组附加到内容(期望内容为 Nelmio\ApiDocBundle\Annotation\Model)。
  • #[Api\Response200SubjectGroups] - 预配置的带有代码 200 的响应,支持主题参数的描述,并将内容设置为具有主题类和序列化组的 Nelmio\ApiDocBundle\Annotation\Model
  • #[Api\Response200List] - 预配置的带有代码 200 的列表响应,支持主题参数的描述,并将内容设置为具有 rowsCountresults 的对象,其中包含具有主题类和序列化组的项。
  • #[Api\Response204] - 预配置的带有代码 204 的响应,支持主题参数的描述。
  • #[Api\Response204Delete] - 预配置的带有代码 204 的响应,默认描述({{ subjectTitle }} 成功删除)支持主题参数。
  • #[Api\Response400] - 预配置的带有代码 400 的响应,默认描述(无法处理请求,因为数据无效)支持主题参数。
  • #[Api\Response404] - 预配置的带有代码 404 的响应,支持主题参数的描述。
  • #[Api\Response404Id] - 预配置的带有代码 404 的响应,默认描述(未找到指定 ID 的 {{ subjectTitle }} )支持主题参数。

使用示例

    #[Api\Summary('Get {{ subjectLower }} by ID')]
    public function getAction(int $id)
    #[Api\ParameterPathId('ID of {{ subjectLower }} to return')]
    public function getAction(int $id)
    #[Api\Parameter(name: 'serialNumber', in: 'path', schema: new OA\Schema(type: 'string'), description: 'The serial number of {{ subjectLower }} to return')]
    public function getAction(string $serialNumber)
    #[Api\RequestBody(description: 'New data for {{ subjectTitle }}', content: new NA\Model(type: Order::class))]
    public function editAction()
use Nelmio\ApiDocBundle\Annotation as NA;

    #[Api\RequestBodyBatch(content: new NA\Model(type: BatchVariableAddType::class))]
    public function batchVariableAddAction()
use Nelmio\ApiDocBundle\Annotation as NA;

    #[Api\Response200(description: 'Returns public configuration for application', content: new NA\Model(type: PublicConfiguration::class))]
    public function getAction()
use Nelmio\ApiDocBundle\Annotation as NA;

    #[Rest\View(serializerGroups: ['public:configuration'])]
    #[Api\Response200Groups(description: 'Returns public configuration for application', content: new NA\Model(type: PublicConfiguration::class))]
    public function getAction()
use Nelmio\ApiDocBundle\Annotation as NA;

#[Rest\View(serializerGroups: ['public:configuration'])]
class AnonymousController extends AbstractApiController
{
    #[Api\Response200Groups(description: 'Returns public configuration for application', content: new NA\Model(type: PublicConfiguration::class))]
    public function getAction()
}
    #[Api\Response200SubjectGroups('Returns created {{ subjectLower }}')]
    public function createAction(Request $request)
    #[Api\Response200List('Returns list of {{ subjectPluralLower }}')]
    public function listAction(Request $request)
    #[Api\Response204('{{ subjectTitle }} successfully enabled')]
    public function enableAction()
    #[Api\Response404('{{ subjectTitle }} with specified serial number not found')]
    public function getAction()

常见用例

use Carve\ApiBundle\Attribute as Api;
use Nelmio\ApiDocBundle\Annotation as NA;

    #[Rest\Post('/change/password')]
    #[Api\Summary('Change authenticated user password')]
    #[Api\Response204('Password successfully changed')]
    #[Api\RequestBody(content: new NA\Model(type: AuthenticatedChangePasswordType::class))]
    #[Api\Response400]
    public function changePasswordAction(Request $request)
use Carve\ApiBundle\Attribute as Api;
use Nelmio\ApiDocBundle\Annotation as NA;

    #[Rest\Post('/change/password/required')]
    #[Api\Summary('Change authenticated user password when password change is required. Password change is required when authenticated user roles include ROLE_CHANGEPASSWORDREQUIRED')]
    #[Api\RequestBody(content: new NA\Model(type: AuthenticationChangePasswordRequiredType::class))]
    #[Api\Response200(description: 'Returns updated authentication data', content: new NA\Model(type: AuthenticationData::class))]
    #[Api\Response400]
use Carve\ApiBundle\Attribute as Api;

    #[Rest\Get('/token/extend/{refreshTokenString}')]
    #[Api\Summary('Extend refresh token for another access token TTL')]
    #[Api\Response204('Correct refresh token extended successfully')]
    #[Api\Parameter(in: 'path', name: 'refreshTokenString', description: 'Refresh token string')]
    public function extendAction(string $refreshTokenString)
use Carve\ApiBundle\Attribute as Api;
use Nelmio\ApiDocBundle\Annotation as NA;

    #[Rest\Post('/batch/variable/add')]
    #[Api\Summary('Add variable to multiple {{ subjectPluralLower }}')]
    #[Api\RequestBodyBatch(content: new NA\Model(type: BatchVariableAddType::class))]
    #[Api\Response200BatchResults]
    #[Api\Response400]
    public function batchVariableAddAction(Request $request)
use OpenApi\Attributes as OA;

    #[Api\Response200(
        description: 'Progress',
        content: new OA\JsonContent(
            type: 'object',
            properties: [
                new OA\Property(property: 'total', type: 'integer'),
                new OA\Property(property: 'pending', type: 'integer'),
            ]
        ),
    )]
    public function progressAction(int $id)
class OptionsController extends AbstractApiController
{
    #[Rest\Get('/users')]
    #[Api\Summary('Get users')]
    #[Api\Response200ArraySubjectGroups(User::class)]
    public function usersAction()
    {
        return $this->getRepository(User::class)->findAll();
    }
}

开发

运行测试

使用 composer 下载包(包括 PHPUnit)。

composer install

验证 PHPUnit 安装。以下命令应返回 PHPUnit 的版本。

php vendor/bin/phpunit --version

运行测试。

php vendor/bin/phpunit