spawnia/

sailor

PHP的强类型GraphQL客户端

资助包维护!
spawnia

v0.33.0 2024-08-20 10:10 UTC

README

sailor-logo"

CI Status codecov

Latest Stable Version Total Downloads

PHP的强类型GraphQL客户端

动机

GraphQL通过服务器通过内省提供的模式定义提供强类型API访问。Sailor利用这些信息来启用人性化的工作流程并减少代码中的类型相关错误。

本机GraphQL查询语言是最广泛使用的工具,用于构建GraphQL查询,并且与整个GraphQL工具生态系统本机兼容。Sailor将您编写的纯查询转换为可执行的PHP代码,使用服务器模式生成强类型操作和结果。

安装

通过运行以下命令通过composer安装Sailor:

composer require spawnia/sailor

如果您想使用内置的默认客户端(请参阅客户端实现

composer require guzzlehttp/guzzle

如果您想使用PSR-18客户端并且没有PSR-17请求和流工厂实现(请参阅客户端实现

composer require nyholm/psr7

配置

运行vendor/bin/sailor来设置配置。项目根目录将创建一个名为sailor.php的文件。

期望Sailor配置文件返回一个关联数组,其中键是端点名称,值是Spawnia\Sailor\EndpointConfig的实例。

您可以查看示例配置以了解配置时有哪些选项:sailor.php

如果您想使用多个配置文件,请通过-c/--config选项指定要使用的文件。

在配置中包含动态值非常有用。您可能需要使用PHP dotenv来加载环境变量(如果您还没有安装,请运行composer require vlucas/phpdotenv)。

# sailor.php
+$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
+$dotenv->load();

...
        public function makeClient(): Client
        {
            return new \Spawnia\Sailor\Client\Guzzle(
-               'https://hardcoded.url',
+               getenv('EXAMPLE_API_URL'),
                [
                    'headers' => [
-                       'Authorization' => 'hardcoded-api-token',
+                       'Authorization' => getenv('EXAMPLE_API_TOKEN'),
                    ],
                ]
            );
        }

客户端实现

Sailor提供了一些内置客户端

  • Spawnia\Sailor\Client\Guzzle:默认HTTP客户端
  • Spawnia\Sailor\Client\Psr18:PSR-18 HTTP客户端
  • Spawnia\Sailor\Client\Log:用于测试

您可以通过实现接口Spawnia\Sailor\Client来提供自己的客户端。

动态客户端

您可以针对特定操作或每个请求动态配置客户端

use Example\Api\Operations\HelloSailor;

/** @var \Spawnia\Sailor\Client $client Somehow instantiated dynamically */

HelloSailor::setClient($client);

// Will use $client over the client from EndpointConfig
$result = HelloSailor::execute();

// Reverts to using the client from EndpointConfig
HelloSailor::setClient(null);

自定义类型

自定义标量通常序列化为字符串,但也可能使用其他表示形式。由于不知道类型的内容,Sailor无法进行任何转换或提供更精确的类型提示,因此它使用mixed

枚举从PHP 8.1开始支持。许多项目仅使用标量值或通过某种类型的值类实现的枚举近似。Sailor不持任何观点,并将枚举生成为一个具有字符串常量的类,而不进行转换 - 这很有用但并不完美。为了获得更好的体验,建议自定义枚举生成/转换。

重写EndpointConfig::configureTypes()以专门化Sailor如何处理模式中的类型。请参阅examples/custom-types

错误转换

GraphQL响应中发送的错误必须遵循响应错误规范。Sailor默认将解码JSON响应得到的普通stdClass转换为\Spawnia\Sailor\Error\Error实例。

如果你的端点返回包含在extensions中的结构化数据,你可以通过覆盖EndpointConfig::parseError()来自定义将普通错误解码为类实例的方式。

使用方法

内省

运行vendor/bin/sailor introspect,通过执行内省查询来更新你的模式,使其包含来自服务器的最新更改。例如,一个非常简单的服务器可能会在您的项目中放置以下文件

# schema.graphql
type Query {
  hello(name: String): String
}

定义操作

将您的查询和突变放入.graphql文件中,并将它们放在配置的项目目录中的任何位置。您可以根据需要命名和结构化文件。让我们查询上面的示例模式

# src/example.graphql
query HelloSailor {
  hello(name: "Sailor")
}

您必须为所有操作提供唯一的PascalCase名称,以下示例是无效的

# Invalid, operations have to be named
query {
  anonymous
}

# Invalid, names must be unique across all operations
query Foo { ... }
mutation Foo { ... }

# Invalid, names must be PascalCase
query camelCase { ... }

生成代码

运行vendor/bin/sailor以生成操作对应的PHP代码。对于上面的示例,Sailor将生成一个名为HelloSailor的类,将其放置在配置的命名空间中,并将其写入配置的位置。

namespace Example\Api\Operations;

class HelloSailor extends \Spawnia\Sailor\Operation { ... }

还有其他生成的类,它们表示调用操作的结果。来自服务器的普通数据被封装并包含在这些值类中,因此您可以以类型安全的方式访问它们。

执行查询

您现在可以设置运行针对服务器的查询,只需调用新查询类的execute()函数

$result = \Example\Api\Operations\HelloSailor::execute();

返回的$result将是一个扩展\Spawnia\Sailor\Result的类,并包含来自服务器的解码响应。您可以从中直接获取$data$errors$extensions

$result->data        // `null` or a generated subclass of `\Spawnia\Sailor\ObjectLike`
$result->errors      // `null` or a list of `\Spawnia\Sailor\Error\Error`
$result->extensions  // `null` or an arbitrary map

错误处理

您可以通过查询确保返回了适当的数据且不包含错误

$errorFreeResult = \Example\Api\Operations\HelloSailor::execute()
    ->errorFree(); // Throws if there are errors or returns an error free result

如果您不需要任何数据,但想要确保突变成功

\Example\Api\Operations\SomeMutation::execute()
    ->assertErrorFree(); // Throws if there are errors

带有参数的查询

您生成的操作类将带有查询定义的参数注解。

class HelloSailor extends \Spawnia\Sailor\Operation
{
    public static function execute(string $required, ?\Example\Api\Types\SomeInput $input = null): HelloSailor\HelloSailorResult { ... }
}

输入可以逐步构建

$input = new \Example\Api\Types\SomeInput;
$input->foo = 'bar';

如果您正在使用PHP 8,使用命名参数进行实例化非常有用,以确保您的输入完全填充

\Example\Api\Types\SomeInput::make(foo: 'bar')

部分输入

GraphQL通常使用部分输入模式——相当于HTTP的PATCH。考虑以下输入

input SomeInput {
  requiredID: Int!,
  firstOptional: Int,
  secondOptional: Int,
}

假设我们使用以下实现允许在PHP中进行实例化

class SomeInput extends \Spawnia\Sailor\ObjectLike
{
    public static function make(
        int $requiredID,
        ?int $firstOptional = null,
        ?int $secondOptional = null,
    ): self {
        $instance = new self;

        $instance->requiredID = $required;
        $instance->firstOptional = $firstOptional;
        $instance->secondOptional = $secondOptional;

        return $instance;
    }
}

给定此实现,以下调用将产生以下JSON负载

SomeInput::make(requiredID: 1, secondOptional: 2);
{ "requiredID": 1, "firstOptional": null, "secondOptional": 2 }

但是,我们希望产生以下JSON负载

{ "requiredID": 1, "secondOptional": 2 }

这是因为从make()内部来看,无法区分显式传递的可选命名参数和已分配默认值的参数。因此,产生的JSON负载无意中也会修改firstOptional,擦除它之前持有的任何值。

对此的一个简单解决方案是过滤掉所有null参数。但是,我们也希望能够显式地将第一个可选值设置为null。以下调用应该产生包含"firstOptional": null的JSON负载。

SomeInput::make(requiredID: 1, firstOptional: null, secondOptional: 2);

为了默认生成部分输入,可选命名参数具有特殊的默认值

Spawnia\Sailor\ObjectLike::UNDEFINED = 'Special default value that allows Sailor to differentiate between explicitly passing null and not passing a value at all.';
class SomeInput extends \Spawnia\Sailor\ObjectLike
{
    /**
     * @param int $requiredID
     * @param int|null $firstOptional
     * @param int|null $secondOptional
     */
    public static function make(
        $requiredID,
        $firstOptional = 'Special default value that allows Sailor to differentiate between explicitly passing null and not passing a value at all.',
        $secondOptional = 'Special default value that allows Sailor to differentiate between explicitly passing null and not passing a value at all.',
    ): self {
        $instance = new self;

        if ($requiredID !== self::UNDEFINED) {
            $instance->requiredID = $requiredID;
        }
        if ($firstOptional !== self::UNDEFINED) {
            $instance->firstOptional = $firstOptional;
        }
        if ($secondOptional !== self::UNDEFINED) {
            $instance->secondOptional = $secondOptional;
        }

        return $instance;
    }
}

您可以使用Spawnia\Sailor\ObjectLike::UNDEFINED来完全省略可空参数

SomeInput::make(
    requiredID: 1,
    firstOptional: $maybeNull ?? Spawnia\Sailor\ObjectLike::UNDEFINED,
);

如果$maybeNullnull,这将产生以下JSON负载

{ "requiredID": 1 }

在极不可能需要传递Spawnia\Sailor\ObjectLike::UNDEFINED的精确值的情况下,您可以直接在make()中绕过逻辑并直接分配它

$input = SomeInput::make(requiredID: 1);
$input->secondOptional = Spawnia\Sailor\ObjectLike::UNDEFINED;

事件

Sailor在执行生命周期中调用EndpointConfig::handleEvent()来处理以下事件

  1. StartRequest:在调用execute()操作后触发,在调用客户端之前。
  2. ReceiveResponse:在接收到客户端的 GraphQL 响应后触发。

PHP 关键字冲突

由于 GraphQL 使用一组不同的保留关键字,字段或类型的名称可能与 PHP 关键字冲突。Sailor 通过在名称前加单个下划线 _ 来防止在生成的代码中非法使用这些名称。

测试

Sailor 通过允许您模拟操作来提供对测试的一级支持。

设置

假设您正在使用 PHPUnitMockery

composer require --dev phpunit/phpunit mockery/mockery

请确保您的测试类 - 或其父类之一 - 使用以下特质

use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
use Spawnia\Sailor\Testing\UsesSailorMocks;

abstract class TestCase extends PHPUnitTestCase
{
    use MockeryPHPUnitIntegration;
    use UsesSailorMocks;
}

否则,mocks 不会在测试方法之间重置,您可能会遇到非常令人困惑的错误。

Mock 结果

mocks 按操作类进行注册

use Example\Api\Operations\HelloSailor;

/** @var \Mockery\MockInterface&HelloSailor */
$mock = HelloSailor::mock();

注册时,mock 会捕获对 HelloSailor::execute() 的所有调用。使用它来建立对它应接收的调用的期望并模拟返回的结果

$hello = 'Hello, Sailor!';

$mock
    ->expects('execute')
    ->once()
    ->with('Sailor')
    ->andReturn(HelloSailor\HelloSailorResult::fromData(
        HelloSailor\HelloSailor::make($hello),
    ));

$result = HelloSailor::execute('Sailor')->errorFree();

self::assertSame($hello, $result->data->hello);

后续对 ::mock() 的调用将返回最初注册的 mock 实例。

$mock1 = HelloSailor::mock();
$mock2 = HelloSailor::mock();
assert($mock1 === $mock2); // true

您还可以模拟带有错误的结果

HelloSailor\HelloSailorResult::fromErrors([
    (object) [
        'message' => 'Something went wrong',
    ],
]);

对于 PHP 8 用户,建议使用命名参数来构建复杂的模拟结果

HelloSailor\HelloSailorResult::fromData(
    HelloSailor\HelloSailor::make(
        hello: 'Hello, Sailor!',
        nested: HelloSailor\HelloSailor\Nested::make(
            hello: 'Hello again!',
        ),
    ),
))

集成

如果您想对一个使用 Sailor 的服务进行集成测试,但不想实际调用外部 API,您可以将客户端替换为 Log 客户端。它将所有通过 Sailor 发出的请求写入您选择的文件。

由于 Log 客户端不知道给定请求的有效响应是什么,因此它总是返回错误。

# sailor.php
public function makeClient(): Client
{
    return new \Spawnia\Sailor\Client\Log(__DIR__ . '/sailor-requests.log');
}

每个请求都在新的一行,并包含一个 JSON 字符串,其中包含 queryvariables

{"query":"{ foo }","variables":{"bar":42}}
{"query":"mutation { baz }","variables":null}

这允许您对发出的调用进行断言。 Log 客户端提供了一个方便的方法来以结构化数据读取请求

$log = new \Spawnia\Sailor\Client\Log(__DIR__ . '/sailor-requests.log');
foreach ($log->requests() as $request) {
    var_dump($request);
}

array(2) {
  ["query"]=>
  string(7) "{ foo }"
  ["variables"]=>
  array(1) {
    ["bar"]=>
    int(42)
  }
}
array(2) {
  ["query"]=>
  string(7) "mutation { baz }"
  ["variables"]=>
  NULL
}

在执行测试后清理日志,请使用 Log::clear()

示例

您可以在 examples 中找到如何使用 Sailor 的项目示例。

变更日志

请参阅 CHANGELOG.md

贡献

请参阅 CONTRIBUTING.md

许可

此软件包使用 MIT 许可证授权。