Woohoo Labs. Yin

5.0.0-alpha1 2024-02-06 15:39 UTC

README

Latest Version on Packagist Software License Build Status Coverage Status Quality Score Total Downloads Gitter

Woohoo Labs. Yin 是一个 PHP 框架,可以帮助您构建精美的 JSON:API。

目录

介绍

JSON:API 规范于 2015 年 5 月 29 日达到 1.0 版本,我们认为这将是 RESTful API 的一大里程碑,因为这个规范可以帮助您构建更健壮和未来化的 API。Woohoo Labs. Yin(以阴阳命名)旨在为您的 JSON:API 服务器带来效率和优雅,而 Woohoo Labs. Yang 则是其客户端对应物。

特性

  • 100% PSR-7 兼容性
  • 99% JSON:API 1.1 兼容性(大约)
  • 为效率和易用性而开发
  • 详细的文档和示例
  • 提供用于获取资源的 Documents 和 Transformers
  • 提供用于创建和更新资源的 Hydrators
  • 提供额外的中间件以简化启动和调试

为什么选择 Yin?

完整的 JSON:API 框架

Woohoo Labs. Yin 是一个无框架库,支持 JSON:API 1.1 规范的大部分内容:它提供包括内容协商、错误处理和分页等功能,以及获取、创建、更新和删除资源。虽然 Yin 由许多松散耦合的包和类组成,可以单独使用,但整个框架的使用最为强大。

效率

我们设计了 Yin,使其尽可能高效。这就是为什么只有当请求时才会转换属性和关系。当有大量资源需要转换或很少需要的转换非常昂贵时,这个功能非常有优势。此外,由于转换器是无状态的,避免了为每个资源单独创建模型对象的额外开销。此外,由于无状态,整个库与依赖注入配合得非常好。

补充中间件

有一些额外的中间件可能对 Woohoo Labs. Yin 有用。它可以简化各种任务,如错误处理(通过将异常转换为 JSON:API 错误响应)、派发 JSON:API 感知的控制器或调试(通过语法检查和验证请求和响应)。

安装

开始之前,您只需要Composer

安装 PSR-7 实现

由于 Yin 需要 PSR-7 实现(一个提供 psr/http-message-implementation 虚拟包的包),您必须先安装一个。您可以使用 Laminas Diactoros 或任何其他您喜欢的库。

$ composer require laminas/laminas-diactoros

安装 Yin

要安装此库的最新版本,请运行以下命令

$ composer require woohoolabs/yin

注意:默认情况下不会下载测试和示例。如果您需要它们,请使用 composer require woohoolabs/yin --prefer-source 或克隆仓库。

Yin 的最新版本至少需要 PHP 7.1,但您可以使用 Yin 2.0.6 来支持 PHP 7.0。

安装可选依赖项

如果您想利用请求/响应验证,您还必须要求以下依赖项

$ composer require justinrainbow/json-schema
$ composer require seld/jsonlint

基本用法

当使用 Woohoo Labs. Yin 时,您将创建

  • 文档和资源,以便将领域对象映射到 JSON:API 响应
  • Hydrators,以便将 POST 或 PATCH 请求中的资源转换为领域对象

此外,一个负责仪表化的 JsonApi 类,以及一个 PSR-7 兼容的 JsonApiRequest 类,提供您通常需要的功能。

文档

以下部分将指导您创建成功响应的文档以及创建或构建错误文档。

成功响应的文档

对于成功的请求,您必须返回有关一个或多个资源的信息。Woohoo Labs. Yin 提供多个抽象类,帮助您为不同的用例创建自己的文档

  • AbstractSuccessfulDocument:用于成功响应的通用基本文档
  • AbstractSimpleResourceDocument:关于单个非常简单的顶级资源的基类
  • AbstractSingleResourceDocument:关于单个更复杂的顶级资源的基类
  • AbstractCollectionDocument:关于顶级资源集合的基类

由于 AbstractSuccessfulDocument 仅适用于特殊用例(例如,当文档可以包含多种类型的资源时),因此我们在这里不涉及它。

AbstractSimpleResourceDocumentAbstractSingleResourceDocument 类之间的区别在于前者不需要 资源 对象。因此,对于真正简单的领域对象(如消息),您更愿意使用前者,而对于更复杂的领域对象(如用户或地址),后者则工作得更好。

让我们首先快速看一下 AbstractSimpleResourceDocument:它有一个需要实现 getResource() 抽象方法的抽象方法,当您扩展此类时需要实现。该方法返回一个数组,包括类型、id、属性和关系等,如下所示

