skqr/hateoas

GOintegro HATEOAS 库


README

Join the chat at https://gitter.im/skqr/hateoas

Build Status Code Climate

这是一个使用 Doctrine 2 实体映射和 RAML API 定义来构建 HATEOAS API 的库,遵循 JSON-API 规范。

你不会得到脚手架,你会得到一个可工作的 API。

你将得到一个功能比一只猫的自尊心还要甜的 API。

特性

这就是我的意思。

  • 扁平的、引用的 JSON 序列化。
    • 明确区分标量字段和链接资源。
  • 魔法控制器。
    • 获取资源,支持
      • 稀疏字段;
      • 链接资源展开;
      • 标准化过滤 和排序;
      • 分页;
      • 资源元数据,例如搜索中的细分。
    • 更改资源,支持
      • 在一个请求中处理多个操作;
      • 使用 JSON 模式进行请求验证;
      • 实体验证 使用 Symfony 的验证器;
      • 创建、更新和删除 一键完成;
      • 将服务分配给处理特定资源的任何操作。
  • 一键翻译内容
  • 元数据缓存,类似于 Doctrine 2;
    • Redis,
    • 或 Memcached。

你需要这些。

  • 一个 Doctrine 2 实体映射;
  • A RAML API 定义;
  • 至少一个 Symfony 2 安全投票者。

试一试

查看 示例应用程序项目,这样你可以在不费吹灰之力的情况下感受到指尖上的魔力。

安装

查看 Symfony 2 扩展包,以实现全栈框架实现。

使用

RAML 语言 中设计你的 API,并遵循 JSON-API 规范

例如,假设你有一个具有简短名称 User 的实体类。

#%RAML 0.8
title: HATEOAS Inc. Example API
version: v1
baseUri: https://:8000/api/{version}
mediaType: application/vnd.api+json
/users:
  get:
    description: Fetches all users.
    responses:
      200:
  post:
    description: Creates one or more users.
    responses:
      201:
  /{user-ids}:
    get:
      description: Fetches users by Id.
      responses:
        200:
    put:
      description: Updates one or more users by Id.
      responses:
        200:
    delete:
      description: Deletes one or more users by Id.
    /links:
      /{relationship}:
        get:
          description: Fetches the related resources.
          responses:
            200:
        post:
          description: Relates one or more resources.
          responses:
            201:
        put:
          description: Updates the relationship.
          responses:
            204:
        delete:
          description: Removes the relationship.
        /{relationship-ids}:
          delete:
            description: Removes to-many relationships by Id.

让实体实现资源接口。

不考虑命名空间,仅使用短名称来匹配上述定义的资源。

<?php
namespace HateoasInc\Entity;

use GoIntegro\Hateoas\JsonApi\ResourceEntityInterface

class User implements ResourceEntityInterface {}
?>

哇 - 你会免费获得以下内容。

GET /users
GET /users/1
GET /users/1,2,3
GET /users/1/name
GET /users/1/linked/posts
GET /posts/1/linked/owner
GET /posts?owner=1
GET /posts?owner=1,2,3
GET /users?sort=name,-birth-date
GET /users?include=posts,posts.comments
GET /users?fields=name,email
GET /users?include=posts,posts.comments&fields[users]=name&fields[posts]=content
GET /users?page=1
GET /users?page=1&size=10

任何组合。

你还会得到这些。

POST /users

PUT /users/1
PUT /users/1,2,3

DELETE /users/1
DELETE /users/1,2,3

你可以这样链接或取消链接资源。

POST /users/1/links/user-groups

PUT /users/1/links/user-groups

DELETE /users/1/links/user-groups
DELETE /users/1/links/user-groups/1
DELETE /users/1/links/user-groups/1,2,3

甜蜜,对吧?

对于现在来说,resource_type 必须 与计算出的类型匹配。例如,UserGroupuser-groups

资源

但是你需要有一些控制权来决定你暴露的内容,对吧?我们为你解决了这个问题。

你可以为实体定义一个类似这样的类,并且可以定义你将在其中看到的任何属性和方法。

<?php
namespace GoIntegro\Bundle\ExampleBundle\Rest2\Resource;

