luciansabo/fields-options

用于在RESTful API中检索嵌套字段和字段选项的标准格式

1.5.3.1 2023-07-12 10:33 UTC

This package is auto-updated.

Last update: 2024-09-12 13:03:05 UTC


README

RESTful API尝试通过实现一个用于指定应包含在响应中的字段的fields参数,以获得与GraphQL相同的灵活性。

大多数API使用查询参数,并通过逗号分隔来指定字段。

这个库试图填补这个空白。

展示所提出的标准化语法

假设这个结构

{
    "id": 123,
    "profile": {
        "name": "John Doe",
        "age": 25,
        "education": [
            {
                "institutionName": "Berkeley University",
                "startYear": 1998,
                "endYear": 2000
            },
            {
                "institutionName": "MIT",
                "startYear": 2001,
                "endYear": 2005
            }
        ]
    }
}

字段

字段可以是嵌套结构的一部分。您可以通过类似于GraphQL的方式指定字段的路径或嵌套字段。

根字段

在其基本形式中,您将字段指定为键,其值使用truefalse

  • true表示返回该字段。这也意味着您想要其默认嵌套字段,如果这是一个对象
  • false表示不要返回该字段(如果省略字段,则视为您不想要该字段)

?fields=<url-encoded-json>

{
    "id": true,
    "profile": false
}

这相当于不提供profile字段,因为如果您开始提供字段,省略的字段被视为不想要的。

{
    "id": true
}

字段组

字段组可以用于将某些字段分组,并请求返回/不返回它们。字段组不支持任何选项,并且只能是truefalse

有两个特殊组:_defaults_all

  • 如果您想指示端点返回默认字段或不返回默认字段,请使用_defauls作为字段。
  • 如果您想指示端点返回所有可用字段或不返回所有可用字段,请使用_all作为字段。

_defaults组

_defaults假设对于嵌套字段为true,如果您只指定父字段,并且不提供嵌套字段列表。_defaults仅当您没有根或子字段字段列表时隐式设置为true。当您指定字段列表时,它被视为false,并且您必须明确地包含默认字段。

默认字段逻辑应嵌入到序列化对象中。

在这种情况下,没有字段列表,因此我们将假设您想要从配置文件中导出默认字段

{
    "profile": true
}

这相当于

{
    "_defaults": false,
    "profile": true
}

或使用

{
    "_defaults": false,
    "profile": {
        "_defaults": true
    }
}

由于根定义包含字段列表(配置文件),因此我们将假设您不想要根的默认字段,而只想获取profile对象。

但是,如果您还想获取根的默认字段以及配置文件,您可以在根处指定_defaults

{
    "_defaults": true,
    "profile": true
}

如果您不希望从配置文件中获取默认字段,但只想获取特定字段(例如配置文件ID),您可以请求它,并通过未指定_defaults假设您不希望默认字段,因为您请求了特定字段

{
    "profile": {
        "id": true
    }
}

这相当于

{
    "_defaults": false,
    "profile": {
        "_defaults": false,
        "id": true
    }
}

仅当包含非默认字段列表时,指定_defaults: false才有意义。

这是有效的,但不是很实用,因为没有在配置文件中包含任何其他字段,并且禁用了默认字段,您将获得空值。

{
    "profile": {
        "_defaults": false
    }
}

应用这些后,您将得到

{
    "profile": null
}

这3个示例是等效的,并且它们都请求了profile中的默认字段

{
    "profile": {
        "_defaults": true
    }
}
{
    "profile": true
}

注意空对象如何转换为_defaults: true

{
    "profile": {}
}

_all组

由于 _all 默认为 false,将其设置为 false 没有意义。_all 只有在你想要所有字段时才有意义,因此请使用 _all: true

例如,假设 Profile DTO 默认只序列化两个字段:idname。字段 ageeducation 只会在特别请求或使用 _all: true 时导出。我们还假设根 DTO 默认导出所有字段(即 idprofile)。