protected function getResource(): array
{
    return [
        "type"       => "<type>",
        "id"         => "<ID>",
        "attributes" => [
            "key" => "value",
        ],
    ];
}

请注意,AbstractSimpleResourceDocument 不支持一些开箱即用的功能,如稀疏字段集、自动包含相关资源等。这就是为什么这种文档类型只能被视为一种快速且简单的解决方案,在大多数用例中,您通常应选择下面介绍的其他更高级的文档类型。

AbstractSingleResourceDocumentAbstractCollectionDocument 都需要一个 资源 对象才能工作,这是在以下部分介绍的概念。目前,您只需要知道在实例化文档时必须传递一个对象。这意味着您的文档的最小构造函数应如下所示

public function __construct(MyResource $resource)
{
    parent::__construct($resource);
}

当然,您可以为您自己的构造函数提供其他依赖项,或者如果您不需要它,完全可以省略。

当您扩展 AbstractSingleResourceDocumentAbstractCollectionDocument 之一时,它们都要求您实现以下方法

/**
 * Provides information about the "jsonapi" member of the current document.
 *
 * The method returns a new JsonApiObject object if this member should be present or null
 * if it should be omitted from the response.
 */
public function getJsonApi(): ?JsonApiObject
{
    return new JsonApiObject("1.1");
}

描述非常清晰:如果您想在响应中包含 jsonapi 成员,则创建一个新的 JsonApiObject。其构造函数期望 JSON:API 版本号和一个可选的元对象(作为数组)。

/**
 * Provides information about the "meta" member of the current document.
 *
 * The method returns an array of non-standard meta information about the document. If
 * this array is empty, the member won't appear in the response.
 */
public function getMeta(): array
{
    return [
        "page" => [
            "offset" => $this->object->getOffset(),
            "limit" => $this->object->getLimit(),
            "total" => $this->object->getCount(),
        ]
    ];
}

文档还可以有一个“元”成员,它可以包含任何非标准信息。上面的示例向文档中添加了有关分页的信息。

请注意,object 属性是任何类型的变量(在这种情况下它是一个假设的集合),这是文档的主要“主题”。

/**
 * Provides information about the "links" member of the current document.
 *
 * The method returns a new DocumentLinks object if you want to provide linkage data
 * for the document or null if the member should be omitted from the response.
 */
public function getLinks(): ?DocumentLinks
{
    return new DocumentLinks(
        "https://example.com/api",
        [
            "self" => new Link("/books/" . $this->getResourceId())
        ]
    );

    /* This is equivalent to the following:
    return DocumentLinks::createWithBaseUri(
        "https://example.com/api",
        [
            "self" => new Link("/books/" . $this->getResourceId())
        ]
    );
}

这一次,我们希望在文档中显示一个自链接。为此,我们利用 getResourceId() 方法,这是一个调用资源(以下将介绍)以获取主资源 ID 的快捷方式($this->resource->getId($this->object))。

AbstractSingleResourceDocumentAbstractCollectionDocument 之间的唯一区别是它们对待 object 的方式。前者将其视为单个域对象,而后者将其视为可迭代的集合。

用法

可以将文档转换为 HTTP 响应。实现此目的的最简单方法是使用 JsonApi 并选择适当响应类型。成功的文档支持三种类型的响应

  • 正常:响应中可以出现所有顶级成员(除了“错误”)
  • 元数据:只有“jsonapi”、“links”和元顶级成员可以出现在响应中
  • 关系:指定的关系对象将成为响应的主要数据

错误响应的文档

可以使用 AbstractErrorDocument 创建用于错误响应的可重用文档。它也需要实现与成功文档相同的抽象方法,但还可以使用 addError() 方法包含错误项。

/** @var AbstractErrorDocument $errorDocument */
$errorDocument = new MyErrorDocument();
$errorDocument->addError(new MyError());

还有一个 ErrorDocument,它使得可以即时构建错误响应

/** @var ErrorDocument $errorDocument */
$errorDocument = new ErrorDocument();
$errorDocument->setJsonApi(new JsonApiObject("1.0"));
$errorDocument->setLinks(ErrorLinks::createWithoutBaseUri()->setAbout("https://example.com/api/errors/404")));
$errorDocument->addError(new MyError());

资源

成功响应的文档可以包含一个或多个顶级资源和包含的资源。这就是为什么资源负责将域对象转换为 JSON:API 资源和资源标识符。

