api
laravel
LaravelDay 14

Laravelでクロスドメイン対応のAPIサーバーを立てる為にやったこと

この記事について

Laravel Advent Calendar 2017 14日目の記事です。

初AdventCalendarなので緊張してます💦

SPAアプリを作る機会があり、APIは他のアプリでも使うかもしれないしクロスドメインで使えるものが良いよね(一番の理由はフロントVueでvue-cli使いたかったからです)、ってことでクロスドメイン対応のAPIサーバーを立てる為にやったことをツラツラ書いていきます。
ツッコミどころたくさんあると思いますので、そんな時はコメント頂けますと幸いです。

環境

PHP 7.0.24
Laravel 5.5

Lumenで良いかなとも考えましたが、バッチとかも結構仕込んだりある程度大きめのAPIサーバーになる予感がしたので最初からLaravelにしました。

やったこと

環境を作る

まずは環境構築です。
Laravel本体のインストールは割愛します。
dockerは自作を使いたい派なのでLaradockとかは使わずに作りました。

ディレクトリ構成はこんな感じです。

├── docker-compose.yml
├── api(Laravel本体)
│   ├── app
│   ├── bootstrap
│   ├── config
│   ├── database
│   ├── ...
│   ├── ...
│   └── ...
│
└── docker-compose(DockerfileとかログとかDBのマウントするファイルを置いておくところ)
    ├── api
    │   ├── Dockerfile
    │   ├── php.ini
    │   ├── default.conf
    │   └── ...
    └── db

docker周りのファイルはこんな感じです。

docker-compose.yml
version: '2'
services:
  api:
    container_name: api
    build: ./docker-compose/api
    ports:
      - 8000:80
    links:
      - "db:db"
    volumes:
      - ./api:/var/www/html
      - ./docker-compose/api/log/nginx:/var/log/nginx
      - ./docker-compose/api/log/php-fpm:/var/log/php-fpm
    environment:
      TZ: "Asia/Tokyo"
    command: /sbin/init
    privileged: true
  db:
    container_name: db
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: root
      TZ: "Asia/Tokyo"
    ports:
      - 3306:3306
    volumes:
      - ./docker-compose/db:/var/lib/mysql
    privileged: true
docker-compose/api/Dockerfile
FROM centos:7.3.1611

MAINTAINER tsukasa <test@gmail.com>

## nginx
RUN rpm -ivh http://nginx.org/packages/centos/7/noarch/RPMS/nginx-release-centos-7-0.el7.ngx.noarch.rpm
RUN yum -y update nginx-release-centos
RUN yum -y --enablerepo=nginx install nginx
RUN mv /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.bk
ADD default.conf /etc/nginx/conf.d/
RUN systemctl enable nginx

## php
RUN yum -y install epel-release
RUN rpm -Uvh http://rpms.famillecollet.com/enterprise/remi-release-7.rpm
RUN yum -y install --enablerepo=remi,remi-php70 php php-devel php-mbstring php-pdo php-gd php-mysql php-mcrypt php-xdebug php-xml php-zip
RUN mv /etc/php.ini /etc/php.ini.bk
ADD php.ini /etc/php.ini

## php-fpm
RUN yum -y install php-fpm --enablerepo=epel --enablerepo=remi --enablerepo=remi-php70
RUN mv /etc/php-fpm.d/www.conf /etc/php-fpm.d/www.conf.bk
RUN systemctl enable php-fpm
ADD www.conf /etc/php-fpm.d/www.conf

## git
RUN yum install -y curl-devel expat-devel gettext-devel openssl-devel zlib-devel gcc perl-ExtUtils-MakeMaker wget && \
    cd /usr/local/src/ && \
    wget https://www.kernel.org/pub/software/scm/git/git-2.9.3.tar.gz && \
    tar xzvf git-2.9.3.tar.gz && \
    cd git-2.9.3 && \
    make prefix=/usr/local all && \
    make prefix=/usr/local install

## composer
RUN curl -sS https://getcomposer.org/installer | php
RUN mv composer.phar /usr/local/bin/composer

CMD ["/sbin/init"]

Laradockのように1プロセス1コンテナの原則に則ったほうが良いんでしょうが、docker使ってるのにコンテナの中に入りたがり屋さんなのでAPIサーバー用のコンテナとDB用コンテナの2つしか作っていません。

