play-logo

その3でデータベースを MySQL に変更しました。その4では、いよいよ CRUD で RESTful な JSON API を作成します。

CRUDと、RESTful な JSON API の作成

Play framwork は、デフォルト設定だと内部 CSS、JS が使えるのですが、内部の URL しか許可されておらず、外部の URL が読み込めない状態にあります。つまり、外部の CSS や、JavaScript を読み込めません。

これは、Content-Security-Policy(CSP)が、デフォルトで reference.conf に "default-src 'self'" で設定されているためです。

CSP によりコンテンツの提供元を制限することで、制作者がリストに登録していない提供元からのコンテンツを読込ませないようにできます。このため悪意のあるコードを実行させない、などの対策ができます。

特に設定してないデフォルト状態では、以下のことが制限されます。

  • data: URL でのコンテンツの埋込
  • href=’javascript:’ のような html への javascript の埋込
  • onclick などのインラインのイベント属性
  • script 要素内のインラインスクリプト
  • eval() での文字列コード new Function() コンストラクタ
  • setInterval() 内での文字列コード setTimeout() 内での文字列コード
  • style 要素での CS Sの設定
  • インラインの style 属性

という感じで、デフォルトではかなり制限されています。

CSP でサービス開発者が任意に設定できる項目は、以下の通りです。
提供元を明示的に指定することができます。

項目 内容
default-src デフォルトで許可する URI を指定
inline-script インラインスクリプトを有効にする
eval-scriot eval を有効にする
script-src script の提供元を指定
style-src css の提供元を指定
img-src 画像と favicon の提供元を指定
font-src ウェブフォント @font-face で読込まれる提供元を指定
media-src audio,video で参照する提供元を指定
object-src object,embed,applet で参照する提供元を指定
frame-src frame,iframe で参照する提供元を指定
xhr-src XMLHttpRequest の提供元を指定
connect-src websocket の提供元を指定
form-action form の送信先を指定
sandbox sandbox 属性の値を設定
plugin-type pdf など application/octet-stream ヘッダーで提供されるもの

他のセキュリティヘッダーのデフォルトの設定も reference.conf で、設定されています。

https://www.playframework.com/documentation/2.5.x/SecurityHeaders
によると、conf/application.conf を

  • conf/application.conf
