SPA のバックエンドを Symfony2 で開発したい方向けに、Symfony2 で REST API を作る手順についてまとめてみました。

イメージしやすいように、簡単な例で実際に実装する手順をなぞりながら解説していきたいと思います。

1. Symfony をインストール

いつもどおり Symfony プロジェクトを新規インストールしてください。

symfony-installer を使う方法 のほうが composer create-project よりかなり早いのでおすすめです。

$ symfony new rest-sample
$ cd rest-sample
$ php app/console server:run

2. FOSRestBundle をインストール

FOSRestBundle は、その名のとおり REST API の開発に便利な機能を追加してくれるバンドルです。Symfony2 で REST API を開発する場合は通常このバンドルを活用することになります。

インストール方法

インストール方法は ドキュメントのとおり です。

まずは普通に composer でインストールして、AppKernel でバンドルを有効化します。

$ composer require friendsofsymfony/rest-bundle
// in AppKernel::registerBundles()
$bundles = array(
    // ...
+   new FOS\RestBundle\FOSRestBundle(),
);

これに加えて、FOSRestBundle は Serializer に依存している ため、以下のいずれかの方法で Serializer を有効化する必要があります。

  1. fos_rest.services.serializer サービスに独自の Serializer を指定する
  2. JMSSerializerBundle をインストールする
  3. symfony/serializer を有効にする

JMSSerializerBundle をインストール

今回は 2. の JMSSerializerBundle を使うことにします。

ドキュメントのとおり に普通にインストールすれば OK です。

$ composer require jms/serializer-bundle
// in AppKernel::registerBundles()
$bundles = array(
    // ...
+   new JMS\SerializerBundle\JMSSerializerBundle(),
);

【補足】Serializer について

「シリアライズ」とは、オブジェクトを JSON や XML などのファイル化可能なフォーマットに直列化することです。REST API ではリソースを JSON などにシリアライズして HTTP レスポンスとして返すので、その際に Serializer が必要になります。

組み込みの symfony/serializer を使ってもよいですが、JMS serializer のほうが便利(アノテーションで色々設定できたり、Doctrine ORM や循環参照があるオブジェクトでも扱える)なので、ある程度リッチなアプリケーションを作る場合はこちらを選んでおけばよいのではないかと思います。

JMSSerializerBundle をインストールすれば、自動的に JMS serializer が有効になります。

3. エンティティを作成

通常のアプリ開発と同様の手順で、まずはエンティティを作成しましょう。

今回は例として、ブログチュートリアルっぽく 投稿 とそれに対する コメント という 2 つのエンティティを作ってみたいと思います。

$ php app/console doctrine:generate:entity
 :
The Entity shortcut name: AppBundle:Post
 :
Configuration format (yml, xml, php, or annotation) [annotation]:
 :
New field name (press <return> to stop adding fields): title
Field type [string]:
Field length [255]:
 :
New field name (press <return> to stop adding fields): body
Field type [string]: text
 :
$ php app/console doctrine:generate:entity
 :
The Entity shortcut name: AppBundle:Comment
 :
Configuration format (yml, xml, php, or annotation) [annotation]:
 :
New field name (press <return> to stop adding fields): body
Field type [string]: text
 :

これで以下のように Post Comment の 2 つのエンティティが作成されました。

$ tree src/AppBundle/Entity
src/AppBundle/Entity
├── Comment.php
└── Post.php

0 directories, 2 files

エンティティ間のリレーションシップを設定

さらにこの 2 つのエンティティ間のリレーションシップを設定しましょう。Post.php Comment.php それぞれに以下のようにプロパティを追加します。

// in Post.php

/**
 * @var Comment[]
 *
 * @ORM\OneToMany(targetEntity="Comment", mappedBy="post", cascade={"all"})
 */
private $comments;
// in Comment.php

/**
 * @var Post
 *
 * @ORM\ManyToOne(targetEntity="Post", inversedBy="comments")
 * @ORM\JoinColumn(name="post_id", referencedColumnName="id", nullable=false)
 */
private $post;

手動でプロパティを追加したら、setter/getter/adder/remover の追加はコマンドから実施できます。