虽然鼓励为每种资源类型创建一个转换器,但您也可以定义遵循组合设计模式的“组合”资源。

资源必须实现 ResourceInterface。为了便于这项工作,您还可以扩展 AbstractResource 类。

AbstractResource 类的子类需要实现几个抽象方法 - 其中大多数与在文档对象中看到的类似。以下示例说明了一个处理书籍域对象及其“作者”和“出版商”关系的资源。

class BookResource extends AbstractResource
{
    /**
     * @var AuthorResource
     */
    private $authorResource;

    /**
     * @var PublisherResource
     */
    private $publisherResource;

    /**
     * You can type-hint the object property this way.
     * @var array
     */
    protected $object;

    public function __construct(
        AuthorResource $authorResource,
        PublisherResource $publisherResource
    ) {
        $this->authorResource = $authorResource;
        $this->publisherResource = $publisherResource;
    }

    /**
     * Provides information about the "type" member of the current resource.
     *
     * The method returns the type of the current resource.
     *
     * @param array $book
     */
    public function getType($book): string
    {
        return "book";
    }

    /**
     * Provides information about the "id" member of the current resource.
     *
     * The method returns the ID of the current resource which should be a UUID.
     *
     * @param array $book
     */
    public function getId($book): string
    {
        return $this->object["id"];

        // This is equivalent to the following (the $book parameter is used this time instead of $this->object):
        return $book["id"];
    }

    /**
     * Provides information about the "meta" member of the current resource.
     *
     * The method returns an array of non-standard meta information about the resource. If
     * this array is empty, the member won't appear in the response.
     *
     * @param array $book
     */
    public function getMeta($book): array
    {
        return [];
    }

    /**
     * Provides information about the "links" member of the current resource.
     *
     * The method returns a new ResourceLinks object if you want to provide linkage
     * data about the resource or null if it should be omitted from the response.
     *
     * @param array $book
     */
    public function getLinks($book): ?ResourceLinks
    {
        return new ResourceLinks::createWithoutBaseUri()->setSelf(new Link("/books/" . $this->getId($book)));

        // This is equivalent to the following:
        // return new ResourceLinks("", new Link("/books/" . $this->getResourceId()));
    }

    /**
     * Provides information about the "attributes" member of the current resource.
     *
     * The method returns an array where the keys signify the attribute names,
     * while the values are callables receiving the domain object as an argument,
     * and they should return the value of the corresponding attribute.
     *
     * @param array $book
     * @return callable[]
     */
    public function getAttributes($book): array
    {
        return [
            "title" => function () {
                return $this->object["title"];
            },
            "pages" => function () {
                return (int) $this->object["pages"];
            },
        ];

        // This is equivalent to the following (the $book parameter is used this time instead of $this->object):
        return [
            "title" => function (array $book) {
                return $book["title"];
            },
            "pages" => function (array $book) {
                return (int) $book["pages"];
            },
        ];
    }

    /**
     * Returns an array of relationship names which are included in the response by default.
     *
     * @param array $book
     */
    public function getDefaultIncludedRelationships($book): array
    {
        return ["authors"];
    }

    /**
     * Provides information about the "relationships" member of the current resource.
     *
     * The method returns an array where the keys signify the relationship names,
     * while the values are callables receiving the domain object as an argument,
     * and they should return a new relationship instance (to-one or to-many).
     *
     * @param array $book
     * @return callable[]
     */
    public function getRelationships($book): array
    {
        return [
            "authors" => function () {
                return ToManyRelationship::create()
                    ->setLinks(
                        RelationshipLinks::createWithoutBaseUri()->setSelf(new Link("/books/relationships/authors"))
                    )
                    ->setData($this->object["authors"], $this->authorTransformer);
            },
            "publisher" => function () {
                return ToOneRelationship::create()
                    ->setLinks(
                        RelationshipLinks::createWithoutBaseUri()->setSelf(new Link("/books/relationships/publisher"))
                    )
                    ->setData($this->object["publisher"], $this->publisherTransformer);
            },
        ];

        // This is equivalent to the following (the $book parameter is used this time instead of $this->object):

        return [
            "authors" => function (array $book) {
                return ToManyRelationship::create()
                    ->setLinks(
                        RelationshipLinks::createWithoutBaseUri()->setSelf(new Link("/books/relationships/authors"))
                    )
                    ->setData($book["authors"], $this->authorTransformer);
            },
            "publisher" => function ($book) {
                return ToOneRelationship::create()
                    ->setLinks(
                        RelationshipLinks::createWithoutBaseUri()->setSelf(new Link("/books/relationships/publisher"))
                    )
                    ->setData($book["publisher"], $this->publisherTransformer);
            },
        ];
    }
}