普段作業する時は

# 起動して
$ docker-compose up -d

# apiコンテナの中に入って
$ docker exec -it api bash

# カレントディレクトリ移動して
$ cd /var/www/html

# artisanとかcomposerとか
$ php artisan ...

こんな感じで開発しています。

JWTAuthを入れる

認証はJWTAuthを使いました。

私は JWT = ジョット と呼んでいるんですが、皆さんがどう呼ばれているのかすごく気になってます。

認証を行うにあたり、LaravelPassport使うかJWTAuth使うか、って選択肢でなんとなくスッと入りそうなJWTAuthを選んだのですが、どちらが主流なんでしょうか?
LaravelPassportも仕組みはJWTという理解でいますがこの辺全然わかってません。。

インストールと設定方法はこちらの通りなので割愛します。

CORS対策する

クロスドメインに対応するため、CORS対策しました。
ライブラリもいくつか出ていますが、あまりやることないので自作しました。

まずはミドルウェアを作成します。

app/Http/Middleware/Cors.php
<?php

namespace App\Http\Middleware;

use Closure;

class Cors
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        return $next($request)
            ->header('Access-Control-Allow-Origin', '*')
            ->header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
            ->header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
    }
}

次に、全てのルーティングに適用させる為、作成したCorsをKernelに追加します。

app/Http/Kernel.php
    /**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        'api' => [
            \App\Http\Middleware\Cors::class
        ],
    ];

今回webを使わずapiだけを使ってたんですが、そもそもAPI専用なのでわざわざapi使わずweb使ってしまっても良かったんですよね。
が、当時はそんなことを考えずapiで作ってしまいました。。

これでCORS完璧!と思っていたのですが、後述するプリフライトリクエストでハマることになったのは想定外でした。。。

Swaggerを入れる

APIドキュメントはSwaggerにしました。

スワガー と呼んでいますが スワッガー 派の方もいらっしゃいますよね?
これもどっちが主流なのか気になっています。

インストールはLaravelプロジェクトのAPIをswaggerを使ってドキュメント化を参考にさせて頂きました。
(何かちょっとだけ詰まった気がするけど思い出せない。。ので手順は割愛します^^;)

今回はコード修正時にドキュメントも一緒に修正したかったので同一ファイル内にSwaggerDocを記載しましたが、SwaggerDocのコード量がファイルの大半を占めることになるのでソースコードを開いてすぐに確認したい場合は別ファイルとして切り出すのもアリかなと思います。

その後、swagger.json出力をコマンド化しました。
コマンド化は以前投稿したこちらの通りです。

PUT, PATCH, DELETEを使えるようにする

ここまでで下準備は完了したので、あとはガリガリ作っていくだけだと思ったんですがこれはハマりました。。。
クロスドメインでプリフライトリクエスト実行しようとするとうまくいかないんです。。。
5.4で発生して5.5でどうかな、と思っていたんですがやっぱりダメでした。。
GET、POSTだけで逃げようかなとも思ったんですが、どうしてもSwaggerUIをカラフルにしたかった(そこかい😇)ので頑張って解決策を探りました。
解決方法はこちらの投稿で記載しています。

もっとスマートな解決方法をご存知の方、ぜひ教えて頂きたいです🙇

SwaggerDocの辛さを少しだけ軽減する

リソースコントローラーだけなら良いのですが、今回のAPIサーバーはページネーション用のAPI等も作成していました。
SwaggerDocを書かれたことのある方はおわかりだと思いますが、返す型が違うと新たにSwaggerDocで定義しなければなりません。

例えば 記事と、それに対するコメントを投稿出来るアプリ登録されている記事(付随するコメントも)一覧をページ指定で返す ためのAPIのコントローラーだとこんな感じです。

ArticleController.php
    /**
     * @SWG\Get(
     *     path="/api/articles/page/{page}",
     *     summary="記事取得(ページング)",
     *     description="記事(ページングあり)を取得する",
     *     produces={"application/json"},
     *     tags={"Article"},
     *     @SWG\Parameter(
     *         name="token",
     *         description="トークン",
     *         in="query",
     *         required=true,
     *         type="string"
     *     ),
     *     @SWG\Parameter(
     *         name="page",
     *         description="ページ番号",
     *         in="path",
     *         type="integer"
     *     ),
     *     @SWG\Response(
     *         response=200,
     *         description="Success",
     *         @SWG\Schema(
     *             type="object",
     *             @SWG\Property(
     *                 property="articles",
     *                 type="object",
     *                 @SWG\Property(
     *                     property="current_page",
     *                     type="integer",
     *                     description="id"
     *                 ),
     *                 @SWG\Property(
     *                     property="data",
     *                     type="array",
     *                     @SWG\Items(ref="#/definitions/ArticleComponent")
     *                 ),
     *                 @SWG\Property(
     *                     property="first_page_url",
     *                     type="string",
     *                     description="first_page_url"
     *                 ),
     *                 @SWG\Property(
     *                     property="last_page",
     *                     type="integer",
     *                     description="last_page"
     *                 ),
     *                 @SWG\Property(
     *                     property="last_page_url",
     *                     type="string",
     *                     description="last_page_url"
     *                 ),
     *                 @SWG\Property(
     *                     property="next_page_url",
     *                     type="string",
     *                     description="next_page_url"
     *                 ),
     *                 @SWG\Property(
     *                     property="path",
     *                     type="string",
     *                     description="path"
     *                 ),
     *                 @SWG\Property(
     *                     property="per_page",
     *                     type="integer",
     *                     description="per_page"
     *                 ),
     *                 @SWG\Property(
     *                     property="prev_page_url",
     *                     type="string",
     *                     description="prev_page_url"
     *                 ),
     *                 @SWG\Property(
     *                     property="to",
     *                     type="string",
     *                     description="to"
     *                 ),
     *                 @SWG\Property(
     *                     property="total",
     *                     type="integer",
     *                     description="total"
     *                 ),
     *             ),
     *         ),
     *     ),
     *     @SWG\Response(
     *         response=401,
     *         description="認証エラー",
     *         @SWG\Schema(
     *             type="object",
     *             @SWG\Property(
     *                 property="error",
     *                 type="string",
     *                 description="token_invalid"
     *             ),
     *         ),
     *     ),
     * )
     */
    /**
     * ページネーションを利用した取得
     * @param int $page
     * @return \Illuminate\Http\JsonResponse
     */
    public function page(Request $request, int $page = 1)
    {
        $articles = ArticleComponent::getPage($page);

        return response()->json(compact(['articles']));
    }

