smrhoney/doctrine-rest-driver

使用REST API就像使用本地数据库一样

1.3.5 2018-12-04 15:45 UTC

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

所有可能选项的完整列表可以在这里找到: 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格式发送数据
  • 如果状态码与默认预期状态码匹配,它会自动将响应映射到有效的实体(200用于GET和PUT,201用于POST,204用于DELETE)
  • 它将实体保存为受管理 doctrine 实体
  • 它将INSERT查询转换为POST请求以创建新数据
    • URL具有以下格式: {apiHost}/{pathToApi}/{tableName}
  • UPDATE查询将被转换为PUT请求
    • URLs 的格式如下:{apiHost}/{pathToApi}/{tableName}/{id}
  • DELETE 操作将保留
    • URLs 的格式如下:{apiHost}/{pathToApi}/{tableName}/{id}
  • SELECT 查询变为 GET 请求
    • URLs 的格式如下:{apiHost}/{pathToApi}/{tableName}/{id}(如果请求单个实体)或 {apiHost}/{pathToApi}/{tableName}(如果请求所有实体)

让我们通过实现一些控制器方法来观察驱动器的实际运行情况。在这个例子中,我们假设我们已经配置了 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 值是必需的,而方法和 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 {
   // ... //
}

注解告诉驱动器将请求发送到为每个自定义配置配置的 URLs。如果您只想为一种方法定义特定路由,则不需要使用所有提供的注解。{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_pagepage 的参数键也可以在配置文件中设置。

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时,您可以免费获得)。
  • 为任何更改的代码添加和/或自定义单元测试。
  • 在您的拉取请求中引用相应的问题,并附带您更改的小描述。

所有贡献者都列在AUTHORS文件中,按他们首次贡献的时间排序。