// Symfony 2.
use Symfony\Component\DependencyInjection\ContainerAwareInterface,
  Symfony\Component\DependencyInjection\ContainerAwareTrait;
// HATEOAS.
use GoIntegro\Hateoas\JsonApi\EntityResource;

class UserResource extends EntityResource implements ContainerAwareInterface
{
    use ContainerAwareTrait;

    /**
     * @var array
     */
    public static $fieldWhitelist = ['name', 'surname', 'email'];
    /**
     * You wouldn't ever use both a blacklist and a whitelist.
     * @var array
     */
    public static $fieldBlacklist = ['password'];
    /**
     * @var array
     */
    public static $relationshipBlacklist = ['groups'];
    /**
     * These appear as top-level links but not in the resource object.
     * @var array
     */
    public static $linkOnlyRelationships = ['followers'];

    /**
     * By injecting a field we can have both the JSON-API reserved key "type" and our own "user-type" attribute in the resource object.
     * @return string
     * @see https://jsonapi.fullstack.org.cn/format/#document-structure-resource-object-attributes
     */
    public function injectUserType()
    {
        return $this->entity->getType();
    }

    /**
     * We can use services if we implement the ContainerAwareInterface.
     * @return string
     */
    public function injectSomethingExtraordinary()
    {
        return $this->container->get('mystery_machine')->amaze();
    }
}
?>

查看单元测试以获取更多详细信息。

JSON Schema

创建或更新资源的请求,其主体的内容将根据为该资源和方法定义的RAML架构进行验证。

由于JSON-API的请求体在获取、创建或更新时看起来很相似,因此您可以使用在RAML文档根目录中定义的、以资源类型为键的默认架构。

例如,这可能是/users资源的RAML定义。

#%RAML 0.8
title: HATEOAS Inc. Example API
version: v1
baseUri: http://api.hateoas-example.net/{version}
mediaType: application/vnd.api+json
schemas:
  - users: !include users.schema.json
/users:

实体

这个包非常以实体为中心。您的实体外观以及它们在Doctrine 2中的映射关系对于本包在确定API外观时使用的智能至关重要。

安全

访问控制由Symfony的安全组件处理,因此必须配置security votersACL

如果您根本不需要安全功能,只需配置一个接受任何实现GoIntegro\Hateoas\JsonApi\ResourceEntityInterface的单个投票者即可。但这并不是最好的建议。

关于分页呢?我非常确定不会对集合中的每个实体调用isGranted - 对吗?

绝对。

为了解决这个问题,我们提出了一个非常简单的解决方案。我们将安全投票者和自定义过滤器接口结合起来。

让您的投票者/过滤器实现GoIntegro\Hateoas\Security\VoterFilterInterface,并使用security.voterhateoas.repo_helper.filter标签进行标记。

# src/Example/Bundle/AppBundle/Resources/config/services.yml

  security.access.user_voter:
    class: HateoasInc\Bundle\ExampleBundle\Security\Authorization\Voter\UserVoter
    public: false
    tags:
      - { name: security.voter }
      - { name: hateoas.repo_helper.filter }

您应将查看实体的访问控制逻辑表达为方法vote中的安全投票者,以及方法filter中的获取请求过滤器。

好了。

验证

默认验证由Symfony的验证组件处理,因此您可以直接在实体上配置基本验证。

此外,您还可以通过编写自己的约束来扩展验证器。

由于约束可以是服务,这意味着您可能不需要创建自定义构建器或修改器。

违反唯一实体约束最终会导致409 HTTP响应状态,即冲突。 🔥

JSON-API要求仅在尝试创建已存在的关系时使用409。我们正在扩展其应用范围,包括任何由于与其他资源冲突而验证失败的实例。

如果您想创建一个自定义约束,其中违反意味着资源之间的冲突,只需使其实现GoIntegro\Hateoas\Entity\Validation\ConflictConstraintInterface即可。

事务

JSON-API支持在单个请求中创建、更新或删除多个资源 - 但不允许部分更新

我们在处理创建、更新和删除资源的控制器上使用显式事务划分,以便强制执行此规则。

创建、更新和删除

如前所述,默认提供创建、更新和删除资源的服务。

但您的业务逻辑呢?这不会走得太远。