メソッドはたった数行なのにSwaggerDocはこのボリューム。。
(ページネーションがほとんど占めてるので例外的かもしれませんが、他にもこれくらいになる時はあるので例でこちらを選びました)

API1つ作るのに毎回こんなに書いてたら発狂してしまいます。。

このコストを少しでも軽減するためには出来る限りdefinitionsを使い回すようにすることが必要なのかなと思います。

先程の例だと中段にある

* @SWG\Property(
*     property="data",
*     type="array",
*     @SWG\Items(ref="#/definitions/ArticleComponent")
* ),

でdefinitionsを使っています。

上記definitionsで定義している ArticleComponent はこんな感じです。

ArticleComponent
class ArticleComponent
{
    /**
     * ArticleComponent Entity.
     *
     * @SWG\Definition(
     *     definition="ArticleComponent",
     *     @SWG\Property(
     *         property="id",
     *         type="integer",
     *         description="id"
     *     ),
     *     @SWG\Property(
     *         property="url",
     *         type="string",
     *         description="URL"
     *     ),
     *     @SWG\Property(
     *         property="title",
     *         type="string",
     *         description="タイトル"
     *     ),
     *     @SWG\Property(
     *         property="created_at",
     *         type="string",
     *         format="date-time",
     *         description="作成日"
     *     ),
     *     @SWG\Property(
     *         property="updated_at",
     *         type="string",
     *         format="date-time",
     *         description="更新日"
     *     ),
     *     @SWG\Property(
     *         property="created_id",
     *         type="integer",
     *         description="作成者id"
     *     ),
     *     @SWG\Property(
     *         property="updated_id",
     *         type="integer",
     *         description="更新者id"
     *     ),
     *     @SWG\Property(
     *         property="is_deleted",
     *         type="boolean",
     *         description="削除されたかどうか"
     *     ),
     *     @SWG\Property(
     *         property="user",
     *         ref="#/definitions/User"
     *     ),
     *     @SWG\Property(
     *         property="article_tags",
     *         type="array",
     *         @SWG\Items(ref="#/definitions/ArticleTag")
     *     ),
     *     @SWG\Property(
     *         property="comments",
     *         type="array",
     *         @SWG\Items(ref="#/definitions/Comment")
     *     ),
     * )
     */

