kafoso / questful
一个接口工具,提供HTTP查询内容与RESTful API之间合理的链接。名称"Questful"是“Query”(来自HTTP)和“RESTful”的词玩和组合。
Requires
- beberlei/doctrineextensions: ^1.0.11
- doctrine/orm: ^2.5
- nikic/php-parser: ^3.0
- symfony/validator: ^2.8
Requires (Dev)
- phpunit/phpunit: 4.8
README
Questful是一个接口和元数据工具,提供HTTP查询与RESTful API之间合理的链接。名称"Questful"是“Query”(来自HTTP查询)和“RESTful”的词玩和组合。
简介
RESTful API在互联网上得到广泛应用。虽然关于如何构建HTTP请求和响应已达成共识,但在处理多个结果(列表)的过滤和排序方面仍然存在分歧。
Questful旨在通过提供严格、可管理和安全的方式来应用过滤和排序,从HTTP请求到从存储单元(例如数据库)提取数据,并在最终的HTTP响应中提供数据和元数据,从而缩小这一差距。
目的
Questful的主要目标是让开发者能够快速管理和实现Web应用中的过滤和排序选项,以便他们可以将精力和技能集中在制作优秀应用上,而不是重复实现琐碎的程序。
免责声明
这是一个兴趣项目,仍处于起步阶段。使用Questful存在风险,请参阅LICENSE文件。
快速示例
在Questful中,查找所有名为"Homer"的用户,然后按姓名(字母数字顺序)排序,就像这样简单:
GET /user?filter[]=name=%"Homer"%&sort[]=name
用于捕获和处理上述请求的PHP代码
<?php use Kafoso\Questful\Exception\BadRequestException; use Kafoso\Questful\Factory\Model\QueryParser\QueryParserFactory; use Kafoso\Questful\Model\Bridge\PdoMySql\PdoMySql5_5; use Kafoso\Questful\Model\Mapping; use Kafoso\Questful\Model\Mapping\Allowable; $queryParserFactory = new QueryParserFactory; try { $queryParser = $queryParserFactory->createFromUri($_SERVER['REQUEST_URI']); $queryParser->parse(); // Captures malformed expressions; throws exceptions $mapping = new Mapping($queryParser); $mapping ->relate('name', 'u.name') // Allow this relation ->allow(new Allowable\Filter\AllowedLikeFilter('name')) // Allow a LIKE filter match ->allow(new Allowable\AllowedSort('name')) // Allow this sorting match ->validate(); // Validates input values (queryParser) vs allowed; throws exceptions $pdoMySql = new PdoMySql5_5($mapping); $pdoMySql->generate(); // Generates SQL and parameters; throws exceptions $pdo = \PDO::getInstance(); // Some fully configured PDO instance $stmt = $pdo->prepare( "SELECT u.* FROM User u WHERE {$pdoMySql->getWhere()} ORDER BY {$pdoMySql->getOrderBy()};" ); $stmt->execute($pdoMySql->getParameters()); $json = json_encode($stmt->fetchAll(\PDO::FETCH_ASSOC)); header("HTTP/1.0 200 OK"); echo $json; } catch (BadRequestException $e) { header("HTTP/1.0 400 Bad Request"); throw $e; } catch (\Exception $e) { header("HTTP/1.0 500 Internal Server Error"); throw $e; }
要求
- PHP 5.6及以上。
推荐
- MySQL 5.5及以上。
- Doctrine 2.5及以上。
- Sqlite3。
过滤
过滤器(或条件)允许从存储单元中提取数据子集。
?filter[]
在Questful中,过滤器必须是数组,因此HTTP查询必须指定为: ?filter[]=foo="bar"
。此过滤器索引为0
(零),相当于:?filter[0]=foo="bar"
。索引可以自由选择,但必须是整数且唯一。非唯一索引将只应用最后定义的过滤器(具有非唯一索引)。
索引与过滤表达式一起使用,将在下文进一步解释。
如果提供非整数(例如 filter[a]=foo="bar"
)或负整数(例如 filter[-1]=foo="bar"
)作为索引,将抛出Kafoso\Questful\Exception\BadRequestException
。
注意:名称"filter"是单数。
为什么在"foo"之后有一个等号
=
?
这是因为foo
是键,而"bar"
是值;即“键值”对。所有内容都应该在发送到API之前进行URL编码,使其看起来像?filter[]=foo%3D%22bar%22
。这看起来不太美观——或者不太易读——所以为了简单起见,在大多数示例中我们坚持使用未编码的字符串。
在 foo
后的等号只是一个几个运算符之一;详情见下文。
为什么
"bar"
被双引号包围?
这是因为我们特别希望定位到数据类型 "字符串"。
语法
?filter[]=<key><operator><value>
,其中
筛选类型
Questful 处理以下类型
- 空值(
null
) - 标量值,即
boolean
(true
或false
)浮点数
整数
字符串
- 子字符串(
LIKE
) - 数组(
IN
)
注意:请谨慎使用 LIKE
。这种搜索类型与拒绝服务攻击的风险有关2。只有在你绝对确定你知道自己在做什么的情况下,才允许这种搜索类型。如果为少量用户(例如超级用户或管理员)提供丰富的搜索功能时,这可能会很有用。
作为安全措施,所有筛选和排序都必须映射。
筛选值必须评估为 PHP 语法(不包括修饰符)。
空值
必须为空或明确写出为 null
。
接受的操作符
=
!=
示例
?filter[]=foo=
(空)?filter[]=foo=null
布尔值
必须明确写出为 "true" 或 "false",而不是 1
或 0
;Questful 将这些字符视为整数。
接受的操作符
=
示例
?filter[]=foo=true
?filter[]=foo=false
浮点数
必须包含小数点 .
和至少1位小数的精度。可以提供一个可选的负号。仅指定 1
会导致 Questful 将值解释为整数。
接受的操作符
=
!=
<
<=
>
>=
示例
?filter[]=foo=1.0
?filter[]=foo<3.14
?filter[]=foo=-273.15
整数
数字。可以有一个可选的负号。
接受的操作符
=
!=
<
<=
>
>=
示例
?filter[]=foo=1
?filter[]=foo=42
?filter[]=foo=-512
字符串
字符串必须用双引号 ""
包围。双引号使字符串与其它数据类型(例如,当整数实际上是字符串,如 "42"
)区分开来。
值部分,即双引号及其内容,必须评估为 PHP 语法。自然地,不支持连接变量(例如 "$a"
),它们将被视为字符串的一部分。字符串中的双引号必须由反斜杠转义,例如 "\""
。
不支持单引号,并且会导致抛出\Kafoso\Questful\Exception\BadRequestException
。
接受的操作符
=
!=
<
<=
>
>=
修饰符
/i
使搜索不区分大小写。
示例
?filter[]=foo="bar"
?filter[]=foo="42"
?filter[]=foo="bar"/i
?filter[]=foo="foo \"bar\""
子字符串(LIKE
)
术语 LIKE
是从 SQL 借用的,并且它的工作方式相同:在其它字符串中查找子字符串,通常在 SQL 数据库表中的列中。
适用于字符串的相同规则也适用,即值部分必须评估为 PHP 语法,且仅支持双引号。
虽然语法与SQL略有不同。虽然Questful和SQL中都使用百分号%
,并且可以作为通配符在左侧、右侧或同时使用,但在Questful中,该符号位于引号之外。也就是说,%"bar"%
,而不是"%bar%"
,因为SQL中的语法是这样的。
但为什么语法不同呢?
因为我们希望在客户端无需转义和依赖的情况下,可靠地执行通配符搜索。
示例
考虑以下表
在“percentage”列中,“10%”将被此SQL语句匹配是正确的
SELECT * FROM table WHERE percentage LIKE "10%";
但“100%”也会匹配,因为%
是一个通配符字符,可以匹配“100%”中的0%
。这不是我们想要的。因此,Questful中百分号放在引号之外。这使得上述查询变为
SELECT * FROM table WHERE percentage LIKE "10\%%" ESCAPE '\\';
Questful将确保在MySQL中正确转义符号%
和_
。
接受的操作符
=
!=
修饰符
/i
使搜索不区分大小写。
修饰符紧跟在最后一个双引号或百分号之后。
示例
?filter[]=foo=%"bar"
?filter[]=foo="bar"%
?filter[]=foo=%"bar"%
?filter[]=foo="100%"%
?filter[]=foo="bar"%/i
数组(IN
)
术语IN
- 就像LIKE
一样 - 是从SQL借用的。它允许在同一个键(或列)中查找多个值。
数组搜索是编写一系列or
语句的更易管理的替代方案(请参阅下方的过滤表达式)。
数组过滤器用方括号[]
包围 - 而不是圆括号(如MySQL中的情况) - 并且值必须以逗号分隔。
方括号中的内容必须评估为PHP语法。否则,会抛出\Kafoso\Questful\Exception\BadRequestException
。
Questful尊重严格的null
比较,使得?filter[]=t.foo=[null,"foo",42]
变为(在(PDO)MySQL语法中)(t.foo IN (:filter_0_1, :filter_0_2) OR t.foo IS NULL)
。对于某些桥接器,布尔值从true
和false
转换为1
和0
。
在查询存储单元之前对数组进行优化,通过严格的比较(===
)删除冗余值,例如将[42,"42",42]
变为[42,"42"]
。
语法
?filter[]=<key><operator>[<value_1>, <value_2>, ... , <value_n>]/<modifiers>
,其中
<key>
是映射键。<operator>
是接受的运算符。<value_1>, <value_2>, ... , <value_n>
是受支持并映射的数据类型的值。<modifiers>
是可选的,并且仅接受修饰符i
,使字符串匹配不区分大小写。
接受的操作符
=
!=
支持的数据类型
- 空值
必须写成“null”。例如,[null]
。 - 布尔型
必须写成“true”或“false”。例如,[true]
或[false]
。 - 浮点型
可以有一个可选的负号在开头,然后必须只包含数字和一个单独的点。至少有一位小数的精度是强制性的。例如,[1.0]
或[3.14]
。 - 整型
可以有一个可选的负号在开头,然后必须只包含数字。例如,[1]
或[42]
。 - 字符串
必须用双引号""
括起来,不接受修饰符。例如,["foo"]
或[""]
。对于字符串,任何字符串内的双引号必须由反斜杠转义,例如["\""]
。
语法(如你所注意到的)类似 - 但不一定相同 - 于之前描述的每种数据类型的语法。
修饰符
/i
使搜索字符串值(仅限字符串)不区分大小写。
修饰符附加在最后一个方括号之后,例如 ["foo", "BAR"]/i
,并应用于数组中的所有元素。例如,所有字符串都变为不区分大小写。
示例
?filter[]=foo=[null]
?filter[]=foo=[true,false]
(无意义)?filter[]=foo=[null,"foo"]
?filter[]=foo=[42,-42]
?filter[]=foo=[3.14,-3.14]
?filter[]=foo=["foo","BAR"]/i
运算符
支持的运算符包括
=
(等于)!=
(不等于)>
(大于)>=
(大于等于)<
(小于)<=
(小于等于)
但是,某些运算符不能与某些过滤选项一起使用
字符串 接受 >
、>=
、<
、<=
,保留对某些值进行比较的需要。例如日期。
在 映射 中,您可以进一步 限制接受的运算符,例如,使得整数只接受 =
。然而,指定的运算符必须是相应 过滤类型 可用的运算符之一。否则会抛出 \Kafoso\Questful\Exception\UnexpectedValueException
异常。
?filterExpression
默认情况下,所有过滤器都使用逻辑运算符 AND
连接。即只返回满足所有条件的数据集。但是,filterExpression
选项允许以可读和可管理的方式控制过滤器的连接。
接受的标记
- 括号(
(
和)
)用于声明优先级。 - 逻辑运算符
和
或
xor
(排它或。一个或另一个必须为真,但不能同时为真。)
- 数字(0-9),代表
filter
选项中的索引。
表达式必须评估为 PHP 语法。即标记的位置,例如匹配和正确关闭括号,是强制性的。否则会抛出 \Kafoso\Questful\Exception\BadRequestException
异常。
filter[]
和 filterExpression
中的索引必须匹配。如果出现不匹配,将抛出 \Kafoso\Questful\Exception\BadRequestException
异常。
示例
URL
?filter[]=foo="bar"&filter[]=foo="baz"&filterExpression=(0or1)
结果过滤表达式
(foo = :filter_0 OR foo = :filter_1)
优先级
在 and
、or
和 xor
之间的优先级可能因存储单元而异。甚至 PHP 语言本身 在运算符 &&
和 ||
、运算符 and
和 or
之间也存在差异。
因此,Questful 中的所有表达式都被标准化,并强制执行严格的优先级。这意味着 - 除非有意提供括号 - 表达式如 0or1and2
将被包裹在优先级括号中,变为 0or(1and2)
。然而,您可以自己指定括号的位置,使表达式如 (0or1)and2
保持不变。
通过强制优先级,不同的存储单元应返回相同的结果。
优先级示例
排序
排序语法比过滤机制的语法简单得多。尽管,一些相同的逻辑也得以延续。
?sort[]=foo
也是一个数组,索引可以像过滤器那样提供。即?sort[]=foo
与?sort[0]=foo
等价。
索引的顺序是重要的。优先级较低的是从零开始的。即在?sort[0]=foo&sort[1]=bar
中,先对foo
进行排序,然后对bar
进行排序。
负索引(例如?sort[-1]=foo
)将抛出Kafoso\Questful\Exception\BadRequestException
。
语法
?sort[]=<key>
?sort[<index>]=<key>
?sort[]=<direction><key>
?sort[]=<key>/<modifier>
?sort[]=<direction><key>/<modifier>
?sort[<index>]=<direction><key>/<modifier>
,其中
<index>
是一个正的唯一整数。可选。<direction>
是空或"+"(加号),表示按升序排序,或"-"(减号),表示按降序排序。可选。详情见下文。<key>
是任何(白名单)排序键。必需。<modifier>
是一个排序修饰符。可选。
方向
默认情况下,排序按升序进行。但是,可以通过在目标列名后附加"-"(减号)来更改此顺序,例如?sort[]=-foo
。出于可管理和美观的原因,可以同时提供"+
"(升序)和"-
"(降序)。
示例
?sort[]=foo
?sort[]=+foo
?sort[]=-foo
修饰符
修饰符包括
/i
不区分大小写的字母。
示例
?sort[]=foo/i
?sort[]=-foo/i
字母大小写和非英语字符由您的存储单元处理。这可能会导致结果以不理想的方式排序。
一个现实世界的例子:丹麦的特殊字母"Ø"通常被解释为"O"。这是完全错误的,因为在丹麦,我们总共有三个特殊字母 - "Æ"、"Ø"和"Å" - 添加到正常的英语字母表中。即"..."XYZÆØÅ"。因此,匹配"Ø"的结果必须排在"ZÆ"之后,而不是与"O"并列。
Questful允许您在不区分大小写和二进制安全的情况下(例如使用UTF-8)排序,同时尊重特殊字符。
映射和验证
类Kafoso\Questful\Model\Mapping
在通过HTTP请求提供的键与存储中的相应键/列之间创建映射,例如数据库中的列。此映射确保防止注入攻击并减少对应用程序和存储单元基础设施信息的了解。
使用relate
方法将过滤器或排序键与列相关联。如果您希望,可以在客户端随意混淆键的命名,只要它们在服务器端正确映射到表和列。
使用allow
方法允许特定的过滤器、过滤器表达式和/或排序。
使用示例
<?php use Kafoso\Questful\Factory\Model\QueryParser\QueryParserFactory; use Kafoso\Questful\Model\Mapping; use Kafoso\Questful\Model\Mapping\Allowable; $queryParserFactory = new QueryParserFactory; $queryParser = $queryParserFactory->createFromUri('?filter[]=foo="bar"'); $queryParser->parse(); $mapping = new Mapping($queryParser); $mapping ->relate('foo', 't.foo') ->allow(new Allowable\Filter\AllowedStringFilter('foo')) ->validate(); // All is good - "foo" is allowed
在上面的示例中,如果发生过滤器或排序不匹配,$mapping->validate()
将抛出Kafoso\Questful\Exception\BadRequestException
。调用validate
是可选的。然而,未映射的过滤器将在桥接逻辑中不予考虑。
限制操作符
操作符作为第二个参数在 \Kafoso\Questful\Model\Mapping\Allowable\Filter\AbstractAllowedFilter
中提供,它接受 null
或一个非空字符串数组。如前所述,操作符必须是相应 过滤器类型 中的一个可用操作符。否则会抛出 \Kafoso\Questful\Exception\UnexpectedValueException
异常。
请参阅 006-restrict-operators 以获得实际示例。
示例
<?php use Kafoso\Questful\Model\Mapping\Allowable; $mapping->allow(new Allowable\Filter\AllowedStringFilter("id", ["="]));
验证器
验证器用于限制输入数据。
利用 Symfony 的验证包 来实现此目的。除了是一个可靠的库外,如果您愿意,还可以定义自己的验证错误消息。
有关支持约束的列表,请参阅: https://symfony.com.cn/doc/current/reference/constraints.html
一些常见用例包括
- 字符串的最小和最大长度(
Length
)。 - 允许或禁止字符串中的某些字符(
Regex
)。 - 期望字符串具有特定的模式,例如特定的日期格式(
Regex
和Callback
)。 - 期望整数在某个范围内(
Range
)。 - 期望整数是一个精确值,例如用户的 ID(
EqualTo
)。
验证器作为 \Kafoso\Questful\Model\Mapping\Allowable\Filter\AbstractAllowedFilter
中的第三个参数提供,它接受 null
或一个 \Symfony\Component\Validator\Constraint
数组。
在调用 \Kafoso\Questful\Model\Mapping->validate()
时应用所有验证器。如果发生违规,则抛出 Kafoso\Questful\Exception\BadRequestException
异常。
示例
<?php use Kafoso\Questful\Model\Mapping\Allowable; use Symfony\Component\Validator\Constraints as Assert; $mapping->allow(new Allowable\Filter\AllowedStringFilter("id", null, [ new Assert\GreaterThan(1) ]));
与存储单元桥接
Questful 中的桥接意味着将 HTTP 请求的查询值转换为存储单元可读的格式。并且以安全的方式进行。
桥接(例如 Kafoso\Questful\Model\Bridge\PdoMysql
)输出一系列条件、参数和排序,这些可以由存储单元消费。对于使用 PDO 的 MySQL,生成一系列 WHERE
和 ORDER BY
条件作为 SQL 字符串,以及一个参数数组,这些参数必须在准备好的语句中提供。
桥接的字符编码默认为 "UTF-8",但可以更改。
使用示例
<?php use Kafoso\Questful\Factory\Model\QueryParser\QueryParserFactory; use Kafoso\Questful\Model\Bridge\PdoMySql\PdoMySql5_5; use Kafoso\Questful\Model\Mapping; use Kafoso\Questful\Model\Mapping\Allowable; $queryParserFactory = new QueryParserFactory; $queryParser = $queryParserFactory->createFromUri('?filter[42]=foo="bar"'); $queryParser->parse(); $mapping = new Mapping($queryParser); $mapping ->relate('foo', 't.foo') ->allow(new Allowable\Filter\AllowedStringFilter("foo")); $pdoMySql = new PdoMySql5_5($mapping); $pdoMySql->generate(); var_dump($pdoMySql->toArray()); /** * Will output: * * array(3) { * ["orderBy"]=> * NULL * ["parameters"]=> * array(1) { * ["filter_42"]=> * string(3) "bar" * } * ["where"]=> * string(27) "(t.foo = BINARY :filter_42)" * } */
注意索引 42 如何继续并成为参数标识符 filter_42
的一部分。这些索引可以安全使用(即没有注入风险),因为它们被强制转换为整数。非数字或负索引会抛出 Kafoso\Questful\Exception\BadRequestException
。
您可以在 SQL 语句中自由添加额外的条件和排序。注意上面 where
的内容总是包含在括号中。
桥接类型
现成的桥接类型有
-
Kafoso\Questful\Model\Bridge\Doctrine\Doctrine2_5
与Doctrine\ORM\QueryBuilder
一起使用(http://www.doctrine-project.org/)。Doctrine 版本 2.5 及以上。
需要处理BINARY
。您可以自行实现这些,或者简单地使用 https://github.com/beberlei/DoctrineExtensions。 -
Kafoso\Questful\Model\Bridge\PdoMysql\PdoMysql5_5
用于 PDO MySQL 5.5 及以上版本(https://php.ac.cn/manual/en/book.pdo.php)。 -
Kafoso\Questful\Model\Bridge\PdoSqlite\PdoSqlite3
用于 PDO Sqlite3(https://php.ac.cn/manual/en/book.sqlite3.php)。
需要处理XOR
。您可以自行实现,或者简单地使用Kafoso\Questful\Helper\Sqlite3Helper
。注意XOR
是一个函数,而不是一个运算符,并且是XOR(0, 1)
。
您可以通过扩展 Kafoso\Questful\Model\Bridge\AbstractBridge
来实现自己的桥接。
错误和异常
使用了一些自定义异常
Kafoso\Questful\Exception\BadRequestException
当客户端输入不正确、越界或尝试针对未映射的内容时抛出。此异常与 HTTP 状态码 "400 Bad Request" 一起使用。Kafoso\Questful\Exception\InvalidArgumentException
与原生InvalidArgumentException
的用法相同。Kafoso\Questful\Exception\RuntimeException
与原生RuntimeException
的用法相同。用于在此库的实现中处理编程上的疏忽。Kafoso\Questful\Exception\UnexpectedValueException
与原生UnexpectedValueException
的用法相同。
异常代码
除了上述异常类之外,以下异常代码在每个以下命名空间域中一致使用
这种简洁的格式允许外部代码(即不是此库一部分的代码)更准确地解释和传递消息。
也许开发者希望在将消息返回给客户端之前覆盖消息(例如将其翻译成另一种语言),或者根据异常代码更改状态码(例如将状态码更改为 404 Not Found)。这些选项都是可用的。
示例
- 基本过滤和排序
examples/001-basic-filter-and-sorting.php
Questful 的基本用法。说明了如何获取一个简单的查询,解析它、验证它并生成元数据。 - 生成 MySQL 参数化查询
examples/002-generating-a-mysql-parameterized-query.php
说明了如何生成可以直接由 (PDO) MySQL 消耗的参数化 SQL 语句。 - 使用过滤表达式
examples/003-using-filter-expression.php
展示了如何应用简单的过滤表达式(filterExpression
)。选择所有在 10 和 99 之间的id
。注意过滤的顺序;由于过滤表达式是1and0
,因此filter_1
在filter_0
之前出现。 - 针对连接的 SQL 表
examples/004-targeting-joined-sql-tables.php
定位联合表与定位主表(FROM
)并无区别。在本例中,我们将提取所有在某个时间点之后创建的用户(u.timeCreated
),这些用户属于某个公司(c.id
);即联合表。请注意,为了可读性,将c.id
的命名保留在URL中。您可以随意命名,只需记住使用relate
方法更新关系。 - 条件映射
examples/005-conditional-mapping.php
您可能需要授予某些用户特殊的映射权限,例如基于ACL。本例说明了如何在某些用户中提供映射,同时限制其他用户的映射。 - 限制操作符
examples/006-restrict-operators.php
说明如何在过滤器中仅允许子集操作符。 - 验证器
examples/007-validators.php
说明如何将一系列验证器应用于映射的过滤器。 - SQLite3示例
examples/008-sqlite3.php
使用实际数据库(SQLite3)的示例。需要PHP中启用sqlite3扩展。 - 数组示例
examples/009-in-array.php
使用数组(IN
)在SQLite3数据库中查找项的示例。需要PHP中启用sqlite3扩展。
想法和计划
- 扩展可用的桥梁数量。
- 允许自定义HTTP参数键。例如,使它们可更改,而不是
?filter
、?filterExpression
和?sort
的静态值。 - 在整个应用程序中使用唯一的异常代码,允许根据异常代码执行唯一操作,例如翻译(到另一种语言)并转发异常消息。
- 允许常见的
AbstractAllowedFilter
配置,其中操作符和验证器被存储并自动应用。如果希望始终将字符串长度限制为最大512个字符,则非常有用。 - (重新)引入对正则表达式的支持,例如
?filter[]=foo=/^foo\d+$/i
。这带来了许多安全问题,以及如POSIX 1003.2(由MySQL使用)与PCRE之间的差异等编程挑战。
脚注
1 存储单元:任何持久化数据的方式,包括数据库、缓存、文件系统存储等。
2 (分布式)拒绝服务(DDoS)攻击有多种形式。就本库而言,此类DDoS攻击可能是由数据库中的长时间运行查询引起的。例如,使用正则表达式在数据库中搜索内容是非常昂贵的,并可能导致长时间的加载时间。