btoweb/ doctrine-rest-driver
将REST API当作本地数据库使用。circle/doctrine-rest-driver的分支
Requires
- php: >=7.1
- ext-json: *
- ext-pdo: *
- ci/restclientbundle: ^2.0.2
- doctrine/dbal: ^2.8.0
- doctrine/orm: ^2.6.2
- greenlion/php-sql-parser: ^4.1.2
- symfony/asset: ^3.4||^4.1
- symfony/form: ^3.4||^4.1
- symfony/security-csrf: ^3.4||^4.1
Requires (Dev)
- doctrine/doctrine-bundle: ^1.8
- phpmd/phpmd: ^2.6.0
- phpunit/phpunit: ^7.4.3
- sensio/framework-extra-bundle: ^5.1
- symfony/expression-language: ^3.0||^4.0
- symfony/framework-bundle: ^3.4
- symfony/validator: ^3.4.0
- symfony/yaml: ^4.1.7
README
黑羊和白羊有什么共同之处?它们都产生羊毛。
大巴士和小巴士有什么共同之处?它们载着人们四处奔波。
那么,SQL数据库和REST API有什么共同之处?它们都存储数据。
虽然这听起来很明显,但后果却是巨大的:因为REST API只不过是一些数据存储后端,我们能够重用对象关系映射工具来访问它们。
因为我们完全没有编写编程语言的方法,所以我们尝试像Rasmus一样一步步进行。因此,DoctrineRestDriver应运而生。
先决条件
- 您需要composer来下载库
安装
使用composer将驱动器添加到项目中
composer require circle/doctrine-rest-driver
更改以下doctrine dbal配置条目
doctrine: dbal: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "%default_api_url%" port: "%default_api_port%" user: "%default_api_username%" password: "%default_api_password%" options: format: "json" | "YourOwnNamespaceName" | if not specified json will be used authenticator_class: "HttpAuthentication" | "YourOwnNamespaceName" | if not specified no authentication will be used
此外,您可以添加CURL特定的选项
doctrine: dbal: driver_class: Circle\DoctrineRestDriver\Driver host: "%default_api_url%" port: "%default_api_port%" user: "%default_api_username%" password: "%default_api_password%" options: format: "json" authenticator_class: "HttpAuthentication" CURLOPT_CURLOPT_FOLLOWLOCATION: true CURLOPT_HEADER: true services: rest.orm.entity_manager: public: false class: Circle\DoctrineRestDriver\Decorator\RestEntityManager decorates: "doctrine.orm.entity_manager" arguments: ["@rest.orm.entity_manager.inner"]
所有可能选项的完整列表可以在这里找到: https://php.ac.cn/manual/en/function.curl-setopt.php
默认情况下,UPDATE查询被转换为PUT以与大多数API协同工作,但是,在持久化更新的实体时,Doctrine会将编辑后的实体与原始数据进行比较,并创建一个只包含更改字段的查询。在REST API中,这将转换为PATCH请求,因为PUT请求旨在包含整个实体,即使某些属性没有更改。
要使用PATCH而不是PUT,只需添加一个配置值
doctrine: dbal: options: use_patch: true
用法
如果您的API路由遵循以下这些约定,使用驱动器非常简单
- 每个路由都必须有相同的结构:
{apiHost}/{pathToApi}/{tableName}
- PUT/PATCH、GET(单个)和UPDATE路由需要包含一个额外的
id
:{apiHost}/{pathToApi}/{tableName}/{id}
- POST和GET(所有)必须遵循基本结构:
{apiHost}/{pathToApi}/{tableName}
不用担心,如果这不是这种情况:幸运的是,我们提供了一些注释,供您配置自己的路由。
响应
您的API可以响应一些不同的HTTP状态码,被认为是成功响应。
请注意,在某些情况下,404响应被认为是一个“成功”响应。这允许驱动器反映数据库正在查询,但由于实体未找到而没有返回数据,例如“/entity/1”将允许404而不会引发异常,因为从数据库中找不到实体是完全可以接受的。
以下示例展示了如何在Symfony环境中使用驱动器。
如果您的API遵循我们的约定
首先创建您的实体
namespace CircleBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * This annotation marks the class as managed entity: * * @ORM\Entity * @ORM\Table("products") */ class Product { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string", length=100) */ private $name; public function getId() { return $this->id; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } }
之后,您就可以像使用数据库一样使用创建的实体了。
使用此设置,驱动器在幕后执行了许多魔法
- 它通常使用请求体以JSON格式发送数据
- 如果状态码匹配默认预期状态码(GET和PUT为200,POST为201,DELETE为204),则自动将响应映射到有效的实体
- 它将实体保存为管理 doctrine 实体
- 它将INSERT查询转换为POST请求以创建新数据
- URL具有以下格式:
{apiHost}/{pathToApi}/{tableName}
- URL具有以下格式:
- UPDATE查询将被转换为PUT请求
- URLs 的格式如下:
{apiHost}/{pathToApi}/{tableName}/{id}
- URLs 的格式如下:
- DELETE 操作将保留
- URLs 的格式如下:
{apiHost}/{pathToApi}/{tableName}/{id}
- URLs 的格式如下:
- SELECT 查询变为 GET 请求
- URLs 的格式如下:
{apiHost}/{pathToApi}/{tableName}/{id}
(如果请求单个实体)或{apiHost}/{pathToApi}/{tableName}
(如果请求所有实体)
- URLs 的格式如下:
让我们通过实现一些控制器方法来观察驱动程序的实际运行情况。在这个例子中,我们假设已经配置了 host
设置为 (章节安装) 为 http://www.yourSite.com/api
。
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { /** * Sends the following request to the API: * POST http://www.yourSite.com/api/products HTTP/1.1 * {"name": "Circle"} * * Let's assume the API responded with: * HTTP/1.1 201 Created * {"id": 1, "name": "Circle"} * * Response body is "1" */ public function createAction() { $em = $this->getDoctrine()->getManager(); $entity = new CircleBundle\Entity\Product(); $entity->setName('Circle'); $em->persist($entity); $em->flush(); return new Response($entity->getId()); } /** * Sends the following request to the API by default: * GET http://www.yourSite.com/api/products/1 HTTP/1.1 * * which might respond with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * Response body is "Circle" */ public function readAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); return new Response($entity->getName()); } /** * Sends the following request to the API: * GET http://www.yourSite.com/api/products HTTP/1.1 * * Example response: * HTTP/1.1 200 OK * [{"id": 1, "name": "Circle"}] * * Response body is "Circle" */ public function readAllAction() { $em = $this->getDoctrine()->getManager(); $entities = $em->getRepository('CircleBundle\Entity\Product')->findAll(); return new Response($entities->first()->getName()); } /** * After sending a GET request (readAction) it sends the following * request to the API by default: * PUT http://www.yourSite.com/api/products/1 HTTP/1.1 * {"name": "myName"} * * Let's assume the API responded the GET request with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * and the PUT request with: * HTTP/1.1 200 OK * {"id": 1, "name": "myName"} * * Then the response body is "myName" */ public function updateAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); $entity->setName('myName'); $em->flush(); return new Response($entity->getName()); } /** * After sending a GET request (readAction) it sends the following * request to the API by default: * DELETE http://www.yourSite.com/api/products/1 HTTP/1.1 * * If the response is: * HTTP/1.1 204 No Content * * the response body is "" */ public function deleteAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); $em->remove($entity); $em->flush(); return new Response(); } }
如果您的 API 不遵循我们的约定
现在是我们向您介绍一些注解的时候了,这些注解可以帮助您配置自己的路由。请确保仅使用 Doctrine
实体。所有这些注解具有相同的结构,因此我们将它们称为 DataSource
注解
@DataSource\SomeName("http://www.myRoute.com", method="POST", statusCode=200)
ROUTE
值是必须的,method 和 statusCode 是可选的。
为了展示它们的功能,让我们使用 DataSource
注解自定义上一章的一些部分
namespace CircleBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Circle\DoctrineRestDriver\Annotations as DataSource; /** * This annotation marks the class as managed entity: * * @ORM\Entity * @ORM\Table("products") * @DataSource\Select("http://www.yourSite.com/api/products/findOne/{id}") * @DataSource\Fetch("http://www.yourSite.com/api/products/findAll") * @DataSource\Insert("http://www.yourSite.com/api/products/insert", statusCodes={200}) * @DataSource\Update("http://www.yourSite.com/api/products/update/{id}", method="POST") * @DataSource\Delete("http://www.yourSite.com/api/products/remove/{id}", method="POST", statusCodes={200}) */ class Product { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string", length=100) */ private $name; public function getId() { return $this->id; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } }
如果您有一系列可接受的状态码,您也可以将一个数组传递给 statusCode
选项
<?php namespace CircleBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Circle\DoctrineRestDriver\Annotations as DataSource; /** * This annotation marks the class as managed entity: * * @ORM\Entity * @ORM\Table("products") * @DataSource\Select("http://www.yourSite.com/api/products/findOne/{id}", statusCodes={200, 203, 404}) */ class Product { // ... // }
这些注解会告诉驱动程序为每个自定义配置将请求发送到配置的 URL。如果您只想为一种方法定义一个特定的路由,则不需要使用所有提供的注解。{id}
作用为一个实体的标识符占位符。
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { /** * Sends the following request to the API: * POST http://www.yourSite.com/api/products/insert HTTP/1.1 * {"name": "Circle"} * * Let's assume the API responded with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * Response body is "1" */ public function createAction() { $em = $this->getDoctrine()->getManager(); $entity = new CircleBundle\Entity\Product(); $entity->setName('Circle'); $em->persist($entity); $em->flush(); return new Response($entity->getId()); } /** * Sends the following request to the API by default: * GET http://www.yourSite.com/api/products/findOne/1 HTTP/1.1 * * which might respond with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * Response body is "Circle" */ public function readAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); return new Response($entity->getName()); } /** * Sends the following request to the API: * GET http://www.yourSite.com/api/products/findAll HTTP/1.1 * * Example response: * HTTP/1.1 200 OK * [{"id": 1, "name": "Circle"}] * * Response body is "Circle" */ public function readAllAction() { $em = $this->getDoctrine()->getManager(); $entities = $em->getRepository('CircleBundle\Entity\Product')->findAll(); return new Response($entities->first()->getName()); } /** * After sending a GET request (readAction) it sends the following * request to the API by default: * POST http://www.yourSite.com/api/products/update/1 HTTP/1.1 * {"name": "myName"} * * Let's assume the API responded the GET request with: * HTTP/1.1 200 OK * {"id": 1, "name": "Circle"} * * and the POST request with: * HTTP/1.1 200 OK * {"id": 1, "name": "myName"} * * Then the response body is "myName" */ public function updateAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); $entity->setName('myName'); $em->flush(); return new Response($entity->getName()); } /** * After sending a GET request (readAction) it sends the following * request to the API by default: * POST http://www.yourSite.com/api/products/remove/1 HTTP/1.1 * * If the response is: * HTTP/1.1 200 OK * * the response body is "" */ public function deleteAction($id = 1) { $em = $this->getDoctrine()->getManager(); $entity = $em->find('CircleBundle\Entity\Product', $id); $em->remove($entity); $em->flush(); return new Response(); } }
分页
在 Doctrine 中,可以使用 OFFSET 和 LIMIT 关键字对查询进行分页。默认情况下,分页将通过请求头发送,但这可以配置为以查询参数的形式发送分页。
doctrine: dbal: options: pagination_as_query: true
这将被转换为以下内容
SELECT name FROM users LIMIT 5 OFFSET 10
到
https://api.example.com/users?per_page=5&page=3
用于 per_page
和 page
的参数键也可以在配置文件中设置
doctrine: dbal: options: per_page_param: count page_param: p
示例
需要更多示例?这里它们是
持久化实体
想象您有一个位于 http://www.your-url.com/api 的 REST API
typedef UnregisteredAddress { street: String, city: String } typedef RegisteredAddress { id: Int, street: String, city: String }
首先,让我们配置 Doctrine
doctrine: dbal: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "http://www.your-url.com/api" port: "80" user: "" password: ""
之后,让我们构建地址实体
<?php namespace CircleBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() * @ORM\Table("addresses") */ class Address { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string") */ private $street; /** * @ORM\Column(type="string") */ private $city; public function setStreet($street) { $this->street = $street; return $this; } public function getStreet() { return $this->street; } public function setCity($city) { $this->city = $city; return $this; } public function getCity() { return $this->city; } public function getId() { return $this->id; } }
最后是控制器及其操作
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class AddressController extends Controller { public function createAction($street, $city) { $em = $this->getDoctrine()->getManager(); $address = new CircleBundle\Address(); $address->setStreet($street)->setCity($city); $em->persist($address); try { $em->flush(); return new Response('successfully registered'); } catch(RequestFailedException) { return new Response('invalid address'); } } }
就是这样。每次调用 createAction 时,它都会向 API 发送 POST 请求。
关联实体
让我们扩展第一个示例。现在我们想添加一个新的实体类型 User
,该类型引用了前一个示例中定义的地址。
REST API 提供以下附加路由
typedef UnregisteredUser { name: String, password: String, address: Index } typedef RegisteredUser { id: Int, name: String, password: String, address: Index }
首先,我们需要构建一个附加实体 "User"
namespace Circle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() * @ORM\Table("users") */ class User { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string") */ private $name; /** * @ORM\OneToOne(targetEntity="CircleBundle\Address", cascade={"persist, remove"}) */ private $address; /** * @ORM\Column(type="string") */ private $password; public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setPassword($password) { $this->password = $password; return $this; } public function getPassword() { return $this->password; } public function getId() { return $this->id; } public function setAddress(Address $address) { $this->address = $address; return $this; } public function getAddress() { return $this->address; } }
用户与地址有关联。因此,让我们看看如果我们将它们关联会发生什么
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { public function createAction($name, $password, $addressId) { $em = $this->getDoctrine()->getManager(); $address = $em->find("CircleBundle\Entity\Address", $addressId); $user = new User(); $user->setName($name) ->setPassword($password) ->setAddress($address); $em->persist($user); $em->flush(); return new Response('successfully registered'); } }
如果我们通过触发 createAction 将名称设置为 username
、密码设置为 secretPassword
和 addressId 设置为 1
,我们的驱动程序将发送以下请求
GET http://www.your-url.com/api/addresses/1 HTTP/1.1
POST http://www.your-url.com/api/users HTTP/1.1 {"name": "username", "password":"secretPassword", "address":1}
因为我们已在用户和地址之间的关系上使用了选项 cascade={"remove"}
,所以如果拥有用户被删除,将自动发送地址的 DELETE 请求
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { public function remove($id = 1) { $em = $this->getDoctrine()->getManager(); $em->find('CircleBundle\Entity\User', $id); $em->remove($em); $em->flush(); return new Response('successfully removed'); } }
例如,带有 id 1
的 DELETE 请求将触发这些请求
DELETE http://www.your-url.com/api/addresses/1 HTTP/1.1
DELETE http://www.your-url.com/api/users/1 HTTP/1.1
太棒了,不是吗?
使用多个后端
在这个最后的例子中,我们将用户和地址路由拆分到两个不同的 REST API 中。这意味着我们需要多个管理器,这在 Doctrine 文档中有解释
doctrine: dbal: default_connection: default connections: default: driver: "pdo_mysql" host: "localhost" port: 3306 user: "root" password: "root" user_api: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "http://api.user.your-url.com" port: 80 user: "Circle" password: "CircleUsers" options: authentication_class: "HttpAuthentication" address_api: driver_class: "Circle\\DoctrineRestDriver\\Driver" host: "http://api.address.your-url.com" port: 80 user: "Circle" password: "CircleAddresses" options: authentication_class: "HttpAuthentication"
现在事情变得疯狂:我们将尝试从两个不同的 API 中读取数据并将它们持久化到 MySQL 数据库中。想象一下具有以下路由的用户 API
以及具有此入口点的地址 API
我们希望从用户 API 读取一个用户,并从地址 API 读取一个地址。之后,我们将它们关联并持久化到我们的 MySQL 数据库中。
<?php namespace CircleBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\HttpFoundation\Response; class UserController extends Controller { public function createAction($userId, $addressId) { $emUsers = $this->getDoctrine()->getManager('user_api'); $emAddresses = $this->getDoctrine()->getManager('address_api'); $emPersistence = $this->getDoctrine()->getManager(); $user = $emUsers->find("CircleBundle\Entity\User", $userId); $address = $emAddresses->find("CircleBundle\Entity\Address", $addressId); $user->setAddress($address); $emPersistence->persist($address); $emPersistence->persist($user); $emPersistence->flush(); return new Response('successfully persisted'); } }
如您在请求日志中所见,已请求了两个 API
GET http://api.users.your-url.com/users/1 HTTP/1.1
GET http://api.addresses.your-url.com/addresses/1 HTTP/1.1
之后,这两个实体都保存在默认的MySQL数据库中。
测试
要测试此包,只需输入
make test
贡献
如果您想为此仓库做出贡献,请确保...
- 遵循现有的编码风格。
- 使用在
composer.json
中列出的代码检查工具(当使用make
时,这些工具免费提供)。 - 为任何更改的代码添加和/或自定义单元测试。
- 在您的pull request中引用相应的issue,并附带您更改的简要描述。
所有贡献者都列在AUTHORS
文件中,按照他们首次贡献的时间排序。