$ php app/console doctrine:generate:entities AppBundle:Post
Generating entity "AppBundle\Entity\Post"
  > backing up Post.php to Post.php~
  > generating AppBundle\Entity\Post
$ php app/console doctrine:generate:entities AppBundle:Comment
Generating entity "AppBundle\Entity\Comment"
  > backing up Comment.php to Comment.php~
  > generating AppBundle\Entity\Comment

プロパティに必須制約を追加

後ほどバリデーションの動作を確認したいので、例として投稿のタイトルに必須の制約を付けておきましょう。

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints as Assert;

// ...

    /**
     * @var string
     *
+    * @Assert\NotBlank()
+    *
     * @ORM\Column(name="title", type="string", length=255)
     */
    private $title;

// ...

4. DB を作成

エンティティができたら、忘れないうちに DB を作成しておきましょう。

# 事前に app/config/parameters.yml を設定しておくこと
$ php app/console doctrine:database:create
$ php app/console doctrine:schema:create

5. コントローラを作成

続いて、コントローラを作成しましょう。

REST のコントローラは基本的にはリソースごとに作成することが多いと思いますが、今回は簡略化のために PostController だけを作成して、コメントに対するアクションもこの中に入れてしまうことにします。

コントローラクラスは、Symfony\Bundle\FrameworkBundle\Controller\Controller の代わりに FOS\RestBundle\Controller\FOSRestController を継承して作成します。

<?php

namespace AppBundle\Controller;

use FOS\RestBundle\Controller\FOSRestController;

class PostController extends FOSRestController
{
}

6. ルーティングを設定

作成したコントローラに、ルーティングを設定しましょう。

FOSRestBundle のカスタムルートローダを利用する必要があるので、routing.yml に設定を追加します。

# in app/config/rouging.yml
app:
    resource: "@AppBundle/Controller/"
    type:     annotation

+app_post:
+    resource: "@AppBundle/Controller/PostController.php"
+    type: rest

これを設定しておくだけで、コントローラのアクションメソッド名を元に自動でいい感じのルーティングを生成してくれるようになります。

例えば今回の例なら、

<?php
public function getPostAction($id)
{
}

こんなメソッドを定義すると、自動で以下のルーティングが生成されます。

$ php app/console debug:router
[router] Current routes
 Name                      Method Scheme Host Path
 :
 get_post                  GET    ANY    ANY  /posts/{id}.{_format}
 :

リソース名をアクションメソッド名から追い出す

このままでも問題はないのですが、コントローラのクラス名から対象のリソースが Post だと分かっているのにメソッド名にも Post と書かないといけないのがちょっと気に入らないので、リソース名をメソッド名から追い出しておくことにします。

やり方は簡単で、以下のように ClassResourceInterface を implements すればよいだけ です。

<?php

namespace AppBundle\Controller;

use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;

class PostController extends FOSRestController implements ClassResourceInterface
{
    public function getAction($id)
    {
    }
}
$ php app/console debug:router
 :
 get_post                  GET    ANY    ANY  /posts/{id}.{_format}
 :

基本的なアクションメソッドを追加する

getAction 以外のアクションも追加していきましょう。今回の例ではとりあえず以下のようなアクションを実装することにします。

<?php

namespace AppBundle\Controller;

use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use Symfony\Component\HttpFoundation\Request;

class PostController extends FOSRestController implements ClassResourceInterface
{
    // get collection of posts
    public function cgetAction()
    {
    }

    // get the post
    public function getAction($id)
    {
    }

    // create new post
    public function postAction(Request $request)
    {
    }

    // get collection of comments under the post
    public function getCommentsAction($id)
    {
    }

    // create new comment under the post
    public function postCommentAction($id, Request $request)
    {
    }
}
$ php app/console debug:router
[router] Current routes
 Name                      Method Scheme Host Path
 :
 get_posts                 GET    ANY    ANY  /posts.{_format}
 get_post                  GET    ANY    ANY  /posts/{id}.{_format}
 post_post                 POST   ANY    ANY  /posts.{_format}
 get_post_comments         GET    ANY    ANY  /posts/{id}/comments.{_format}
 post_post_comment         POST   ANY    ANY  /posts/{id}/comments.{_format}
 :