您可以通过标记注册处理每种操作的特定资源的服务,称为构建器修改器删除器

# src/Example/Bundle/AppBundle/Resources/config/services.yml

example.your_resource.builder:
    class: Example\Bundle\AppBundle\Entity\YourEntityBuilder
    public: false
    arguments:
      - @doctrine.orm.entity_manager
      - @validator
      - @security.context
    tags:
      -  { name: hateoas.entity.builder, resource_type: your_resource }

这个构建器类应该实现一个特定的接口。以下是可用的标签和接口。

更新关系和关联所有权

你已设置好一切,发送了一个应该关联两个资源的HTTP请求,获得了200/204的状态响应,但是你的关系并没有创建

怎么回事?这里可能的原因

记住,我们只会对您正在修改的资源关联的实体进行操作。即使在关联资源时,您也是在向其中之一发送请求。

例如:POST /users/1/links/user-groups正在作用于/users

如果您正在操作的实体在 Doctrine 看来不是关联的所有者,您的更改将不会被持久化。

您需要做两件事

以下是一个示例。

<?php
class Team
{
    /**
     * @var ArrayCollection
     * @OneToMany(
     *   targetEntity="User",
     *   mappedBy="team",
     *   cascade={"persist", "remove"}
     * )
     */
    private $members;

    /**
     * @param User $member
     * @return self
     */
    public function addMember(User $member)
    {
        $this->members->add($member);
        $member->setTeam($this); // This is what you need.

        return $this;
    }
}
?>

这些建议适合FAQ或故障排除部分。

可翻译内容

框架通过@stof的Bundle提供了对@l3pp4rd的Doctrine 扩展(也称为Gedmo)的可翻译实体功能的支持。

当获取或更新可翻译资源时,框架将根据由GoIntegro\Hateoas\JsonApi\Request\DefaultLocaleNegotiator协商的locale进行操作。

您可以通过实现GoIntegro\Hateoas\JsonApi\Request\LocaleNegotiatorInterface并使用标签hateoas.request_parser.locale将其公开为服务来覆盖默认的locale协商者。

您可以通过传递查询字符串参数meta=i18n来获取一个或多个资源的所有翻译。您也可以通过使用相同的body发送一个PUT请求来更新它们。

这是示例应用中的一个示例。

GET /articles/1?meta=i18n

Accept-Language: en_GB
{
    "links": {
        "articles.owner": {
            "href": "/api/v1/users/{articles.owner}",
            "type": "users"
        }
    },
    "articles": {
        "id": "1",
        "type": "articles",
        "title": "This is my standing on stuff",
        "content": "Here's me, standing on stuff. E.g. a carrot.",
        "links": {
            "owner": "1"
        }
    },
    "meta": {
        "articles": {
            "translations": {
                "content": [
                    {
                        "locale": "fr",
                        "value": "Ici est moi, debout sur des trucs. Par exemple une carotte."
                    },
                    {
                        "locale": "it",
                        "value": "Qui sono io, in piedi su roba. E.g. una carota."
                    }
                ],
                "title": [
                    {
                        "locale": "fr",
                        "value": "Ce est ma position sur la substance"
                    },
                    {
                        "locale": "it",
                        "value": "Questa è la mia posizione su roba"
                    }
                ]
            }
        }
    }
}

你会说JSON-API吗?是的,是的。

扩展

麻瓜控制器

如果您想出于任何原因覆盖魔法控制器,只需创建一个旧的Symfony 2控制器。

您可以使用实现ResourceEntityInterface的实体,并独立使用HATEOAS包提供的服务。

以下是一个相当基础的示例。

<?php
use GoIntegro\Hateoas\Controller\Controller,
    Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