通常,您不会直接使用资源。只有文档需要它们来填充响应中的“数据”、“包含”和“关系”成员。

Hydrators

Hydrators 允许我们根据当前 HTTP 请求的要求初始化域对象的属性。这意味着,当客户端想要创建或更新资源时,Hydrators 可以帮助实例化一个域对象,然后可以进行验证、保存等。

Woohoo Labs 中有三个抽象的 hydrator 类。

  • AbstractCreateHydrator:它可用于创建新资源的请求
  • AbstractUpdateHydrator:它可用于更新现有资源的请求
  • AbstractHydrator:它可用于两种类型的请求

为了简洁起见,我们只介绍后者的用法,因为它只是 AbstractCreateHydratorAbstractUpdateHydrator 的并集。让我们看看一个 hydrator 的示例

class BookHydrator extends AbstractHydrator
{
    /**
     * Determines which resource types can be accepted by the hydrator.
     *
     * The method should return an array of acceptable resource types. When such a resource is received for hydration
     * which can't be accepted (its type doesn't match the acceptable types of the hydrator), a ResourceTypeUnacceptable
     * exception will be raised.
     *
     * @return string[]
     */
    protected function getAcceptedTypes(): array
    {
        return ["book"];
    }

    /**
     * Validates a client-generated ID.
     *
     * If the $clientGeneratedId is not a valid ID for the domain object, then
     * the appropriate exception should be thrown: if it is not well-formed then
     * a ClientGeneratedIdNotSupported exception can be raised, if the ID already
     * exists then a ClientGeneratedIdAlreadyExists exception can be thrown.
     *
     * @throws ClientGeneratedIdNotSupported
     * @throws ClientGeneratedIdAlreadyExists
     * @throws Exception
     */
    protected function validateClientGeneratedId(
        string $clientGeneratedId,
        JsonApiRequestInterface $request,
        ExceptionFactoryInterface $exceptionFactory
    ) {
        if ($clientGeneratedId !== null) {
            throw $exceptionFactory->createClientGeneratedIdNotSupportedException($request, $clientGeneratedId);
        }
    }

    /**
     * Produces a new ID for the domain objects.
     *
     * UUID-s are preferred according to the JSON:API specification.
     */
    protected function generateId(): string
    {
        return Uuid::generate();
    }

    /**
     * Sets the given ID for the domain object.
     *
     * The method mutates the domain object and sets the given ID for it.
     * If it is an immutable object or an array the whole, updated domain
     * object can be returned.
     *
     * @param array $book
     * @return mixed|void
     */
    protected function setId($book, string $id)
    {
        $book["id"] = $id;

        return $book;
    }

    /**
     * You can validate the request.
     *
     * @throws JsonApiExceptionInterface
     */
    protected function validateRequest(JsonApiRequestInterface $request): void
    {
        // WARNING! THIS CONDITION CONTRADICTS TO THE SPEC
        if ($request->getAttribute("title") === null) {
            throw new LogicException("The 'title' attribute is required!");
        }
    }

    /**
     * Provides the attribute hydrators.
     *
     * The method returns an array of attribute hydrators, where a hydrator is a key-value pair:
     * the key is the specific attribute name which comes from the request and the value is a
     * callable which hydrates the given attribute.
     * These callables receive the domain object (which will be hydrated), the value of the
     * currently processed attribute, the "data" part of the request and the name of the attribute
     * to be hydrated as their arguments, and they should mutate the state of the domain object.
     * If it is an immutable object or an array (and passing by reference isn't used),
     * the callable should return the domain object.
     *
     * @param array $book
     * @return callable[]
     */
    protected function getAttributeHydrator($book): array
    {
        return [
            "title" => function (array $book, $attribute, $data, $attributeName) {
                $book["title"] = $attribute;

                return $book;
            },
            "pages" => function (array &$book, $attribute, $data, $attributeName) {
                $book["pages"] = $attribute;
            },
        ];
    }