// 省略
play.filters {
// 省略
  headers {
    // 省略
    contentSecurityPolicy = null
    // 省略
  }
// 省略

上記のように設定すると、他のセキュリティヘッダーの設定のすべてを無効にできます。
ただ、この設定はセキュリティ脆弱性が増すので、本番前に使っているライブラリはローカルにダウンロードし、app/assets 以下において無効にしないでください。

"app/assets/javascripts/index.coffee" はいま使わないためコメントアウトします。

###
$ ->
  $.get "/persons", (persons) ->
    $.each persons, (index, person) ->
      name = $("<div>").addClass("name").text person.name
      age = $("<div>").addClass("age").text person.age
      $("#persons").append $("<li>").append(name).append(age)
###

"app/assets/stylesheets/main.less" もいま使わないためコメントアウトします。

// dl {
//   clear: both;
//   padding: 10px;
//
//   dt {
//     float: left;
//     width: 60px;
//     font-weight: bold;
//   }
//
//   dd {
//     float: left;
//     margin-left: 10px;
//   }
//
//   .info {
//     display: none;
//   }
//
//  .error {
//    color: red;
//  }
//}
//
//.buttons {
//  clear: both;
//  padding: 10px;
//}
//
//#persons {
//  .name {
//    display: inline-block;
//    width: 60px;
//  }
//  .age {
//    display: inline-block;
//  }
//}

build.sbt を以下のように編集し、Play Framework の Bootstrap ライブラリをインストールします。

name := "play-scala-intro"

version := "1.0-SNAPSHOT"

lazy val root = (project in file(".")).enablePlugins(PlayScala)

scalaVersion := "2.11.7"

libraryDependencies ++= Seq(
  "com.typesafe.play" %% "play-slick" % "2.0.2",
  "com.typesafe.play" %% "play-slick-evolutions" % "2.0.2",
//  "com.h2database" % "h2" % "1.4.190",
  specs2 % Test,
  filters,
  "mysql" % "mysql-connector-java" % "5.1.36",
  "com.adrianhurt" %% "play-bootstrap3" % "0.4.5-P24"
)

"app/assets/stylesheets/main.less" もコメントアウトします。

/*
dl {
   clear: both;
   padding: 10px;

   dt {
     float: left;
     width: 60px;
     font-weight: bold;
   }

   dd {
     float: left;
     margin-left: 10px;
   }

   .info {
     display: none;
   }

  .error {
    color: red;
  }
}

.buttons {
  clear: both;
  padding: 10px;
}

#persons {
  .name {
    display: inline-block;
    width: 60px;
  }
  .age {
    display: inline-block;
  }
}
*/

"conf/routes" を以下のように編集してください。

# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET     /                           controllers.PersonController.index

GET     /persons/new                controllers.PersonController.newPerson
GET     /persons/:id/edit           controllers.PersonController.edit(id: Long)
GET     /persons/:id                controllers.PersonController.show(id: Long)
PUT     /persons/:id                controllers.PersonController.replace(id: Long)
PATCH   /persons/:id                controllers.PersonController.update(id: Long)
DELETE  /persons/:id                controllers.PersonController.delete(id: Long)

POST    /persons                    controllers.PersonController.create
GET     /persons                    controllers.PersonController.list



# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)

"app/models/Person.scala" を以下のように編集します。

package models

import play.api.libs.json._

case class Person(id: Option[Long], name: String, age: Int)

object Person {

  implicit val personFormat = Json.format[Person]
}

"app/dal/PersonRepository.scala" を以下のように編集します。

package dal

import javax.inject.{Inject, Singleton}

import play.api.db.slick.DatabaseConfigProvider
import slick.driver.JdbcProfile
import models.Person

import scala.concurrent.{ExecutionContext, Future}

/**
 * A repository for people.
 *
 * @param dbConfigProvider The Play db config provider. Play will inject this for you.
 */
@Singleton
class PersonRepository @Inject() (dbConfigProvider: DatabaseConfigProvider)(implicit ec: ExecutionContext) {
  // We want the JdbcProfile for this provider
  private val dbConfig = dbConfigProvider.get[JdbcProfile]

  // These imports are important, the first one brings db into scope, which will let you do the actual db operations.
  // The second one brings the Slick DSL into scope, which lets you define the table and other queries.
  import dbConfig._
  import driver.api._
  // import slick.driver.MySQLDriver.api._

  /**
   * Here we define the table. It will have a name of people
   */
  private class PeopleTable(tag: Tag) extends Table[Person](tag, "people") {

    /** The ID column, which is the primary key, and auto incremented */
    def id = column[Long]("id", O.PrimaryKey, O.AutoInc)

    /** The name column */
    def name = column[String]("name")

    /** The age column */
    def age = column[Int]("age")

    /**
     * This is the tables default "projection".
     *
     * It defines how the columns are converted to and from the Person object.
     *
     * In this case, we are simply passing the id, name and page parameters to the Person case classes
     * apply and unapply methods.
     */
    def * = (id.?, name, age)  ((Person.apply _).tupled, Person.unapply)
  }

  /**
   * The starting point for all queries on the people table.
   */
  private val people = TableQuery[PeopleTable]

  def findById(id: Long): Future[Option[Person]] = db.run {
    people.filter(t => t.id === id.bind).result.headOption
  }

  def create(person: Person): Future[Int] = db.run {
    people += person
  }

  def update(person: Person): Future[Int] = db.run {
    people.filter(_.id === person.id).update(person)
  }

  def delete(id: Long): Future[Int] = db.run {
    people.filter(_.id === id).delete
  }

  /**
    * List all the people in the database.
    */
  def list(): Future[Seq[Person]] = db.run {
    people.result
  }
}

"app/controllers/PersonController.scala" を以下のように編集します。

package controllers

import java.util.Locale

import play.api._
import play.api.mvc._
import play.api.i18n._
import play.api.data.Form
import play.api.data.Forms._
import play.api.data.validation.Constraints._
import play.api.libs.json.{JsError, JsValue, Json, Writes}
import models._
import dal._

import scala.concurrent.{ExecutionContext, Future}
import javax.inject._

import play.api.http.{HeaderNames, MimeTypes}

class PersonController @Inject() (
   personRepository: PersonRepository,
   val messagesApi: MessagesApi
)(implicit ec: ExecutionContext)
extends Controller with I18nSupport {

  /**
   * The mapping for the person form.
   */
  val personForm: Form[PersonForm] = Form {
    mapping(
      "id" -> optional(longNumber),
      "name" -> nonEmptyText,
      "age" -> number.verifying(min(0), max(140))
    )(PersonForm.apply)(PersonForm.unapply)
  }

  /**
   * The index action.
   */
  def index = Action { implicit request =>
    Ok(views.html.index(personForm))
  }

  def newPerson = Action { implicit request =>
    Ok(views.html.person.personForm(personForm, None))
  }

  def toFilledForm(person: Person): Form[PersonForm] = personForm.fill(PersonForm(person.id, person.name, person.age))

  def show(id: Long) = Action.async { implicit request =>
    personRepository.findById(id).map {
      case Some(person) => {
        request.contentType match {
          case Some(MimeTypes.JSON) => {
            Ok(Json.toJson(person))
          }
          case _ => {
            Ok(views.html.person.show(person))
          }
        }
      }
      case None => Redirect(routes.PersonController.list())
    }
  }

  def edit(id: Long) = Action.async { implicit request =>
    personRepository.findById(id).map {
      case Some(person) => Ok(views.html.person.personForm(toFilledForm(person), Some(id)))
      case None => Redirect(routes.PersonController.list())
    }
  }

  def update(id: Long) = Action.async { implicit request =>
    request.contentType match {
      case Some(MimeTypes.JSON) => {
        val body: AnyContent = request.body
        val jsonBody: Option[JsValue] = body.asJson

        if (request.headers.keys.contains(HeaderNames.X_REQUESTED_WITH)) {
          jsonBody match {
            case Some(json) => {
              personRepository.findById(id).map(x => x.get).map { person =>
                // If successful, we simply redirect to the index page.
                personRepository.update(Person(Option[Long](id), (json \ "name").as[String], (json \ "age").as[Int]))
                Ok(Json.obj("status" ->"success"))
              }
            }
            case None => Future.successful(BadRequest(Json.obj("status" ->"error", "message" -> ("parse error"))))
          }
        } else {
          Future.successful(BadRequest(Json.obj("status" ->"error", "message" -> ("CSRF protection"))))
        }
      }
      case _ => {
        personForm.bindFromRequest.fold(
          errorForm => {
            Future.successful(Ok(views.html.person.personForm(errorForm, None)))
          },
          form => for (
            person  x.get);
            newPerson 
    request.contentType match {
      case Some(MimeTypes.JSON) => {
        val body: AnyContent = request.body
        val jsonBody: Option[JsValue] = body.asJson

        if (request.headers.keys.contains(HeaderNames.X_REQUESTED_WITH)) {
          jsonBody match {
            case Some(json) => {
              personRepository.findById(id).map(x => x.get).map { person =>
                // If successful, we simply redirect to the index page.
                personRepository.delete(person.id.get)
                personRepository.create(Person((json \ "id").asOpt[Long], (json \ "name").as[String], (json \ "age").as[Int]))
                Ok(Json.obj("status" ->"success"))
              }
            }
            case None => Future.successful(BadRequest(Json.obj("status" ->"error", "message" -> ("parse error"))))
          }
        } else {
          Future.successful(BadRequest(Json.obj("status" ->"error", "message" -> ("CSRF protection"))))
        }
      }
      case _ => {
        personForm.bindFromRequest.fold(
          errorForm => {
            Future.successful(Ok(views.html.person.personForm(errorForm, None)))
          },
          form => for (
            person  x.get);
            newPerson 
    // deleteはbodyを使わないかわりにCSRFチェック
    request.contentType match {
      case Some(MimeTypes.JSON) => {
        if (request.headers.keys.contains(HeaderNames.X_REQUESTED_WITH)) {
          personRepository.findById(id.toLong).map { person =>
            person match {
              case Some(person) => {
                personRepository.delete(person.id.get)
                Ok(Json.obj("status" ->"success"))
              }
              case None => BadRequest(Json.obj("status" ->"error"))
            }
          }
        } else {
          Future.successful(BadRequest(Json.obj("status" ->"error", "message" -> ("CSRF Protection"))))
        }
      }
      case _ => {
        personRepository.findById(id.toLong).map { person =>
          person match {
            case Some(person) => {
              personRepository.delete(person.id.get)
              Ok(Json.obj("status" ->"success"))
            }
            case None => BadRequest(Json.obj("status" ->"error"))
          }
        }
      }
    }
  }

  implicit val PersonWrites = new Writes[Person] {
    override def writes(person: Person): JsValue = Json.obj(
      "id" -> person.id,
      "name" -> person.name,
      "age" -> person.age
    )
  }

  def list = Action.async { implicit request =>
    personRepository.list().map { people =>
      request.contentType match {
        case Some(MimeTypes.JSON) => {
          Ok(Json.toJson(people))
        }
        case _ => {
          Ok(views.html.person.list(people))
        }
      }
    }
  }

  /**
    * The add person action.
    *
    * This is asynchronous, since we're invoking the asynchronous methods on PersonRepository.
    */
  def create = Action.async { implicit request =>

    request.contentType match {
      case Some(MimeTypes.JSON) => {
        val body: AnyContent = request.body
        val jsonBody: Option[JsValue] = body.asJson

        if (request.headers.keys.contains(HeaderNames.X_REQUESTED_WITH)) {
          jsonBody.map { json =>
            personRepository.create(Person(None, (json \ "name").as[String], (json \ "age").as[Int])).map { _ =>
              // If successful, we simply redirect to the index page.
              Ok(Json.obj("status" ->"success"))
            }
          }.getOrElse {
            Future.successful(BadRequest(Json.obj("status" ->"error", "message" -> ("parse error"))))
          }
        } else {
          Future.successful(BadRequest(Json.obj("status" ->"error", "message" -> ("CSRF protection"))))
        }
      }
      case _ => {
        personForm.bindFromRequest.fold(
          errorForm => {
            Future.successful(Ok(views.html.person.personForm(errorForm, None)))
          },
          person => {
            personRepository.create(Person(None, person.name, person.age)).map { _ =>
              // If successful, we simply redirect to the index page.
              Redirect(routes.PersonController.list)
            }
          }
        )
      }
    }
  }
}

case class PersonForm(id: Option[Long], name: String, age: Int)

今回JSON APIは、Webのformがないので、Tokenを発行せず、X-Request-Withヘッダーをチェックするだけの手抜き実装です。
脆弱性解消のために、本番では、json web tokenなどを用いて、改ざん検証した方がいいでしょう。

こちらは参考サイトになります。

"app/views/main.scala.html" を以下のように編集してください。

@(title: String)(content: Html)






        <title>@title</title>








    <nav class="navbar navbar-default">
        <div class="container-fluid">
            <div class="navbar-header">
                <a class="navbar-brand" href="/persons">
                    People
                </a>

            </div>
            <ul class="nav navbar-nav">
                <li class="active"><a href="#">Home <span class="sr-only">Tab</span></a></li>
            </ul>
        </div>
    </nav>

    <div class="container-fluid">
        <div class="col-md-12">
            @content
        </div>
    </div>


"app/views/index.scala.html" を以下のように編集します。

@(person: Form[PersonForm])(implicit request: RequestHeader, messages: Messages)

@import helper._

@main("People") {
    <div class="col-xs-2"></div>
    <div class="col-xs-8">
        <h2>Play Rest Server</h2>
        <a href="/persons">ユーザー一覧取得</a>
    </div>
    <div class="col-xs-2"></div>
}

"app/views" パッケージを右クリック -> New ->Package を選択して、person パッケージを作成します。
person パッケージを右クリックし、list.scala.html を作成します。
"app/views/person/list.scala.html" を以下のように編集してください。

@(people: Seq[Person])(implicit request: RequestHeader, messages: Messages)

@import helper._

@main("ユーザー一覧") {

    <div class="col-xs-2">
    </div>
    <div class="col-xs-8">
        <h2>ユーザー一覧</h2>

        <table class="table table-hover">
            <tr>
                <th>ID</th>
                <th>ニックネーム</th>
                <th>年齢</th>
            </tr>

            @people.map { person =>
                <tr>
                    <td><a href="/persons/@person.id">@person.id</a></td>
                    <td>@person.name</td>
                    <td>@person.age</td>
                </tr>
            }
        </table>

        <a href="/persons/new">ユーザー新規作成</a>
    </div>
    <div class="col-xs-2">
    </div>
}

person パッケージを右クリックして、personForm.scala.html を作成します。
"app/views/person/personForm.scala.html" を以下のように編集します。

@(personForm: Form[PersonForm], id: Option[Long])(implicit request: RequestHeader, messages: Messages)

@import helper._
@import b3.vertical.fieldConstructor  // Declares a vertical field constructor as default for bootstrap

@scripts = {

        jQuery(function($) {
            function getFormData($form){
                var unIndexed_array = $form.serializeArray();
                var indexed_array = {};

                $.map(unIndexed_array, function(n, i){
                    indexed_array[n['name']] = n['value'];
                });

                return indexed_array;
            }

            $('#editPerson').submit(function(event) {
                // HTMLでの送信をキャンセル
                event.preventDefault();

                // 操作対象のフォーム要素を取得
                var $form = $(this);

                // 送信ボタンを取得
                // (後で使う: 二重送信を防止する。)
                var $button = $form.find('span');

                // 送信
                $.ajax({
                    url: $form.attr('action'),
                    type: 'PATCH',
                    data: getFormData($form),
                    timeout: 10000,  // 単位はミリ秒
                    // 送信前
                    beforeSend: function(xhr, settings) {
                        // ボタンを無効化し、二重送信を防止
                        $button.attr('disabled', true);
                    },
                    // 応答後
                    complete: function(xhr, textStatus) {
                        // ボタンを有効化し、再送信を許可
                        $button.attr('disabled', false);
                    },

                    // 通信成功時の処理
                    success: function(result, textStatus, xhr) {
                        // 入力値を初期化
                        // $form[0].reset();

                        $('#result').text('OK');
                        window.location.reload();
                    },

                    // 通信失敗時の処理
                    error: function(xhr, textStatus, error) {}
                });
            });

            $('#deletePerson').submit(function(event) {
                // HTMLでの送信をキャンセル
                event.preventDefault();

                // 操作対象のフォーム要素を取得
                var $form = $(this);

                // 送信ボタンを取得
                // (後で使う: 二重送信を防止する。)
                var $button = $form.find('span');

                // 送信
                $.ajax({
                    url: $form.attr('action'),
                    type: 'DELETE',
                    data: {
                        'csrfToken': $form.find('[name=csrfToken]').val()
                    },
                    timeout: 10000,  // 単位はミリ秒
                    // 送信前
                    beforeSend: function(xhr, settings) {
                        // ボタンを無効化し、二重送信を防止
                        $button.attr('disabled', true);
                    },
                    // 応答後
                    complete: function(xhr, textStatus) {
                        // ボタンを有効化し、再送信を許可
                        $button.attr('disabled', false);
                    },

                    // 通信成功時の処理
                    success: function(result, textStatus, xhr) {
                        // 入力値を初期化
                        // $form[0].reset();

                        $('#result').text('OK');
                        // window.location.href = "/persons"
                        window.location.reload();
                    },

                    // 通信失敗時の処理
                    error: function(xhr, textStatus, error) {}
                });
            });
        }
    );

}

@main(
    if(id.isEmpty) {
        "Person新規追加"
    } else {
        "Person編集"
    }
) {
    @if(id) {
        <form method="POST" action="/persons/@id" id="editPerson"></form>
            @CSRF.formField
            @b3.hidden("_method", "PATCH")
            @b3.hidden("id", id)
            @b3.text(personForm("name"), '_label -> "ニックネーム")
            @b3.text(personForm("age"), '_label -> "年齢")
            @b3.submit('class -> "btn btn-default") {
                <span class="glyphicon glyphicon-ok"></span> 編集
            }


        <form method="POST" action="/persons/@id" id="deletePerson"></form>
            @CSRF.formField

            @b3.hidden("id", id)
            @b3.submit('class -> "btn btn-default") {
                <span class="glyphicon glyphicon-ok"></span> 削除
            }

    } else {
        <form method="POST" action="/persons"></form>
            @CSRF.formField
            @b3.text(personForm("name"), '_label -> "ニックネーム")
            @b3.text(personForm("age"), '_label -> "年齢")

            @b3.submit('class -> "btn btn-default") {
                <span class="glyphicon glyphicon-ok"></span> 保存
            }

    }

    @scripts
}

person パッケージを右クリックで、show.scala.html を作成します。
"app/views/person/show.scala.html" を以下のように編集します。

@(person: Person)(implicit request: RequestHeader, messages: Messages)

@import helper._

@main("ユーザー詳細") {

    <div class="col-xs-2">
    </div>
    <div class="col-xs-8">
        <h2>ユーザー一覧</h2>

        <table class="table table-hover">
            <tr>
                <th>ID</th>
                <th>ニックネーム</th>
                <th>年齢</th>
            </tr>

            <tr>
                <td><a href="/persons/@person.id/edit">@person.id</a></td>
                <td>@person.name</td>
                <td>@person.age</td>
            </tr>
        </table>

        <a href="/persons/@person.id/edit">ユーザー編集</a>
    </div>
    <div class="col-xs-2">
    </div>
}

ここまで編集できたところで、Run メニューから ■ ボタンで、stop 'sbt run' を選択してサーバーを止め、▶︎ の、Run 'sbt run' を選択して、サーバーを再起動しましょう。

API を web ブラウザから実行してみよう

再起動したら早速 API を web ブラウザから実行してみましょう。

トップ

29

  • conf/routes の以下に対応
GET     /  controllers.PersonController.index

一覧

30

  • conf/routesの以下に対応
GET     /persons controllers.PersonController.list

新規投稿

https://localhost:9000/persons

31

  • conf/routesの以下に対応
GET  /persons/new controllers.PersonController.newPerson
POST /persons      controllers.PersonController.create

controllers.PersonController.newPersonなのは、既存のnew関数と衝突したため。

詳細、編集、削除画面

32

  • conf/routesの以下に対応
GET /persons/:id/edit  controllers.PersonController.edit(id: Long)
PUT   /persons/:id      controllers.PersonController.replace(id: Long)
PATCH /persons/:id      controllers.PersonController.update(id: Long)
DELETE /persons/:id     controllers.PersonController.delete(id: Long)

HTML の Form は GET/POST 以外の PUT や DELETE をリクエストすることができません。
そのため、POST リクエストに hidden 属性で _method パラメータを付与したり、
submit イベントを JavaScript でフックして、 Ajax で PATCH/PUT/DELETE リクエストを代行するなどの方法で回避されています。

Play Framework は、hidden 属性で _method パラメータを付与する方法には対応してないので、jQueryで、ajax で手抜き実装しましたが、
Angular.jsだと、$http serviceを使って、 XMLHttpRequestで、PATCH/PUT/DELETE リクエストを代行するか、

https://docs.angularjs.org/api/ng/service/$http

Reactだと、axiosっていうPromiseベースのHTTP clientでPATCH/PUT/DELETE リクエストを代行するといいでしょう!

https://github.com/mzabriskie/axios#axiosheadurl-config

ブラウザからは部分更新なので、PATCH だけ実装しました。curl では、PUT の全更新できます。
DELETE は、BODY は使いませんでした。

ターミナルで実行してみる

ターミナルで、curl コマンドを使ってリクエストを投げてみましょう。
JSON を返すのにも対応しています。

  • 1件 POST
curl -H "Content-type: application/json" -XPOST -d '{"name": "test", "age": 3}' http://localhost:9000/persons
  • 全件取得 GET
curl -H "Content-type: application/json" -XGET http://localhost:9000/persons
  • 1件取得 GET
curl -H "Content-type: application/json" -XGET http://localhost:9000/persons/1
  • 部分更新 PATCH
curl -H "Content-type: application/json" -H "X-Requested-With: XMLHttpRequest;" -XPATCH -d '{"name": "ddd", "age": 5}' http://localhost:9000/persons/1
  • 全部更新 PUT
curl -H "Content-type: application/json" -H "X-Requested-With: XMLHttpRequest;" -XPUT -d '{"id": 2, "name": "ccc", "age": 4}' http://localhost:9000/persons/1

ここまで出来たら、ターミナルで git にコミットしましよう!

$ git add -A
$ git commit -m "Playで、RESTful URLで、CRUDを実装"

その5へ続く。