这排除所有字段(除了 profile

{
    "_all": true,
    "profile": false
}

这导出 profile 中的所有字段

{
    "profile": {
        "_all": true
    }
}

内置组优先级规则

  • _all_defaults 互斥
  • _all_defaults 有优先级

自定义组

您还可以声明自己的字段组,并在其背后放置自定义逻辑。要求以下划线为组名前缀,以便检测到它们。

您仍然可以有以下划线开头的字段名,但它们将不支持选项,仅支持布尔值。如果可能,请避免使用以下划线开头的字段名。

{
    "profile": {
        "_basicInfo": true
    }
}

嵌套字段

嵌套字段使用点表示法。

示例 1

只检索根的 idprofilename

解码字段选项

{
    "id": true,
    "profile": {
        "name": true
    }
}

实际请求,带有 URL 编码的字段选项:?fields=%7B%22id%22%3Atrue%2C%22profile%22%3A%7B%22name%22%3Atrue%7D%7D

结果

{
    "id": 123,
    "profile": {
        "name": "John Doe"
    }
}

嵌套字段也支持字段组。

示例 2

只检索 profile 字段,从 profile 中检索默认字段 + age(假设 idname 是默认字段,ageeducation 是可选字段)

解码字段选项

{
    "profile": {
        "_defaults": true,
        "age": true
    }
}

结果

{
    "profile": {
        "id": 123,
        "name": "John Doe",
        "age": 25
    }
}

字段选项

使用所需字段的 _opt 键指定字段选项。

示例 1

从根中检索 id,从 profile 中仅检索第一个机构,按 startYear 排序

{
    "id": true,
    "profile": {
        "education": {
            "_opt": {
                "limit": 1,
                "sort": "startYear",
                "sortDir": "asc"
            }
        }
    }
}

实际请求,带有 URL 编码的字段选项:?fields=%7B%22id%22%3Atrue%2C%22profile%22%3A%7B%22education%22%3A%7B%22_opt%22%3A%7B%22limit%22%3A1%2C%22sort%22%3A%22startYear%22%2C%22sortDir%22%3A%22asc%22%7D%7D%7D%7D

结果包含教育对象中的默认字段。假设所有字段都默认检索

{
    "id": 123,
    "profile": {
        "education": [
            {
                "institutionName": "Berkeley University",
                "startYear": 1998,
                "endYear": 2000
            }
        ]
    }
}

示例 2

要从 profile 的教育中检索除机构名称之外的所有字段,按 startYear 排序,可以将 _all 设置为 true 并将 institutionName 设置为 false

{
    "profile": {
        "education": {
            "_all": true,
            "institutionName": false,
            "_opt": {
                "limit": 1,
                "sort": "startYear",
                "sortDir": "asc"
            }
        }
    }
}

结果

{
    "profile": {
        "education": [
            {
                "startYear": 1998,
                "endYear": 2000
            }
        ]
    }
}

使用库

FieldsOptions 对象封装了提供的选项,可以从数组(来自请求)或使用 FieldsOptionsBuilder(如果您想以编程方式配置它们)构造。

由调用者决定是否遵守这些字段选项,但库提供了一个名为 FieldsOptionsObjectApplier 的类,可以用于递归地在诸如 DTO 和其嵌套属性等对象上应用选项,这样对象将只序列化所需的字段。

使用点表示法指定字段路径。

内部结构永远不应该手动指定。您应该始终使用 FieldsOptionsFieldsOptionsBuilder 类。如果您觉得需要手动构建/修改数据数组,那么您正在做错事。库试图隐藏这些细节,以便可以保护您免受 BC 断裂和结构更改的影响。

假设此 JSON 结构已在请求中发送

{
    "id": true,
    "seo": false,
    "profile": {
        "education": {
            "_all": true,
            "_opt": {
                "limit": 1,
                "sort": "startYear",
                "sortDir": "asc"
            }
        }
    }
}

FieldsOptionsBuilder

可以使用构建器以编程方式配置之前 JSON 结构的字段选项。通常建议使用构建器而不是手动创建数组。

/**
 * Include fields from given path
 *
 * @param string|null $fieldPath Base path
 * @param array $fields Optional list of included field (you can use relative paths in dot notation too)
 * @return $this
*/
public function setFieldIncluded(?string $fieldPath, array $fields = []): self`

您可以选择使用接收样本 DTO 的验证器。验证器将接收一个对象(首选)或表示响应模式的数组。

use Lucian\FieldsOptions\FieldsOptionsBuilder;
use Lucian\FieldsOptions\Validator;

$validator = new Validator($this->getSampleDto());
$builder = new FieldsOptionsBuilder($validator);
$fieldsOptions = $builder
    ->setFieldIncluded('id')
    ->setFieldExcluded('seo')
    ->setAllFieldsIncluded('profile.education')
    ->setFieldOption('profile.education', 'limit', 1)
    ->setFieldOption('profile.education', 'sort', 'startYear')
    ->setFieldOption('profile.education', 'sortDir', 'asc')
    ->build()

您可以从给定路径一次性包括或排除多个字段

$fieldsOptions = $this->builder
    ->setFieldIncluded(null, ['name']) // this is equivalent to setFieldIncluded('name') 
    ->setFieldIncluded('profile', ['workHistory']) // include profile.workHistory
    ->setFieldExcluded('profile.workHistory', ['institution']) // but exclude profile.workHistory.institution
    ->setFieldIncluded('profile.education', ['id', 'name']) // include profile.education.id and profile.education.name    
    ->setFieldIncluded('profile', ['education.startYear']) // include profile.education.startYear. the field can be a relative path
    ->build();

您还有方法可以一次性设置字段的全部选项

public function setFieldOptions(string $fieldPath, array $options): self

$educationOptions = ['limit' => 2, 'offset' => 5];
$builder->setFieldOptions('profile.education', $educationOptions);

您可以设置一个包含的自定义分组字段

public function setGroupFieldIncluded(string $groupField, ?string $fieldPath = null): self

$fieldsOptions = $builder->setGroupFieldIncluded('_basicInfo', 'profile')
    ->build();
$fieldsOptions->hasGroupField('_basicInfo', 'profile') // true

您还可以使用初始数据创建一个构建器。一个场景是向现有字段的选项中添加选项。

$data = $fieldsOptions->toArray();
$builder = new FieldsOptionsBuilder($validator, $data);
$builder->setFieldIncluded('fancyField');

使用 FieldsOptions 类

use Lucian\FieldsOptions\FieldsOptions;

// assuming we use the Symfony request
// $request = Request:::createFromGlobals();
$data = json_decode($request->getContent());

$options = new FieldsOptions($data);

$options->isFieldIncluded('id'); // true
$options->isFieldIncluded('missing'); // false
// field is present but value is false
$options->isFieldIncluded('seo'); // false
$options->isFieldIncluded('profile'); // true
$options->isFieldIncluded('profile.education'); // true
$options->getFieldOption('profile.education', 'limit'); // 1
$options->getFieldOption('profile.education', 'missing', 1); // 1 - default
$options->getFieldOption('profile.education', 'missing'); // null - default

// field groups
$options->hasDefaultFields(); // true
$options->hasDefaultFields('profile'); // false
$options->hasAllFields('profile'); // false
$options->hasAllFields('profile.education'); // true
$options->hasAllFields('profiles.missing'); // throws exception
$options->hasGroupField('_basicInfo', 'profile'); // false
        
// you can export the entire structure
$array = $options->toArray();

注意 isFieldIncluded()isFieldSpecified() 之间的区别。 isFieldSpecified() 简单地用来确定字段是否在选项中指定,可以是 truefalseisFieldIncluded() 还会检查字段是否设置为 true

字段验证

此库附带了一个 Validator 实现,该实现应与数组和对象原型一起使用: Lucian\FieldsOptions\Validator

您可以将验证器提供给 FieldsOptionsFieldsOptionsBuilder。如果没有提供验证器,将使用基本验证器,该验证器仅检查数据结构。

如果验证器使用原型/模式构建,则包含无效字段将触发 RuntimeException。

验证工作原理:要验证字段,将使用反射分析原型。所有属性都视为有效字段。有类型的属性没有问题,如果它们是标量或类。对于没有类型提示的属性,验证器会尝试检查值。如果值包含集合,PHP 没有集合类型,我们也不能依赖 phpdoc。在这种情况下,您必须至少填充一个对象到数组中,我们假设它们都是相同类型。

测试字段组

/**
 * WIll check if the options contain the default fields either by implicit or explicit inclusion
 *
 * @param string|null $fieldPath
 * @return bool true if _defaults is not specified or specified and is not false, false otherwise
 */
public function hasDefaultFields(?string $fieldPath = null): bool
/**
 * WIll check if the options contain all fields either by implicit or explicit inclusion
 *
 * @param string|null $fieldPath
 * @return bool false if _all is not specified or specified and is not false, true otherwise
 */
public function hasAllFields(?string $fieldPath = null): bool

获取路径的包含字段列表

/**
* Returns the list of actually explicitly included fields
* Does not know about defaults or groups. If a field is a default field it won't be returned here.
* This will probably change in future versions to also include the default fields or coming from group fields
* if they were included using the group
*
* @param string|null $fieldPath
* @return array
*/
public function getIncludedFields(?string $fieldPath = null): array

使用 FieldsOptionsObjectApplier 类

应用选项意味着确保数据按给定选项期望的方式序列化。在 FieldsOptionsObjectApplier 中的方法是提供您的 DTO 和您的 ExportApplierInterface 实现。

interface ExportApplierInterface
{
    /**
     * This is s used to mark the exported properties on the object.
     * It is up to the object and/or whatever serialization method you have to actually only export those.
     * The easiest way to do it is to implement the native PHP `JsonSerializable`interface and write the logic right
     * inside the object.
     *
     * @param object|array $data
     * @param array|null $fields
     * @return object|array $data with exported fields
     */
    public function setExportedFields(/*object|array*/ $data, ?array $fields): void;

    /**
     * Returns the properties exported by default on the object.
     *
     * @param object|array $data
     * @return string[]
     */
    public function getExportedFields(/*object|array*/ $data): array;

    /**
     * Should return the base class of your DTO
     * This helps
     *
     * @return string
     */
    public function getSupportedClass(): string;
}

class SampleExportApplier implements ExportApplierInterface
{
    public function setExportedFields($data, ?array $fields): void
    {
        if ($data instanceof AbstractDto) {
            // keep valid properties only
            if ($fields) {
                $fields = array_filter($fields, [$data, 'propertyExists']);
            }
            $data->setExportedProperties($fields);
        }
    }

    public function getExportedFields($data): array
    {
        if ($data instanceof AbstractDto) {
            return array_keys(iterator_to_array($data->getIterator()));
        }

        return [];
    }

    public function getSupportedClass(): string
    {
        return AbstractDto::class;
    }
}

示例

$applier = new FieldsOptionsObjectApplier(new SampleExportApplier());
$dto = $this->getSampleDto();

$fieldsOptions = (new FieldsOptionsBuilder())    
    ->setFieldIncluded('id')
    ->build();

$applier->apply($dto, $fieldsOptions);

// now DTO should only serialize the id field
echo json_encode($dto);