    /**
     * Provides the relationship hydrators.
     *
     * The method returns an array of relationship hydrators, where a hydrator is a key-value pair:
     * the key is the specific relationship name which comes from the request and the value is a
     * callable which hydrate the previous relationship.
     * These callables receive the domain object (which will be hydrated), an object representing the
     * currently processed relationship (it can be a ToOneRelationship or a ToManyRelationship
     * object), the "data" part of the request and the relationship name as their arguments, and
     * they should mutate the state of the domain object.
     * If it is an immutable object or an array (and passing by reference isn't used),
     * the callable should return the domain object.
     *
     * @param mixed $book
     * @return callable[]
     */
    protected function getRelationshipHydrator($book): array
    {
        return [
            "authors" => function (array $book, ToManyRelationship $authors, $data, string $relationshipName) {
                $book["authors"] = BookRepository::getAuthors($authors->getResourceIdentifierIds());

                return $book;
            },
            "publisher" => function (array &$book, ToOneRelationship $publisher, $data, string $relationshipName) {
                $book["publisher"] = BookRepository::getPublisher($publisher->getResourceIdentifier()->getId());
            },
        ];
    }

    /**
     * You can validate the domain object after it has been hydrated from the request.
     * @param mixed $book
     */
    protected function validateDomainObject($book): void
    {
        if (empty($book["authors"])) {
            throw new LogicException("The 'authors' relationship cannot be empty!");
        }
    }
}

根据 book example,以下请求

POST /books HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json

{
  "data": {
    "type": "book",
    "attributes": {
      "title": "Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation",
      "pages": 512
    },
    "relationships": {
      "authors": {
        "data": [
            { "type": "author", "id": "100" },
            { "type": "author", "id": "101" }
        ]
      }
    }
  }
}

将生成以下 Book 域对象

Array
(
    [id] => 1
    [title] => Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation
    [pages] => 512
    [authors] => Array
        (
            [0] => Array
                (
                    [id] => 100
                    [name] => Jez Humble
                )
            [1] => Array
                (
                    [id] => 101
                    [name] => David Farley
                )
        )
    [publisher] => Array
        (
            [id] => 12346
            [name] => Addison-Wesley Professional
        )
)

异常

Woohoo Labs. Yin被设计为使错误处理尽可能简单和可定制。这就是为什么所有默认异常都扩展了JsonApiException类,并包含一个带有适当的错误对象(s)的错误文档。这就是为什么如果你在异常情况下想要返回错误文档,你需要做以下操作

try {
    // Do something which results in an exception
} catch (JsonApiExceptionInterface $e) {
    // Get the error document from the exception
    $errorDocument = $e->getErrorDocument();

    // Instantiate the responder - make sure to pass the correct dependencies to it
    $responder = Responder::create($request, $response, $exceptionFactory, $serializer);

    // Create a response from the error document
    $responder->genericError($errorDocument);

    // Emit the HTTP response
    sendResponse($response);
}

为了保证完全可定制性,我们引入了异常工厂的概念。这些是创建Woohoo Labs. Yin抛出的所有异常的类。由于每个转换器和填充器都会传递一个你自己的选择的异常工厂,你可以完全自定义抛出的异常类型。

默认的异常工厂创建JsonApiException的子类,但你也可以创建任何JsonApiExceptionInterface异常。如果你只想自定义异常的错误文档或错误对象,只需扩展基本Exception类并创建你的createErrorDocument()getErrors()方法。

JsonApi

JsonApi类是整个框架的协调者。如果你想要使用Woohoo Labs. Yin的所有功能,强烈建议使用这个类。你可以在示例部分示例目录中找到关于其使用的各种示例。

JsonApiRequest

JsonApiRequest类实现了WoohooLabs\Yin\JsonApi\Request\JsonApiRequestInterface,该接口扩展了PSR-7 ServerRequestInterface并提供了一些有用的与JSON:API相关的函数。有关可用方法的更多信息,请参阅JsonApiRequestInterface的文档。

高级用法

本节将引导您了解Yin的高级功能。

分页

Yin能够帮助您分页资源集合。首先,它提供了在基于页面、基于偏移或基于游标的分页策略中查询请求查询参数的一些快捷方式。

基于页面的分页

Yin会寻找page[number]page[size]查询参数,并解析它们的值。如果其中任何一个缺失,则将使用默认的页面编号或大小(以下示例中的"1"和"10")。

$pagination = $jsonApi->getPaginationFactory()->createPageBasedPagination(1, 10);

固定页面分页

Yin会寻找page[number]查询参数并解析其值。如果它缺失,则使用默认的页面编号(以下示例中的"1")。这种策略在您不想完全公开页面大小时可能很有用。

$pagination = $jsonApi->getPaginationFactory()->createFixedPageBasedPagination(1);

基于偏移的分页

Yin会寻找page[offset]page[limit]查询参数并解析它们的值。如果其中任何一个缺失,则使用默认的偏移或限制(以下示例中的"1"和"10")。