class UsersController extends Controller
{
    /**
     * @Route("/users/{user}", name="api_get_user", methods="GET")
     * @return \GoIntegro\Hateoas\Http\JsonResponse
     */
    public function getUserAction(User $user)
    {
      $resourceManager = $serviceContainer->get('hateoas.resource_manager');
      $resource = $resourceManager->createResourceFactory()
        ->setEntity($user)
        ->create();

      $json = $resourceManager->createSerializerFactory()
        ->setDocumentResources($resource)
        ->create()
        ->serialize();

      return $this->createETagResponse($json);
    }
?>

查看包的services.yml文件,看看我们那里保留的HATEOAS装备。

幽灵 👻

我知道你在想什么——如果我的资源没有实体怎么办?我在黑暗中孤军奋战吗?

不是这样。幽灵就在你身边,在黑暗中。帮助你。

幽灵是从持久化实体而不是从幽灵资源创建的。它们在自定义HATEOAS控制器中创建,并可以代替实体提供给资源工厂。它们定义自己的关系,而不是ORM事先知道它们。

您使用什么作为ID,以及您使用它们的程度,完全取决于您。

<?php
namespace GoIntegro\Bundle\SomeBundle\Rest2\Ghost;

// Entidades.
use GoIntegro\Bundle\SomeBundle\Entity\Star;
// JSON-API.
use GoIntegro\Hateoas\JsonApi\GhostResourceEntity,
    GoIntegro\Hateoas\Metadata\Resource\ResourceRelationship,
    GoIntegro\Hateoas\Metadata\Resource\ResourceRelationships;
// Colecciones.
use Doctrine\Common\Collections\ArrayCollection;

class StarCluster implements GhostResourceEntity
{
    /**
     * @var string
     */
    private $id;
    /**
     * @var Star
     */
    private $brightestStar;
    /**
     * @var ArrayCollection
     */
    private $stars;

    /**
     * @param string $id
     */
    public function __construct($id)
    {
        $this->id = $id;
        $this->stars = new ArrayCollection;
    }

    /**
     * @return string
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @param Star $star
     * @return self
     */
    public function setBrightestStar(Star $star)
    {
        $this->brightestStar = $star;

        return $this;
    }

    /**
     * @return Star
     */
    public function getBrightestStar()
    {
        return $this->brightestStar;
    }

    /**
     * @return ArrayCollection
     */
    public function getStars()
    {
        return $this->stars;
    }

    /**
     * @param \GoIntegro\Bundle\SomeBundle\Entity\Star $star
     * @return self
     */
    public function addStar(Star $star)
    {
        $this->stars[] = $star;

        return $this;
    }

    /**
     * @return ResourceRelationships
     */
    public static function getRelationships()
    {
        $relationships = new ResourceRelationships;
        $relationships->toMany['stars'] = new ResourceRelationship(
            'GoIntegro\Bundle\SomeBundle\Entity\Star',
            'stars', // resource type
            'stars', // resource sub-type
            'toMany', // relationship kind
            'stars', // relationship name
            'stars' // mapping field
        );
        $relationships->toOne['brightest-star'] = new ResourceRelationship(
            'GoIntegro\Bundle\SomeBundle\Entity\Star',
            'stars',
            'stars',
            'toOne',
            'brightest-star',
            'brightestStar'
        );

        return $relationships;
    }
}
?>

查询过滤器

与包捆绑的标准获取请求过滤器

这是提供这些过滤器服务的服务。

# GoIntegro/HateoasBundle/Resources/config/services.yml

  hateoas.repo_helper.default_filter:
    class: GoIntegro\Hateoas\JsonApi\Request\DefaultFilter
    public: false
    tags:
      - name: hateoas.repo_helper.filter

如果您对标记服务有所了解,您可能已经猜到您可以添加自己的。

只需让您的过滤器类实现GoIntegro\Hateoas\JsonApi\Request\FilterInterface,并在将其声明为服务时添加hateoas.repo_helper.filter标记。

您的过滤器应使用其获得的实体和过滤器参数来决定是否采取行动。请确保单个类不要承担过多的过滤责任。

测试

该软件包附带了一个舒适的PHPUnit测试用例,用于进行HATEOAS API功能测试。

简单的HTTP客户端发起请求,断言使用JSON模式进行。

<?php
namespace GoIntegro\Entity\Suite;

// Testing.
use GoIntegro\Test\PHPUnit\ApiTestCase;
// Fixtures.
use GoIntegro\DataFixtures\ORM\Standard\SomeDataFixture;

class SomeResourceTest extends ApiTestCase
{
    const RESOURCE_PATH = '/api/v2/some-resource',
        RESOURCE_JSON_SCHEMA = '/schemas/some-resource.json';