    /**
     * ArticleComponentのベースを返す
     * @return $this|\Illuminate\Database\Eloquent\Builder|static
     */
    private static function base()
    {
        return Article::with('user')
            ->with('articleTags')
            ->with('articleTags.tag')
            ->with('comments');
    }

    /**
     * ページング指定取得
     *
     * @param int $page
     * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator|Builder
     */
    public static function getPage(int $page = 1)
    {
        $articles = self::base()
            ->active()
            ->latest('id')
            ->paginate(15, null, null, $page);

        return $articles;
    }
}

上記例でもdefinitionsを使っていますが、definitionsを使い回すためのコツは 極力withを使う かなと思っています。

上記でいうと

    private static function base()
    {
        return Article::with('user')
            ->with('articleTags')
            ->with('articleTags.tag')
            ->with('comments');
    }

で型を決めているのですが、withだと 既存のモデルで記載したSwaggerDocが使いまわせる のでこれでだいぶストレスは減りました。

上記のuserはこんな感じです。

User.php
namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    /**
     * User Entity.
     *
     * @SWG\Definition(
     *     definition="User",
     *     type="object",
     *     @SWG\Property(
     *         property="id",
     *         type="integer",
     *         description="id"
     *     ),
     *     @SWG\Property(
     *         property="name",
     *         type="string",
     *         description="ユーザー名"
     *     ),
     *     @SWG\Property(
     *         property="email",
     *         type="string",
     *         description="メールアドレス"
     *     ),
     *     @SWG\Property(
     *         property="password",
     *         type="string",
     *         format="password",
     *         description="パスワード"
     *     ),
     *     @SWG\Property(
     *         property="profile",
     *         type="string",
     *         description="プロフィール"
     *     ),
     *     @SWG\Property(
     *         property="image_path",
     *         type="string",
     *         description="画像パス"
     *     ),
     *     @SWG\Property(
     *         property="created_at",
     *         type="string",
     *         format="date-time",
     *         description="作成日"
     *     ),
     *     @SWG\Property(
     *         property="updated_at",
     *         type="string",
     *         format="date-time",
     *         description="更新日"
     *     ),
     *     @SWG\Property(
     *         property="created_id",
     *         type="integer",
     *         description="作成者id"
     *     ),
     *     @SWG\Property(
     *         property="updated_id",
     *         type="integer",
     *         description="更新者id"
     *     )
     * )
     */

まとまりなく長く書いてしまいましたが、SwaggerDocは

  1. Responseとして返す型はある程度絞る
  2. 極力joinを使わず、withを使う

この2つを意識することでコード量を減らすことが出来る、ということです。

ただ、こちらについてはぜひ こうやったらもっと良いよ みたいなご意見を頂けるとすごく助かります🙇

それと、SwaggerDocの書き方で悩むことが度々あって、まとまった記事もなさそうだったのでどこかでチートシート書きたい(あくまで希望)なと思います。

さいごに

私は普段業務系システムに携わることが多いのですが、

  • サブシステムごとに異なるDBを持っていてキツイ
  • 外部とのIFがバラバラでキツイ

等の悩みを抱えている企業さんはまだまだ多い気がしています。
ERPパッケージ等の導入ですんなり解決出来る企業でも、少なからず独自APIは必要になったりするのかなと思うので、クロスドメインのAPIサーバーを立てる機会がある方にとって少しでも参考になることがあればと思います。

さいごに、以前CodeignitorでAPIサーバーを立てたことがありましたが、個人的にはやっぱりLaravelのほうが書いていて楽しかったです。
まだまだわからないことばかりですが、これからもLaravelで楽しみながらAPIを作っていきたいと思います。