FOSRestBundle で用意されているアクションメソッド名の一覧は こちらをご参照ください

また、用意されているアクションメソッド名を使わない場合には、アクションごとに独自に設定することも可能 です。

7. View を設定

View によるフォーマットの隠蔽

コントローラから見ると、レスポンスのフォーマット(JSON, XML, etc)は FOSRestBundle の View レイヤーによって隠蔽されています

簡単に言うと、コントローラからは View クラスのインスタンスを

return $this->handleView($view);

というような形で返しておけば、ViewHandler がいい感じにしてくれる仕組みになっています。

handleView をイベントリスナーに任せる

さらに View Response Listener を使えばもっと簡単 で、

return $view;

とだけすればイベントリスナーが自動で handleView してくれるようになります。これを使うための設定は、config.yml に以下を追記するだけです。

fos_rest:
  view:
    view_response_listener: force

ちなみに、force ではなく true をセットしておいて、リスナーを使いたい対象のアクションメソッドに @View をアノテートするという方法もあり、こちらのほうが少し柔軟です。

エンティティをそのまま返す

さらに、コントローラから返されたデータが View オブジェクトではなく生データだった場合は、リスナー側で View オブジェクトにラップしてくれます

なので、コントローラの実装としては、エンティティやエンティティの配列などの生データを直接 return しておけば、あとは ViewHandler が自動でいい感じにレスポンスを作ってくれます。超便利ですね。

実際に View を設定する

では、実際に View を設定しましょう。今回は全てのアクションで強制的に View Response Listener を使う方法をとりたいと思います。

# in app/config.yml

# FOSRestBundle
fos_rest:
  view:
    view_response_listener: force

8. get 系アクションを実装

View Response Listener を設定したので、コントローラからエンティティやエンティティの配列を返せば ViewHandler が勝手にいい感じのレスポンスにしてくれるようになりました。

というわけで、ひとまず get 系のアクションを実装してみましょう。例えば以下のような感じになるかと思います。

<?php
public function cgetAction()
{
    $posts = $this->getRepository()->findAll();

    return $posts;
}

public function getAction($id)
{
    $post = $this->getRepository()->find($id);

    return $post;
}

// ...

public function getCommentsAction($id)
{
    $comments = $this->getRepository()->find($id)->getComments();

    return $comments;
}

// ...

private function getRepository()
{
    return $this->getDoctrine()->getManager()->getRepository('AppBundle:Post');
}

動作確認

ここまでで一度動かしてみましょう。server:run コマンドを起動してから、

http://localhost:8000/posts.json

にブラウザでアクセスしてみてください。

[]

が返ってきたら成功です。(まだ投稿データが何もないので)

9. post 系アクションを実装

では次に、post 系のアクションを実装していきます。リクエストの内容を元にエンティティを生成して DB に永続化するアクションです。

symfony/form と View レイヤー

FOSRestBundle では、通常のアプリと同じように symfony/form を使ってリクエストをいい感じに処理できるようになっています。(詳細は こちら をご参照ください)

具体的には、コントローラから return されたデータが以下のいずれかに該当する場合に、ViewHandler において少し特殊な処理が行われるようになっています。

  • Form オブジェクト
  • データとして Form オブジェクトだけがセットされた View オブジェクト
  • ['form' => {Form オブジェクト}] という形の連想配列
  • バリデーションエラーを含む Form オブジェクト

これらに該当する場合に、以下のような処理が行われます。

  • Form::createView() したものを 'form' というキーで View に渡してくれる
  • Form::getData() の結果を 'data' とというキーで View に渡してくれる
  • Form オブジェクトにバリデーションエラーがあった場合は、ステータスコード 400 で、エラーの詳細を含んだレスポンスを返してくれる

バリデーションエラー時のレスポンスの例

{
  "code": 400,
  "message": "Validation Failed",
  "errors": {
    "children": {
      "username": {
        "errors": [
          "This value should not be blank."
        ]
      }
    }
  }
}

要するに、通常のアプリと同じように、コントローラで handleRequest した Form オブジェクトを返しておけば、バリデーション等を含め色々いい感じにやってもらえるということですね。

FormType を作成

というわけで、まずは普通に FormType を作成しましょう。