    /**
     * Doctrine 2 data fixtures to load *before the test case*.
     * @return array <FixtureInterface>
     */
    protected static function getFixtures()
    {
        return array(new SomeDataFixture);
    }

    public function testGettingMany200()
    {
        /* Given... (Fixture) */
        $url = $this->getRootUrl() . self::RESOURCE_PATH;
        $client = $this->createHttpClient($url);
        /* When... (Action) */
        $transfer = $client->exec();
        /* Then... (Assertions) */
        $this->assertResponseOK($client);
        $this->assertJsonApiSchema($transfer);
        $schema = __DIR__ . self::RESOURCE_JSON_SCHEMA;
        $this->assertJsonSchema($schema, $transfer);
    }

    public function testGettingSortedBySomeCustomField400()
    {
        /* Given... (Fixture) */
        $url = $this->getRootUrl()
            . self::RESOURCE_PATH
            . '?sort=some-custom-field';
        $client = $this->createHttpClient($url);
        /* When... (Action) */
        $transfer = $client->exec();
        /* Then... (Assertions) */
        $this->assertResponseBadRequest($client);
    }
}
?>

错误处理

JSON-API还涵盖了如何通知错误

我们的实现并不完全完整,但您可以让Twig使用我们的ExceptionController代替其自身的,以便正确序列化错误。

# app/config/config.yml

twig:
  exception_controller: 'GoIntegro\Hateoas\Controller\ExceptionController::showAction'

获取多个URL

这里有一些有用但不遵循RESTful的东西。

您可以使用/multi操作来获取多个JSON-API URL,而且这甚至不会导致额外的HTTP请求。

/multi?url[]=%2Fapi%2Fv1%2Fusers&url[]=%2Fapi%2Fv1%2Fposts

只需对URL进行编码即可,但您可以使用支持的全部JSON-API功能。

一个blender服务将确保在提供的URL无法合并时通知您。

缓存

是的,这些过程并不便宜。

您可能希望保存您挖掘的元数据或序列化的资源一段时间。

资源元数据

资源元数据描述了资源类型。它描述了其名称、字段、与其他资源的关联以及其他类似内容。

它与Doctrine 2实体映射类似,即Doctrine\ORM\Mapping\ClassMetadata

我们通过检查实体的映射及其类,使用ORM和反射来获取此类。

您可以将资源类型的元数据缓存,直到这两个内容之一发生变化。

以下是方法。添加此参数。

# app/config/parameters.yml

parameters:
    hateoas.resource_cache.class: GoIntegro\Hateoas\JsonApi\ArrayResourceCache

您可以使用以下选项中的任何一个来自定义Redis或Memcached配置。以下为默认值。

# app/config/config.yml

go_integro_hateoas:
  cache: ~
    # resource:
    #   redis:
    #     parameters:
    #       scheme: tcp
    #       host: 127.0.0.1
    #       port: 6379
    #     options: []
    #   memcached: ~
    #     persistent_id: null
    #     servers:
    #       - host: 127.0.0.1
    #         port: 11211

HTTP响应

获取响应都带有ETag

ETag是从响应的完整主体创建的,因此它准确地代表了您正在获取的JSON-API文档,包括其包含的内容、稀疏字段、元数据等。

请求中的ETag使用Symfony进行检查。

反馈

如果您有任何有价值的(或无价值的)反馈,请随时提交问题。希望听到您的反馈(无论是哪种情况)。

如果您敢于在代码上做出如此大的改动,以至于要发起拉取请求,请使用master分支进行修复,使用develop分支进行提议的功能。

我们正在使用Git Flow分支模型。以下是一个很好的速查表,可以为您提供一般性的了解。

新代码不应超过传奇的80字符边界,并且需要完全文档化

顺便说一下:我们仍然需要将问题从软件包仓库迁移过来。

路线图

任何被标记为增强问题都会进行。

PATCH支持即将到来

(大多数增强问题仍然在bundle仓库中。)

免责声明

你可能已经注意到了PHP代码片段中的一些奇怪之处

关闭标签.

我实际上不支持使用它们,如果我不使用它们,我的文本编辑器就会疯狂