$pagination = $jsonApi->getPaginationFactory()->createOffsetBasedPagination(1, 10);

基于游标的分页

Yin会寻找page[cursor]page[size]查询参数并解析它们的值。如果其中任何一个缺失,则使用默认的游标或大小(以下示例中的"2016-10-01"或10)。

$pagination = $jsonApi->getPaginationFactory()->createCursorBasedPagination("2016-10-01", 10);

固定游标分页

Yin会寻找page[cursor]查询参数并解析其值。如果它缺失,则使用默认的游标(以下示例中的"2016-10-01")。

$pagination = $jsonApi->getPaginationFactory()->createFixedCursorBasedPagination("2016-10-01");

自定义分页

如果您需要一个自定义的分页策略,可以使用JsonApiRequestInterface::getPagination()方法,该方法返回一个包含分页参数的数组。

$paginationParams = $jsonApi->getRequest()->getPagination();

$pagination = new CustomPagination($paginationParams["from"] ?? 1, $paginationParams["to"] ?? 1);

用法

一旦您有了适当的分页对象,您可以在从数据源获取数据时使用它们。

$users = UserRepository::getUsers($pagination->getPage(), $pagination->getSize());

分页链接

JSON:API 规范允许您为资源集合提供分页链接。Yin 在这方面也能为您提供帮助。在定义文档链接时,您可以使用 DocumentLinks::setPagination() 方法。它期望传入分页 URI 和一个实现了 PaginationLinkProviderInterface 的对象,以下是一个示例

public function getLinks(): ?DocumentLinks
{
    return DocumentLinks::createWithoutBaseUri()->setPagination("/users", $this->object);
}

为了使事情更加简单,有一些 LinkProvider 特性可以帮助轻松实现内置分页策略的 PaginationLinkProviderInterface 实现。例如,对于 User 对象的集合,可以使用 PageBasedPaginationLinkProviderTrait。这样,只需要实现三个抽象方法

class UserCollection implements PaginationLinkProviderInterface
{
    use PageBasedPaginationLinkProviderTrait;

    public function getTotalItems(): int
    {
        // ...
    }

    public function getPage(): int
    {
        // ...
    }

    public function getSize(): int
    {
        // ...
    }

    // ...
}

完整的示例可以在 这里 找到。

高效加载关系数据

有时调整关系数据检索可能是有益的,甚至是有必要的。一个可能的场景是,您有一个包含无数项目的“多对多”关系。如果这个关系并不总是需要的,那么您可能只想在关系本身包含在响应中时返回关系的数据键。这种优化可以通过省略资源链接来节省带宽。

以下是一个从 UserResource 示例类中提取的例子

public function getRelationships($user): array
{
    return [
        "contacts" => function (array $user) {
            return
                ToManyRelationship::create()
                    ->setData($user["contacts"], $this->contactTransformer)
                    ->omitDataWhenNotIncluded();
        },
    ];
}

通过使用 omitDataWhenNotIncluded() 方法,当关系不被包含时,将省略关系数据。然而,有时这种优化本身可能不够。尽管我们可以通过先前技术节省带宽,但关系仍然需要从数据源(可能是一个数据库)中加载,因为我们通过 setData() 方法将其传递给关系对象。

可以通过延迟加载关系来减轻这个问题。为此,您需要使用 setDataAsCallable() 方法而不是 setData()

public function getRelationships($user): array
{
    return [
        "contacts" => function (array $user) {
            return
                ToManyRelationship::create()
                    ->setDataAsCallable(
                        function () use ($user) {
                            // Lazily load contacts from the data source
                            return $user->loadContactsFromDataSource();
                        },
                        $this->contactTransformer
                    )
                    ->omitDataWhenNotIncluded()
                ;
        },
    ];
}

这样,只有当响应中存在给定关系的 data 键时,用户联系人才会被加载,这使得您的 API 尽可能高效。

将元数据注入文档

元数据可以动态地注入到文档中。如果您想自定义或装饰响应,这很有用。例如,如果您想将缓存 ID 注入到响应文档中,可以使用以下方法

// Calculate the cache ID
$cacheId = calculateCacheId();

// Respond with "200 Ok" status code along with the book document containing the cache ID in the meta data
return $jsonApi->respond()->ok($document, $book, ["cache_id" => $cacheId]);

通常,每个响应方法中的最后一个参数可以用来向文档添加元数据。

内容协商