$ php app/console doctrine:generate:form AppBundle:Post
$ php app/console doctrine:generate:form AppBundle:Comment

これで以下のように PostType CommentType の 2 つの FormType が作成されました。

$ tree src/AppBundle/Form
src/AppBundle/Form
├── CommentType.php
└── PostType.php

0 directories, 2 files

CommentType を修正

CommentType にはデフォルトで bodypost という 2 つのフィールドができていますが、今回の例では、投稿が特定されているアクションからしかこのフォームは使われないので、post フィールドは不要です。

このままだと、既に URL で投稿 id を指定しているのに、さらに送信するデータにも投稿 id を含めなければいけなくなります。これは API の設計としておかしいですよね。

というわけで、post フィールドについては削除しておきましょう。

$builder
    ->add('body')
-   ->add('post')
;

アクションを実装

では、作成した FormType を使ったアクションを実装してみましょう。

<?php

namespace AppBundle\Controller;

use AppBundle\Entity\Comment;
use AppBundle\Entity\Post;
use AppBundle\Form\CommentType;
use AppBundle\Form\PostType;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use Symfony\Component\HttpFoundation\Request;

class PostController extends FOSRestController implements ClassResourceInterface
{
    // ...

    public function postAction(Request $request)
    {
        $form = $this->createForm(new PostType(), $post = new Post(), [
            'csrf_protection' => false,
        ]);

        $form->handleRequest($request);

        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $em->persist($post);
            $em->flush();

            return $post;
        }

        return $form;
    }

    // ...

    public function postCommentAction($id, Request $request)
    {
        $comment = new Comment();
        $comment->setPost($this->getRepository()->find($id));

        $form = $this->createForm(new CommentType(), $comment, [
            'csrf_protection' => false,
        ]);

        $form->handleRequest($request);

        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $em->persist($comment);
            $em->flush();

            return $comment;
        }

        return $form;
    }

    // ...
}

通常のアプリの場合と何ら変わらない実装だと思います。

動作確認(その1)

では試しに投稿の新規作成を行ってみましょう。

http://localhost:8000/posts.json に title=test&body=test を POST すれば、投稿が新規作成されて、作成された投稿の内容がレスポンスとして返ってくるはずです。

Google Chrome を使っている方なら、Advanced REST client などの REST クライアントアプリをインストールすると、手軽にブラウザから POST リクエストを送信することができます。

Advanced REST client を使う場合は、以下のようにして POST してみてください。(該当しない方は各自で REST クライアントを用意してください)

image

レスポンスは以下のような結果になりました。

{"children":{"title":[],"body":[]}}

なんかおかしいですね。投稿がちゃんと新規作成されているか確認してみましょう。

GET http://localhost:8000/posts.json (普通にブラウザでアクセスすれば OK です)

[]

やっぱりおかしいですね。投稿が作成されていません。

フォームのデータ構造とリクエストのデータ構造のズレ

この原因は、handleRequest() のソースを読んでみると分かります。

この部分 を見ると、リクエストからデータを取得する際に、フォーム名をキーにしていることが分かります。

通常のアプリケーションで、Form オブジェクトを普通に Twig でレンダリングしたときのレンダリング結果を思い出してみると、

<input type="text" id="フォーム名_フィールド名" name="フォーム名[フィールド名]">

というような形になっていました。なるほどこの場合は上記の処理で正常にデータが取得できますね。

しかし今回の例ではリクエストのデータ構造にフォーム名のキーが入っていないため、$data = $request->query->get($name); の結果は null になります。これでは正常に動作しません。

動作確認(その2)

というわけで、リクエストのデータ構造を変えてもう一度送信してみましょう。

POST http://localhost:8000/posts.json appbundle_post[title]=test&appbundle_post[body]=test

{"id":1,"title":"test","body":"test","comments":[]}

正常に登録できましたね。

無名のフォームを使うようにする

さて、一応動作はしましたが、このままだとフロント側がフォーム名を知っていないと正常にリクエストできないので、PHP 側でちゃんと対処しましょう。

先ほどの handleRequest() のソースの この部分 を見ると、フォーム名がない場合はリクエストのデータをそのまま取得するようになっています。

