gointegro/hateoas

GOintegro HATEOAS 库


README

Build Status Code Climate

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

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

您会得到一个具有比一只美洲狮的自尊更美好特性的可工作 API。

特性

这就是我的意思。

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

您需要以下内容。

  • Doctrine 2 实体映射;
  • 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/links/posts
GET /posts/1/links/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 模式

创建或更新资源的请求,其正文内容将与 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的安全组件处理,因此必须配置安全投票者ACL

如果您根本不想使用安全功能,只需配置一个接受任何实现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的验证组件处理,因此您可以在实体上直接配置基本验证。

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

由于constraints可以是服务,这意味着您可能不需要创建自定义构建器或突变器。

违反唯一实体约束最终会导致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 Extensions(又称Gedmo)提供支持。

当检索或更新可翻译资源时,框架将针对由GoIntegro\Hateoas\JsonApi\Request\DefaultLocaleNegotiator协商的本地化操作。

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

您可以通过传递查询字符串参数meta=i18n检索一个或多个资源的所有翻译。您也可以通过使用相同的主体发起一个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 bundle提供的服务。

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

<?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);
    }
?>

查看bundle的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;
    }
}
?>

查询过滤器

与bundle捆绑的标准检索请求过滤器

这是提供这些服务的部分。

# 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

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

你可以使用/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文档,包括其包含的内容、稀疏字段、元数据等。

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

反馈

如果你有宝贵的(或任何其他)反馈,请随时提交问题。希望收到你的回复(无论哪种情况)。

如果你要提交一个pull request,请使用master分支进行修复,使用develop分支进行新功能的提议。

我们使用的是Git Flow分支模型。这里有一个很好的速查表,可以给你一个大致的了解。

新代码不应超过传奇的八十个字符边界,并且应该完全文档化

顺便说一句:我们仍然需要将包存储库中的问题迁移过来。

路线图

任何被标记为 增强问题都将进行处理。

PATCH 支持即将到来

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

免责声明

你可能在上面PHP代码片段中注意到一些可疑之处

闭合标签.

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