JSON:API 标准指定了有关内容协商的一些规则。Yin 尝试使用 RequestValidator 类来帮助您强制执行这些规则。让我们首先创建一个请求验证器来查看其作用

$requestValidator = new RequestValidator(new DefaultExceptionFactory(), $includeOriginalMessageInResponse);

为了自定义可以抛出的异常,有必要提供一个 异常工厂。另一方面,$includeOriginalMessageInResponse 参数在开发环境中可能很有用,因为它还可以在错误响应中返回触发异常的原始请求体。

为了验证当前请求的 AcceptContent-Type 标头是否符合 JSON:API 规范,请使用此方法

$requestValidator->negotiate($request);

请求/响应验证

您可以使用以下方法来检查当前请求的查询参数是否符合 命名规则

$requestValidator->validateQueryParams($request);

注意:为了应用以下验证,请记住安装 Yin 的 可选依赖项

此外,可以验证请求体是否是一个格式良好的 JSON 文档

$requestValidator->validateJsonBody($request);

同样,也可以验证响应。让我们首先创建一个响应验证器

$responseValidator = new ResponseValidator(
    new JsonSerializer(),
    new DefaultExceptionFactory(),
    $includeOriginalMessageInResponse
);

为确保响应体是一个格式良好的JSON文档,可以使用以下方法:

$responseValidator->validateJsonBody($response);

为确保响应体是一个格式良好的JSON:API文档,可以使用以下方法:

$responseValidator->validateJsonApiBody($response);

在开发环境中验证响应可以有助于及早发现可能的错误。

自定义序列化

您可以为Yin配置自定义序列化响应的方式,而不是使用默认的序列化器(JsonSerializer),该序列化器利用json_encode()函数将JSON:API文档写入响应体。

在大多数用例中,默认序列化器应该能满足您的需求,但有时您可能需要更复杂的处理。或者有时您可能想做一些复杂的事情,比如当API端点“内部”调用时,将您的JSON:API响应作为数组返回,而不进行任何序列化。

要使用自定义序列化器,创建一个实现SerializerInterface的类,并根据需要设置您的JsonApi实例(请注意最后一个参数)。

$jsonApi = new JsonApi(new JsonApiRequest(), new Response(), new DefaultExceptionFactory(), new CustomSerializer());

自定义反序列化

您可以为Yin配置自定义反序列化请求的方式,而不是使用默认的反序列化器(JsonDeserializer),该反序列化器利用json_decode()函数解析请求体的内容。

在大多数用例中,默认反序列化器应该能满足您的需求,但有时您可能需要更复杂的处理。或者有时您可能想做一些复杂的事情,比如当“内部”调用您的JSON:API端点时,不将请求体转换为JSON格式。

要使用自定义反序列化器,创建一个实现DeserializerInterface的类,并根据需要设置您的JsonApiRequest实例(请注意最后一个参数)。

$request = new JsonApiRequest(ServerRequestFactory::fromGlobals(), new DefaultExceptionFactory(), new CustomDeserializer());

中间件

如果您使用面向中间件的框架(如Woohoo Labs. HarmonyZend-StratigilityZend-ExpressiveSlim Framework 3),您会发现Yin-middleware库非常有用。请阅读文档以了解其优势!

示例

获取单个资源

public function getBook(JsonApi $jsonApi): ResponseInterface
{
    // Getting the "id" of the currently requested book
    $id = $jsonApi->getRequest()->getAttribute("id");

    // Retrieving a book domain object with an ID of $id
    $book = BookRepository::getBook($id);

    // Instantiating a book document
    $document = new BookDocument(
        new BookResource(
            new AuthorResource(),
            new PublisherResource()
        )
    );

    // Responding with "200 Ok" status code along with the book document
    return $jsonApi->respond()->ok($document, $book);
}

获取资源集合

public function getUsers(JsonApi $jsonApi): ResponseInterface
{
    // Extracting pagination information from the request, page = 1, size = 10 if it is missing
    $pagination = $jsonApi->getPaginationFactory()->createPageBasedPagination(1, 10);

    // Fetching a paginated collection of user domain objects
    $users = UserRepository::getUsers($pagination->getPage(), $pagination->getSize());

    // Instantiating a users document
    $document = new UsersDocument(new UserResource(new ContactResource()));

    // Responding with "200 Ok" status code along with the users document
    return $jsonApi->respond()->ok($document, $users);
}

获取关系