であれば、コントローラで Form オブジェクトを作る際に名前がないものを作っておけばいいということになりますね。

-$form = $this->createForm(new PostType(), $post = new Post(), [
+$form = $this->get('form.factory')->createNamed('', new PostType(), $post = new Post(), [
    'method' => 'GET',
    'csrf_protection' => false,
]);
-$form = $this->createForm(new CommentType(), $comment, [
+$form = $this->get('form.factory')->createNamed('', new CommentType(), $comment, [
    'method' => 'GET',
    'csrf_protection' => false,
]);

動作確認(その3)

では、再度元のデータ構造でトライしてみましょう。

POST http://localhost:8000/posts.json title=test&body=test

{"id":2,"title":"test","body":"test","comments":[]}

おお、正常に登録できましたね。

同様にコメントの追加も実行してみましょう。

POST http://localhost:8000/posts/1/comments.json body=test

{"id":1,"body":"test","post":{"id":1,"title":"test","body":"test","comments":[]}}

大丈夫そうですね。

動作確認(その4)

続いてバリデーションエラーの確認もしてみましょう。

投稿のタイトルには必須制約が付いているので、タイトルなしの投稿を作成しようとするとバリデーションエラーになるはずです。

POST http://localhost:8000/posts.json body=test

{"code":400,"message":"Validation Failed","errors":{"children":{"title":{"errors":["This value should not be blank."]},"body":[]}}}

バッチリですね。

10. シリアライズ対象プロパティを設定

現状だと、投稿を取得すると配下のコメントもすべて付随して取得されます。

GET http://localhost:8000/posts/1.json

{"id":1,"title":"test","body":"test","comments":[{"id":1,"body":"test"}]}

一見、特に問題なさそうに思えますが、ここは注意が必要です。

今はエンティティ間のリレーションシップが単純なので問題になりませんが、もっと大量のエンティティが複雑に関係しあっているようなアプリケーションだと、エンティティをシリアライズする処理のネストが極端に深くになってしまう恐れがあります。

そもそも、投稿の配下のコメントを取得する API は別で用意されている (GET http://localhost:8000/posts/1/comments.json) わけですから、投稿を取得したときに配下のコメントまで一緒についてくる必要はないですよね。

というわけで、Post::$comments はシリアライズの対象外にしてしまうことにしましょう。

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
+use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;

// ...

    /**
     * @var Comment[]
     *
     * @ORM\OneToMany(targetEntity="Comment", mappedBy="post", cascade={"all"})
+    *
+    * @Serializer\Exclude()
     */
    private $comments;

// ...

これで、投稿取得時のレスポンスから配下のコメントの情報が削除されました。

GET http://localhost:8000/posts/1.json

{"id":1,"title":"test","body":"test"}

サンプルコード

ここまでで実装したサンプルコードは こちらに公開しておきます ので、参考にしてみてください。

おまけ:FOSJsRoutingBundle

最後におまけでもう一つバンドルを紹介しておきます。

同じ Symfony プロジェクト内にフロントエンドの実装も含めてしまいたい場合には、FOSJsRoutingBundle が便利です。FOSJsRoutingBundle は、Symfony のルーティング名を js のファイルから使えるようにしてくれるバンドルです。

インストール

ドキュメント の案内どおりにインストールするだけです。

  1. composer でインストール
  2. バンドルを AppKernel に追加
  3. バンドルのルーティング定義を読み込み
  4. バンドルのアセットをインストール

使い方

以下のように、バンドルが持っている js ファイルをロードするようにテンプレートに追記します。

<script src="{{ asset('bundles/fosjsrouting/js/router.js') }}"></script>
<script src="{{ path('fos_js_routing_js', {'callback': 'fos.Router.setData'}) }}"></script>


これで、他の js ファイルから以下のように Routing オブエジェクトを使って Symfony のルーティング名から URL が生成できます。

Routing.generate('route_id')

注意点

ルーティングの設定で、以下のように expose (露出)しておいたルーティング名しか使えないので注意が必要です。

# routing.yml
some_routing:
    options:
        expose: true

詳細

詳細についてはドキュメントをご参照ください。
https://github.com/FriendsOfSymfony/FOSJsRoutingBundle/blob/master/Resources/doc/index.md