luciansabo / fields-options
用于在RESTful API中检索嵌套字段和字段选项的标准格式
Requires
- php: >=7.4
Requires (Dev)
- phpunit/phpunit: ^9.5
- squizlabs/php_codesniffer: ^3.7
- vimeo/psalm: ^5.0
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的方式指定字段的路径或嵌套字段。
根字段
在其基本形式中,您将字段指定为键,其值使用true或false。
true表示返回该字段。这也意味着您想要其默认嵌套字段,如果这是一个对象false表示不要返回该字段(如果省略字段,则视为您不想要该字段)
?fields=<url-encoded-json>
{
"id": true,
"profile": false
}
这相当于不提供profile字段,因为如果您开始提供字段,省略的字段被视为不想要的。
{
"id": true
}
字段组
字段组可以用于将某些字段分组,并请求返回/不返回它们。字段组不支持任何选项,并且只能是true或false。
有两个特殊组:_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 默认只序列化两个字段:id 和 name。字段 age 和 education 只会在特别请求或使用 _all: true 时导出。我们还假设根 DTO 默认导出所有字段(即 id 和 profile)。
这排除所有字段(除了 profile)
{
"_all": true,
"profile": false
}
这导出 profile 中的所有字段
{
"profile": {
"_all": true
}
}
内置组优先级规则
_all和_defaults互斥_all比_defaults有优先级
自定义组
您还可以声明自己的字段组,并在其背后放置自定义逻辑。要求以下划线为组名前缀,以便检测到它们。
您仍然可以有以下划线开头的字段名,但它们将不支持选项,仅支持布尔值。如果可能,请避免使用以下划线开头的字段名。
{
"profile": {
"_basicInfo": true
}
}
嵌套字段
嵌套字段使用点表示法。
示例 1
只检索根的 id 和 profile 的 name
解码字段选项
{
"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(假设 id 和 name 是默认字段,age 和 education 是可选字段)
解码字段选项
{
"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 和其嵌套属性等对象上应用选项,这样对象将只序列化所需的字段。
使用点表示法指定字段路径。
内部结构永远不应该手动指定。您应该始终使用 FieldsOptions 或 FieldsOptionsBuilder 类。如果您觉得需要手动构建/修改数据数组,那么您正在做错事。库试图隐藏这些细节,以便可以保护您免受 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() 简单地用来确定字段是否在选项中指定,可以是 true 或 false。 isFieldIncluded() 还会检查字段是否设置为 true。
字段验证
此库附带了一个 Validator 实现,该实现应与数组和对象原型一起使用: Lucian\FieldsOptions\Validator。
您可以将验证器提供给 FieldsOptions 和 FieldsOptionsBuilder。如果没有提供验证器,将使用基本验证器,该验证器仅检查数据结构。
如果验证器使用原型/模式构建,则包含无效字段将触发 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);