public function getBookRelationships(JsonApi $jsonApi): ResponseInterface
{
    // Getting the "id" of the currently requested book
    $id = $jsonApi->getRequest()->getAttribute("id");

    // Getting the currently requested relationship's name
    $relationshipName = $jsonApi->getRequest()->getAttribute("rel");

    // Retrieving a book domain object with an ID of $id
    $book = BookRepository::getBook($id);

    // Instantiating a book document
    $document = new BookDocument(
        new BookResource(
            new AuthorResource(),
            new PublisherResource(
                new RepresentativeResource()
            )
        )
    );

    // Responding with "200 Ok" status code along with the requested relationship document
    return $jsonApi->respond()->okWithRelationship($relationshipName, $document, $book);
}

创建新资源

public function createBook(JsonApi $jsonApi): ResponseInterface
{
    // Hydrating a new book domain object from the request
    $book = $jsonApi->hydrate(new BookHydrator(), []);

    // Saving the newly created book
    // ...

    // Creating the book document to be sent as the response
    $document = new BookDocument(
        new BookResource(
            new AuthorResource(),
            new PublisherResource(
                new RepresentativeResource()
            )
        )
    );

    // Responding with "201 Created" status code along with the book document
    return $jsonApi->respond()->created($document, $book);
}

更新资源

public function updateBook(JsonApi $jsonApi): ResponseInterface
{
    // Retrieving a book domain object with an ID of $id
    $id = $jsonApi->getRequest()->getResourceId();
    $book = BookRepository::getBook($id);

    // Hydrating the retrieved book domain object from the request
    $book = $jsonApi->hydrate(new BookHydrator(), $book);

    // Updating the book
    // ...

    // Instantiating the book document
    $document = new BookDocument(
        new BookResource(
            new AuthorResource(),
            new PublisherResource(
                new RepresentativeResource()
            )
        )
    );

    // Responding with "200 Ok" status code along with the book document
    return $jsonApi->respond()->ok($document, $book);
}

更新资源的关联关系

public function updateBookRelationship(JsonApi $jsonApi): ResponseInterface
{
    // Checking the name of the currently requested relationship
    $relationshipName = $jsonApi->getRequest()->getAttribute("rel");

    // Retrieving a book domain object with an ID of $id
    $id = $jsonApi->getRequest()->getAttribute("id");
    $book = BookRepository::getBook($id);
    if ($book === null) {
        die("A book with an ID of '$id' can't be found!");
    }

    // Hydrating the retrieved book domain object from the request
    $book = $jsonApi->hydrateRelationship($relationshipName, new BookHydrator(), $book);

    // Instantiating a book document
    $document = new BookDocument(
        new BookResource(
            new AuthorResource(),
            new PublisherResource(
                new RepresentativeResource()
            )
        )
    );

    // Responding with "200 Ok" status code along with the book document
    return $jsonApi->respond()->ok($document, $book);
}

如何尝试

如果您想了解Yin的工作原理,可以查看示例。如果您的系统上提供了docker-composemake,那么只需运行以下命令即可尝试示例API:

cp .env.dist .env      # You can now edit the settings in the .env file
make composer-install  # Install the Composer dependencies
make up                # Start the webserver

最后,只需访问以下URL:localhost:8080。您甚至可以使用JSON:API中指定的fieldsinclude参数来限制检索的字段和关系。

图书示例的示例URI

  • GET /books/1:获取一本书
  • GET /books/1/relationships/authors:获取作者关系
  • GET /books/1/relationships/publisher:获取出版社关系
  • GET /books/1/authors:获取一本书的作者
  • POST /books:创建新书
  • PATCH /books/1:更新书
  • PATCH /books/1/relationships/author:更新书的作者
  • PATCH /books/1/relationships/publisher:更新书的出版社

用户示例的示例URI

  • GET /users:获取用户
  • GET /users/1:获取一个用户
  • GET /users/1/relationships/contacts:获取联系人关系

完成工作后,只需停止web服务器

make down

如果您没有可用的先决条件,您必须设置web服务器,并在您的宿主系统上安装PHP以及通过Composer安装依赖项。

集成

版本控制

此库遵循SemVer v2.0.0

变更日志

有关最近更改的更多信息,请参阅CHANGELOG

测试

Woohoo Labs. Yin有一个PHPUnit测试套件。要从项目文件夹中运行测试,请执行以下命令

$ phpunit

此外,您还可以运行docker-compose upmake test以执行测试。

贡献

有关详细信息,请参阅CONTRIBUTING

支持

有关详细信息,请参阅SUPPORT

致谢

许可

MIT许可证(MIT)。有关更多信息,请参阅许可证文件