かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その22 )( 貸出希望書籍 CSV ファイルアップロード画面の作成 )

概要

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その21 )( 検索対象図書館登録画面の作成3 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。5~6回に分けて書きます。
    • 貸出希望書籍 CSV ファイルアップロード画面の作成
      • まずは Thymeleaf テンプレートファイルと Controller クラスの雛形を作成します。

参照したサイト・書籍

  1. Bootstrap File Input
    http://plugins.krajee.com/file-input

    • ファイルアップロードの部分の見栄えの良いライブラリを探していて見つけました。今回の画面ではこれを使用してみます。
  2. Bootstrap の File input の見た目を Cool にする
    http://qiita.com/QUANON/items/dbcd694a3b66eceb3023

    • Bootstrap File Input の使用方法が書かれている記事です。こちらも参考にしました。

目次

  1. はじめに
  2. 1.0.x-make-booklist ブランチの作成
  3. Bootstrap File Input のインストール
  4. 貸出希望書籍 CSV ファイルアップロード画面の HTML ファイルの作成
  5. Thymeleaf テンプレートファイルの作成
  6. BooklistController クラスの作成
  7. 動作確認
  8. 次回は。。。

手順

はじめに

全てのユーザが使用できる貸出希望書籍 CSV ファイルアップロード画面を作成します。

  • 書籍の ISBN、書名が記入された CSV ファイルをアップロードします。
  • ファイルの内容をチェックし、エラーがある場合にはエラーメッセージを表示します。エラーがない場合には CSV ファイルの内容を表示します。
  • 内容を確認して問題なければ「登録」ボタンをクリックします。この時に貸出申請 ID を発行し、画面上に表示します。
  • メニューの「検索対象図書館登録」を管理権限 ( ROLE_ADMIN ) を持つユーザにのみ表示させる対応をまだしていなかったので、今回対応します。

1.0.x-make-booklist ブランチの作成

  1. IntelliJ IDEA で 1.0.x-make-booklist ブランチを作成します。

Bootstrap File Input のインストール

  1. build.gradle を リンク先のその1、その2の内容 に変更します。

  2. Gradle projects view から downloadCssFontsJs タスクを実行し、BUILD SUCCESSFUL のログが出力されることを確認します。

    f:id:ksby:20150919073406p:plain

  3. src/main/resources/static の下に以下のファイルが作成されていることを確認します。

    f:id:ksby:20150919075312p:plain

  4. build.gradle の downloadCssFontsJs タスクのコメントアウトした部分を元に戻します。

貸出希望書籍 CSV ファイルアップロード画面の HTML ファイルの作成

  1. src/main/resources/static/html の下に booklist.html を作成します。作成後、リンク先の内容 に変更します。

    HTMLファイルには全ての要素を入れていますが、実際には以下のように表示を切り替える想定です。

    ■ファイル選択前 f:id:ksby:20150919183323p:plain

    ■ファイル選択時 f:id:ksby:20150919183522p:plain

    ■アップロードファイルのエラー発生時 f:id:ksby:20150919210437p:plain

    ■ファイルアップロード後 f:id:ksby:20150919200827p:plain

    ■登録後 f:id:ksby:20150920073040p:plain

Thymeleaf テンプレートファイルの作成

  1. src/main/resources/templates/common の下の head-cssjs.html を リンク先の内容 に変更します。

  2. src/main/resources/templates/admin/library の下の library.html を リンク先の内容 に変更します。

  3. src/main/resources/templates の下に booklist ディレクトリを作成します。

  4. Thymeleaf テンプレートファイルでは src/main/resources/static/html/booklist.html の内容を booklist.html, fileupload.html, complete.html の3つに分割します。

  5. src/main/resources/static/html/booklist.html を src/main/resources/templates/booklist の下にコピーします。

  6. src/main/resources/templates/booklist/booklist.html をコピーして同じディレクトリ内に fileupload.html, complete.html を作成します。

  7. src/main/resources/templates/booklist の下の booklist.html を リンク先の内容 に変更します。

  8. src/main/resources/templates/booklist の下の fileupload.html を リンク先の内容 に変更します。

  9. src/main/resources/templates/booklist の下の complete.html を リンク先の内容 に変更します。

BooklistController クラスの作成

  1. src/main/java/ksbysample/webapp/lending/web の下に booklist パッケージを作成します。

  2. src/main/java/ksbysample/webapp/lending/web/booklist の下に BooklistController.java を作成します。作成後、リンク先の内容 に変更します。

動作確認

  1. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  2. ブラウザを起動し http://localhost:8080/booklist へアクセスします。最初はログイン画面が表示されますので ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

    以下の画面が表示されることを確認します。

    f:id:ksby:20150920083327p:plain

  3. ファイルを選択した後「アップロード」ボタンをクリックします。以下の画面が表示されることを確認します。

    f:id:ksby:20150920083736p:plain

  4. 「登録」ボタンをクリックします。以下の画面が表示されることを確認します。

    f:id:ksby:20150920084023p:plain

  5. Ctrl+F2 を押して Tomcat を停止します。

  6. 一旦 commit します。

次回は。。。

CSVファイルアップロード機能を作成します。

ソースコード

build.gradle

■その1

task downloadCssFontsJs << {
    def staticDirPath   = 'src/main/resources/static'
    def workDirPath     = 'work'
    def adminLTEVersion     = '2.2.0'
    def jQueryVersion       = '2.1.4'
    def fontAwesomeVersion  = '4.3.0'
    def ioniconsVersion     = '2.0.1'
    def html5shivJsVersion  = '3.7.2'
    def respondMinJsVersion = '1.4.2'
    
    // 作業用ディレクトリを削除する
    clearDir("${workDirPath}")
    
/*
    // Bootstrap & AdminLTE Dashboard & Control Panel Template
    downloadAdminLTE("${adminLTEVersion}", "${jQueryVersion}", "${workDirPath}", "${staticDirPath}")
    
    // Font Awesome Icons
    downloadFontAwesome("${fontAwesomeVersion}", "${workDirPath}", "${staticDirPath}")

    // Ionicons
    downloadIonicons("${ioniconsVersion}", "${workDirPath}", "${staticDirPath}")
    
    // html5shiv.js
    downloadHtml5shivJs("${html5shivJsVersion}", "${workDirPath}", "${staticDirPath}")

    // respond.min.js
    downloadRespondMinJs("${respondMinJsVersion}", "${workDirPath}", "${staticDirPath}")
*/

    // fileinput.min.js ( v4.2.7 )
    downloadBootstrapFileInputMinJs("${workDirPath}", "${staticDirPath}")

    // 作業用ディレクトリを削除する
    clearDir("${workDirPath}")
    
    // 追加したファイルを git add する
    addGit()
}
  • downloadBootstrapFileInputMinJs("${workDirPath}", "${staticDirPath}") を追加します。
  • 今回インストールしないモジュールの関数は一旦コメントアウトします。

■その2

void downloadBootstrapFileInputMinJs(String workDirPath, String staticDirPath) {
    download {
        src "https://github.com/kartik-v/bootstrap-fileinput/zipball/master"
        dest new File("${workDirPath}/download/kartik-v-bootstrap-fileinput.zip")
    }
    copy {
        from zipTree("${workDirPath}/download/kartik-v-bootstrap-fileinput.zip")
        into "${workDirPath}/unzip"
    }
    copy {
        from "${workDirPath}/unzip/kartik-v-bootstrap-fileinput-883d8b6/js/fileinput.min.js"
        into "${staticDirPath}/js"
    }
    copy {
        from "${workDirPath}/unzip/kartik-v-bootstrap-fileinput-883d8b6/js/fileinput_locale_ja.js"
        into "${staticDirPath}/js"
    }
    copy {
        from "${workDirPath}/unzip/kartik-v-bootstrap-fileinput-883d8b6/css/fileinput.min.css"
        into "${staticDirPath}/css"
    }
}
  • build.gradle の一覧最後にこの関数を追加します。

static/html/booklist.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>貸出希望書籍 CSV ファイルアップロード</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>
    <!-- Bootstrap File Input -->
    <link href="/css/fileinput.min.css" rel="stylesheet" type="text/css"/>

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <style type="text/css">
        <!--
        .content-wrapper {
            background-color: #fffafa;
        }
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        .callout ul li {
            margin-left: -30px;
        }
        .box-body.no-padding {
            padding-bottom: 10px !important;
        }
        .table>tbody>tr>td
        , .table>tbody>tr>th
        , .table>tfoot>tr>td
        , .table>tfoot>tr>th
        , .table>thead>tr>td
        , .table>thead>tr>th {
            padding: 5px;
            font-size: 90%;
        }
        .lending-oneline-msgbox {
            height: 50px;
            padding-top: 10px;
        }
        -->
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <header class="main-header">
        <nav class="navbar navbar-static-top">
            <div class="container">
                <div class="navbar-header">
                    <a href="#" class="navbar-brand"><b>ksbysample-lending</b></a>
                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar-collapse">
                        <i class="fa fa-bars"></i>
                    </button>
                </div>

                <!-- Collect the nav links, forms, and other content for toggling -->
                <div class="collapse navbar-collapse pull-left" id="navbar-collapse">
                    <ul class="nav navbar-nav">
                        <li class="dropdown">
                            <a href="#" class="dropdown-toggle" data-toggle="dropdown">メニュー <span class="caret"></span></a>
                            <ul class="dropdown-menu" role="menu">
                                <li><a href="#">Action</a></li>
                                <li><a href="#">Another action</a></li>
                                <li><a href="#">Something else here</a></li>
                                <li class="divider"></li>
                                <li><a href="#">Separated link</a></li>
                                <li class="divider"></li>
                                <li><a href="#">One more separated link</a></li>
                            </ul>
                        </li>
                    </ul>
                </div>
                <!-- /.navbar-collapse -->

                <!-- Navbar Right Menu -->
                <div class="navbar-custom-menu">
                    <p class="navbar-text selected-library">選択中:テスト図書館</p>
                    <ul class="nav navbar-nav">
                        <li><a href="#">ログアウト</a></li>
                    </ul>
                </div>
                <!-- /.navbar-custom-menu -->
            </div>
            <!-- /.container-fluid -->
        </nav>
    </header>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>貸出希望書籍 CSV ファイルアップロード</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                <div class="row">
                    <div class="col-xs-12">
                        <!-- ファイル選択前・ファイル選択時 -->
                        <form enctype="multipart/form-data">
                            <div class="form-group">
                                <input type="file" name="fileupload" class="js-fileupload"/>
                            </div>
                        </form>
                        <div class="callout callout-danger">
                            <h4><i class="fa fa-warning"></i> アップロードされたCSVファイルでエラーが発生しました。</h4>
                            <ul>
                                <li>1行目のデータの項目数がISBN、署名の2個ではありません。</li>
                                <li>2行目のデータの署名が128文字を超えています。</li>
                            </ul>
                        </div>
                        <!-- /ファイル選択前・ファイル選択時 -->

                        <!-- ファイルアップロード後 -->
                        <div class="box">
                            <div class="box-body no-padding">
                                <form id="" method="post" action="">
                                    <table class="table table-hover">
                                        <colgroup>
                                            <col width="5%"/>
                                            <col width="35%"/>
                                            <col width="60%"/>
                                        </colgroup>
                                        <thead class="bg-purple">
                                        <tr>
                                            <th>No.</th>
                                            <th>ISBN</th>
                                            <th>書名</th>
                                        </tr>
                                        </thead>
                                        <tbody class="jp-gothic">
                                        <tr>
                                            <th>1</th>
                                            <th>978-1-4302-5908-4</th>
                                            <th>Spring Recipes</th>
                                        </tr>
                                        <tr>
                                            <th>2</th>
                                            <th>978-4-7741-5380-3</th>
                                            <th>Spring3入門</th>
                                        </tr>
                                        <tr>
                                            <th>3</th>
                                            <th>978-4-87311-718-8</th>
                                            <th>Javaパフォーマンス</th>
                                        </tr>
                                        </tbody>
                                    </table>
                                    <div class="text-center">
                                        <button class="btn bg-blue"><i class="fa fa-save"></i> 登録</button>
                                        <button class="btn bg-orange"><i class="fa fa-undo"></i> ファイルをアップロードし直す</button>
                                    </div>
                                </form>
                            </div>
                        </div>
                        <!-- /ファイルアップロード後 -->

                        <!-- 登録後 -->
                        <div class="lending-oneline-msgbox">
                            <p>貸出希望書籍を登録しました。選択中の図書館に蔵書の有無と貸出状況を問い合わせます。</p>
                        </div>
                        <button class="btn bg-blue"><i class="fa fa-file-text"></i> 別の貸出希望書籍を登録する</button>
                        <!-- /登録後 -->
                    </div>
                </div>
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<!-- jQuery 2.1.4 -->
<script src="/js/jQuery-2.1.4.min.js" type="text/javascript"></script>
<!-- Bootstrap 3.3.2 JS -->
<script src="/js/bootstrap.min.js" type="text/javascript"></script>
<!-- AdminLTE App -->
<script src="/js/app.min.js" type="text/javascript"></script>
<!-- Bootstrap File Input -->
<script src="/js/fileinput.min.js" type="text/javascript"></script>
<script src="/js/fileinput_locale_ja.js" type="text/javascript"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('.js-fileupload').fileinput({
            language: 'ja',
            showPreview: false,
            maxFileCount: 1,
            browseClass: 'btn btn-info fileinput-browse-button',
            browseIcon: '',
            browseLabel: ' ファイル選択',
            removeClass: 'btn btn-warning',
            removeIcon: '',
            removeLabel: ' 削除',
            uploadClass: 'btn btn-success fileinput-upload-button',
            uploadIcon: '<i class="fa fa-upload"></i>',
            uploadLabel: ' アップロード',
            allowedFileExtensions: ['csv'],
            msgValidationError: '<span class="text-danger"><i class="fa fa-warning"></i> CSV ファイルのみ有効です。'
        })
    });
    -->
</script>
</body>
</html>
  • Bootstrap File Input で日本語のファイル名を選択する場合には以下2点の対応が必要です。
    • <script src="/js/fileinput.min.js" type="text/javascript"></script> の後に <script src="/js/fileinput_locale_ja.js" type="text/javascript"></script> を記述する。
    • Javascript 内で language: 'ja' を指定する。

head-cssjs.html

    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>
    
    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
        <script src="/js/html5shiv.min.js"></script>
        <script src="/js/respond.min.js"></script>
    <![endif]-->

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        .content-wrapper {
            background-color: #fffafa;
        }
        .noselected-library {
            color: #ff8679 !important;
            font-size: 100%;
            font-weight: 700;
        }
        .selected-library {
            color: #ffffff !important;
            font-size: 100%;
            font-weight: 700;
        }
        .table>tbody>tr>td
        , .table>tbody>tr>th
        , .table>tfoot>tr>td
        , .table>tfoot>tr>th
        , .table>thead>tr>td
        , .table>thead>tr>th {
            padding: 5px;
            font-size: 90%;
        }
        -->
    </style>
  • 画面共通で使用する .jp-gothic, .content-wrapper, .noselected-library, .selected-library, .table>tbody>tr>... の定義を library.html からこちらに移動します。

library.html

<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>検索対象図書館登録</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <link th:replace="common/head-cssjs"/>

    <style type="text/css">
        <!--
        -->
    </style>
</head>
  • head タグ内の以下の点を変更します。
    • style タグを <style><style type="text/css"> へ修正し、<!----> を追加します。
    • head-cssjs.html へ移動した CSS の定義を削除します。

templates/booklist/booklist.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>貸出希望書籍 CSV ファイルアップロード</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <link th:replace="common/head-cssjs"/>
    <!-- Bootstrap File Input -->
    <link href="/css/fileinput.min.css" rel="stylesheet" type="text/css"/>

    <style type="text/css">
        <!--
        .callout ul li {
            margin-left: -30px;
        }
        -->
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <!-- Main Header -->
    <div th:replace="common/mainparts :: main-header"></div>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>貸出希望書籍 CSV ファイルアップロード</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                <div class="row">
                    <div class="col-xs-12">
                        <form id="uploadBooklistForm" enctype="multipart/form-data" method="post" action="/booklist/fileupload" th:action="@{/booklist/fileupload}">
                            <div class="form-group">
                                <input type="file" name="fileupload" class="js-fileupload"/>
                            </div>
                        </form>
                        <div class="callout callout-danger">
                            <h4><i class="fa fa-warning"></i> アップロードされたCSVファイルでエラーが発生しました。</h4>
                            <ul>
                                <li>1行目のデータの項目数がISBN、署名の2個ではありません。</li>
                                <li>2行目のデータの署名が128文字を超えています。</li>
                            </ul>
                        </div>
                    </div>
                </div>
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<script th:replace="common/bottom-js"></script>
<!-- Bootstrap File Input -->
<script src="/js/fileinput.min.js" type="text/javascript"></script>
<script src="/js/fileinput_locale_ja.js" type="text/javascript"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('.js-fileupload').fileinput({
            language: 'ja',
            showPreview: false,
            maxFileCount: 1,
            browseClass: 'btn btn-info fileinput-browse-button',
            browseIcon: '',
            browseLabel: ' ファイル選択',
            removeClass: 'btn btn-warning',
            removeIcon: '',
            removeLabel: ' 削除',
            uploadClass: 'btn btn-success fileinput-upload-button',
            uploadIcon: '<i class="fa fa-upload"></i>',
            uploadLabel: ' アップロード',
            allowedFileExtensions: ['csv'],
            msgValidationError: '<span class="text-danger"><i class="fa fa-warning"></i> CSV ファイルのみ有効です。'
        })
    });
    -->
</script>
</body>
</html>
  • 画面のメインコンテンツ以外では以下の点を修正しています。
    • <html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> へ変更します。
    • Bootstrap File Input の CSS ファイルの link タグを除く部分を <link th:replace="common/head-cssjs"/> へ変更します。
    • <style type="text/css">...</style> の中から head-cssjs.html の中に定義されているものを削除します。
    • ヘッダー部分を <header class="main-header">...</header><div th:replace="common/mainparts :: main-header"></div> へ変更します。
    • </body> 前の Bootstrap File Input の Javascript ファイルの script タグを除く部分を <script th:replace="common/bottom-js"></script> へ変更します。

templates/booklist/fileupload.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>貸出希望書籍 CSV ファイルアップロード</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <link th:replace="common/head-cssjs"/>

    <style type="text/css">
        <!--
        .box-body.no-padding {
            padding-bottom: 10px !important;
        }
        -->
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <!-- Main Header -->
    <div th:replace="common/mainparts :: main-header"></div>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>貸出希望書籍 CSV ファイルアップロード</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                <div class="row">
                    <div class="col-xs-12">
                        <div class="box">
                            <div class="box-body no-padding">
                                <form id="registerBooklistForm" method="post" action="/booklist/register" th:action="@{/booklist/register}">
                                    <table class="table table-hover">
                                        <colgroup>
                                            <col width="5%"/>
                                            <col width="35%"/>
                                            <col width="60%"/>
                                        </colgroup>
                                        <thead class="bg-purple">
                                        <tr>
                                            <th>No.</th>
                                            <th>ISBN</th>
                                            <th>書名</th>
                                        </tr>
                                        </thead>
                                        <tbody class="jp-gothic">
                                        <tr>
                                            <th>1</th>
                                            <th>978-1-4302-5908-4</th>
                                            <th>Spring Recipes</th>
                                        </tr>
                                        <tr>
                                            <th>2</th>
                                            <th>978-4-7741-5380-3</th>
                                            <th>Spring3入門</th>
                                        </tr>
                                        <tr>
                                            <th>3</th>
                                            <th>978-4-87311-718-8</th>
                                            <th>Javaパフォーマンス</th>
                                        </tr>
                                        </tbody>
                                    </table>
                                    <div class="text-center">
                                        <button class="btn bg-blue js-btn-register"><i class="fa fa-save"></i> 登録</button>
                                        <button class="btn bg-orange js-btn-backindex"><i class="fa fa-undo"></i> ファイルをアップロードし直す</button>
                                    </div>
                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<script th:replace="common/bottom-js"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $(".js-btn-register").click(function(){
            $("#registerBooklistForm").submit();
           return false;
        });

        $(".js-btn-backindex").click(function(){
            location.href = "/booklist";
            return false;
        });
    });
    -->
</script>
</body>
</html>

templates/booklist/complete.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>貸出希望書籍 CSV ファイルアップロード</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <link th:replace="common/head-cssjs"/>

    <style type="text/css">
        <!--
        .lending-oneline-msgbox {
            height: 50px;
            padding-top: 10px;
        }
        -->
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <!-- Main Header -->
    <div th:replace="common/mainparts :: main-header"></div>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>貸出希望書籍 CSV ファイルアップロード</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                <div class="row">
                    <div class="col-xs-12">
                        <div class="lending-oneline-msgbox">
                            <p>貸出希望書籍を登録しました。選択中の図書館に蔵書の有無と貸出状況を問い合わせます。</p>
                        </div>
                        <button class="btn bg-blue js-btn-moveindex"><i class="fa fa-file-text"></i> 別の貸出希望書籍を登録する</button>
                    </div>
                </div>
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<script th:replace="common/bottom-js"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $(".js-btn-moveindex").click(function(){
            location.href = "/booklist";
            return false;
        });
    });
    -->
</script>
</body>
</html>

BooklistController.java

package ksbysample.webapp.lending.web.booklist;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/booklist")
public class BooklistController {

    @RequestMapping
    public String index() {
        return "booklist/booklist";
    }

    @RequestMapping("/fileupload")
    public String fileupload() {
        return "booklist/fileupload";
    }

    @RequestMapping("/register")
    public String register() {
        return "redirect:/booklist/complete";
    }
   
    @RequestMapping("/complete")
    public String complete() {
        return "booklist/complete";
    }

}

履歴

2015/09/20
初版発行。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その21 )( 検索対象図書館登録画面の作成3 )

概要

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その20 )( 検索対象図書館登録画面の作成2 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 検索対象図書館登録画面 ( 管理者のみ ) の作成
      • 今回はテストを作成します。
      • 一部のテストで JMockit を使用します。久しぶりに使おうとしたらライブラリの仕様が以前と違う?

参照したサイト・書籍

  1. JMockit - Mocking http://jmockit.org/tutorial/Mocking.html

目次

  1. 作成済のテストをひと通り実行してみる
  2. テスト未作成のクラスを洗い出す
  3. JMockitを利用可能にする
  4. TestDataResource クラスの変更、testdata/base へのデータの追加
  5. LibraryHelper クラスのテストの作成
  6. UrlAfterLoginHelper クラスのテストの作成
  7. AdminLibraryService クラスのテストの作成
  8. AdminLibraryController クラスのテストの作成
  9. 全てのテストが成功するか確認する
  10. commit、Push、Pull Request、マージ

手順

作成済のテストをひと通り実行してみる

  1. 最初に作成済のテストが全て成功するのか確認します。Project View のルートでコンテキストメニューを表示して「Run 'Tests in 'ksbysample...' with Coverage」を選択します。

    テストが実行されますが、いくつかのテストが失敗しました。原因を確認します。

    f:id:ksby:20150912003808p:plain

  2. 原因は以下の2点でした。修正します。

    • 現在のテストではログイン後の URL は必ず /loginsuccess にしていますが、管理権限 ( ROLE_ADMIN ) を持つユーザはログイン後に /admin/library へ遷移するように変更したためでした。
    • /webapi/library/getLibraryList のレスポンスの JSON で formalName → formal に名称変更していたところで失敗していました。
  3. 最初にテストでも使用する URL を定数として定義します。src/main/java/ksbysample/webapp/lending/config の下の Constant.javaリンク先の内容 に変更します。

  4. src/test/java/ksbysample/webapp/lending/web の下の LoginControllerTest.javaリンク先のその1、その2、その3の内容 に変更します。

  5. src/test/java/ksbysample/webapp/lending/webapi/library の下の LibraryControllerTest.javaリンク先の内容 に変更します。

  6. 定義した定数を反映できるところに反映します。Ctrl+SHIFT+F を押して「Find in Path」ダイアログを表示し、"/admin/library" が書かれている箇所を検索します。

  7. src/main/java/ksbysample/webapp/lending/security の下の RoleAwareAuthenticationSuccessHandler.javaリンク先のその1の内容 に変更します。

  8. src/main/java/ksbysample/webapp/lending/web/admin/library の下の AdminLibraryController.javaリンク先の内容 に変更します。

  9. 再度 Project View のルートでコンテキストメニューを表示して「Run 'Tests in 'ksbysample...' with Coverage」を選択し、テストを実行します。

    まだ失敗したテストがあるので原因を確認します。

    f:id:ksby:20150912140024p:plain

  10. 原因は LoginController クラスの index メソッドに実装した有効な remember-me Cookie がある場合にはログイン画面を表示させずに自動ログインさせる処理で、付与された権限に関係なく固定で /loginsuccess にリダイレクトさせていたためでした。権限に応じたログイン後画面にリダイレクトさせるようにします。

  11. src/main/java/ksbysample/webapp/lending/helper の下に url パッケージを作成します。

  12. src/main/java/ksbysample/webapp/lending/helper/url の下に UrlAfterLoginHelper.java を作成します。作成後、リンク先の内容 に変更します。

  13. src/main/java/ksbysample/webapp/lending/security の下の RoleAwareAuthenticationSuccessHandler.javaリンク先のその2の内容 に変更します。

  14. src/main/java/ksbysample/webapp/lending/web の下の LoginController.javaリンク先の内容 に変更します。

  15. 再度テストを実行し、今度は全て成功しました。

    f:id:ksby:20150912143340p:plain

  16. 一旦 commit します。

テスト未作成のクラスを洗い出す

  1. 今回は以下のクラスのテストを作成します。

    • ksbysample.webapp.lending.helper.library.LibraryHelper
    • ksbysample.webapp.lending.helper.url.UrlAfterLoginHelper
    • ksbysample.webapp.lending.web.admin.library.AdminLibraryController
    • ksbysample.webapp.lending.web.admin.library.AdminLibraryService

    ※他にもテスト未作成のクラスはありますが、上のものだけ作成します。

    IntelliJ IDEA でテスト未作成のクラスを検出してくれる機能がないか探したのですが見つからず。。。 今回は1つずつ確認していきました。

JMockitを利用可能にする

  1. 一部のテストは DB のデータをわざわざ変更する手間をかける程のものではないので、モックを作成してテストするようにします。モックのライブラリには JMockit を使用します。

  2. build.gradle を リンク先の内容 に変更します。

  3. Gradle projects View の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

  4. classpath 内での jmockit.jar の位置を JUnit より前になるようにします。メイン画面のメニューから「File」-「Project Structure...」を選択します。

  5. 「Project Structure」ダイアログが表示されます。画面左側のリストから「Project Settings」-「Modules」を選択します。

  6. 画面右側で「Dependencies」タブを選択した後、jmockit のライブラリを junit の上に移動します。移動後「OK」ボタンをクリックしてダイアログを閉じます。

    f:id:ksby:20150912173400p:plain

TestDataResource クラスの変更、testdata/base へのデータの追加

  1. バックアップ対象のテーブルを追加します。src/test/java/ksbysample/common/test の下の TestDataResource.javaリンク先の内容 に変更します。

  2. src/test/resources/testdata/base の下に library_forsearch.csv を作成します。作成後、リンク先の内容 に変更します。

  3. src/test/resources/testdata/base の下の table-ordering.txt を リンク先の内容 に変更します。

LibraryHelper クラスのテストの作成

  1. src/main/java/ksbysample/webapp/lending/helper/library の下の LibraryHelper.java で「Create Test」ダイアログを表示し、テストクラスを作成します。

    f:id:ksby:20150912173855p:plain

    src/test/java/ksbysample/webapp/lending/helper/library の下に LibraryHelperTest.java が作成されます。

  2. src/test/java/ksbysample/webapp/lending/helper/library の下の LibraryHelperTest.javaリンク先の内容 に変更します。

  3. テストを実行します。LibraryHelperTest クラスのクラス名にカーソルを移動し、コンテキストメニューを表示後「Run 'LibraryHelperTest' with Coverage」を選択します。

    テストが成功することが確認できます。

    f:id:ksby:20150912195635p:plain

UrlAfterLoginHelper クラスのテストの作成

  1. src/main/java/ksbysample/webapp/lending/helper/url の下の UrlAfterLoginHelper.java で「Create Test」ダイアログを表示し、テストクラスを作成します。

    f:id:ksby:20150912200248p:plain

    src/test/java/ksbysample/webapp/lending/helper/url の下に UrlAfterLoginHelperTest.java が作成されます。

  2. UrlAfterLoginHelper.getUrlAfterLogin には Authentication インターフェースを持つオブジェクトを渡しますが、Authentication インターフェースの実装クラスには何があるのか IntelliJ IDEA の Diagram 生成機能で調べて見ると以下の画像のクラス構成になっていました。システム稼働時は UsernamePasswordAuthenticationToken クラスが使用されていると思われますが、今回はテストが実行できればよいので TestingAuthenticationToken クラスを使用します。

    f:id:ksby:20150912201840p:plain

  3. src/test/java/ksbysample/webapp/lending/helper/url の下の UrlAfterLoginHelperTest.javaリンク先の内容 に変更します。

  4. テストを実行します。UrlAfterLoginHelperTest クラスのクラス名にカーソルを移動し、コンテキストメニューを表示後「Run 'UrlAfterLoginHelperTest' with Coverage」を選択します。

    テストが成功することが確認できます。

    f:id:ksby:20150912233005p:plain

AdminLibraryService クラスのテストの作成

  1. src/main/java/ksbysample/webapp/lending/web/admin/library の下の AdminLibraryService.java で「Create Test」ダイアログを表示し、テストクラスを作成します。

    f:id:ksby:20150912233729p:plain

    src/test/java/ksbysample/webapp/lending/web/admin/library の下に AdminLibraryServiceTest.java が作成されます。

  2. テストで使用するデータを作成します。src/test/resources の下に ksbysample/webapp/lending/web/admin/library ディレクトリを作成します。

  3. src/test/resources/ksbysample/webapp/lending/web/admin/library の下に SetSelectedLibraryForm_001.yaml を作成します。作成後、リンク先の内容 に変更します。

  4. src/test/resources/ksbysample/webapp/lending/web/admin/library の下に testdata/001 ディレクトリを作成します。

  5. src/test/resources/ksbysample/webapp/lending/web/admin/library/testdata/001 の下に table-ordering.txt, library_forsearch.csv を作成します。作成後、リンク先の内容 に変更します。

  6. src/test/resources/ksbysample/webapp/lending/web/admin/library の下に assertdata/001 ディレクトリを作成します。

  7. src/test/resources/ksbysample/webapp/lending/web/admin/library/assertdata/001 の下に table-ordering.txt, library_forsearch.csv を作成します。作成後、リンク先の内容 に変更します。

  8. src/test/java/ksbysample/webapp/lending/web/admin/library の下の AdminLibraryServiceTest.javaリンク先の内容 に変更します。

AdminLibraryController クラスのテストの作成

  1. src/main/java/ksbysample/webapp/lending/web/admin/library の下の AdminLibraryController.java で「Create Test」ダイアログを表示し、テストクラスを作成します。

    f:id:ksby:20150915014530p:plain

    src/test/java/ksbysample/webapp/lending/web/admin/library の下に AdminLibraryControllerTest.java が作成されます。

  2. src/test/java/ksbysample/webapp/lending/web/admin/library の下の AdminLibraryControllerTest.javaリンク先の内容 に変更します。

全てのテストが成功するか確認する

  1. 最後に全てのテストが成功するか確認します。Project View のルートでコンテキストメニューを表示して「Run 'Tests in 'ksbysample...' with Coverage」を選択します。

    テストが実行され、全て成功することが確認できます。

    f:id:ksby:20150915045926p:plain

  2. clean タスクの実行→「Rebuild Project」メニューの実行→build タスクの実行を行い、"BUILD SUCCESSFUL" のメッセージが出力されることも確認します。

    f:id:ksby:20150915050316p:plain

commit、Push、Pull Request、マージ

  1. ここまでの変更内容を commit します。

  2. コマンドラインから以下のコマンドを実行して commit を1つにまとめます。

    > git rebase -i HEAD~7
    > git commit --amend -m "#25 検索対象図書館登録画面を作成しました。"

  3. GitHub へ Push、1.0.x-make-admin-library -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-make-admin-library ブランチを削除、をします。

ソースコード

Constant.java

package ksbysample.webapp.lending.config;

public class Constant {

    /* 
     * URL一覧
     */
    public static final String URL_ADMIN_LIBRARY = "/admin/library";

    /* 
     * ログイン後ページのURL
     */
    public static final String URL_AFTER_LOGIN_FOR_ROLE_ADMIN = URL_ADMIN_LIBRARY;

}
  • URL_ADMIN_LIBRARY, URL_AFTER_LOGIN_FOR_ROLE_ADMIN を追加します。

LoginControllerTest.java

■その1

        @Test
        public void 有効なユーザ名とパスワードを入力すればログインに成功する() throws Exception {
            // ログイン前にはログイン後の画面にアクセスできない
            mvc.noauth.perform(get(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("http://localhost/"))
                    .andExpect(unauthenticated());

            // ログインする
            MvcResult result = mvc.noauth.perform(formLogin()
                            .user("id", mvc.MAILADDR_TANAKA_TARO)
                            .password("password", "taro")
            )
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN))
                    .andExpect(authenticated().withUsername(mvc.MAILADDR_TANAKA_TARO))
                    .andReturn();
            HttpSession session = result.getRequest().getSession();
            assertThat(session).isNotNull();

            // ログインしたのでログイン後の画面にアクセスできる
            mvc.noauth.perform(get(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN).session((MockHttpSession) session))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("text/html;charset=UTF-8"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(authenticated().withUsername(mvc.MAILADDR_TANAKA_TARO));

            // ログアウトする
            mvc.noauth.perform(get("/logout").session((MockHttpSession) session))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("/"))
                    .andExpect(unauthenticated());

            // ログアウトしたのでログイン後の画面にアクセスできない
            mvc.noauth.perform(get(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN).session((MockHttpSession) session))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("http://localhost/"))
                    .andExpect(unauthenticated());
        }
  • 有効なユーザ名とパスワードを入力すればログインに成功する() メソッドの以下の点を変更します。
    • メソッド内の WebSecurityConfig.DEFAULT_SUCCESS_URLConstant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN へ変更します。
    • ビュー名までチェックするのはやり過ぎな感じがしたので .andExpect(view().name("loginsuccess")) を削除します。

■その2

        @Test
        public void 次回から自動的にログインするをチェックすれば次はログインしていなくてもログイン後の画面にアクセスできる()
                throws Exception {
            // ログイン前にはログイン後の画面にアクセスできない
            mvc.noauth.perform(get(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("http://localhost/"))
                    .andExpect(unauthenticated());

            // 「次回から自動的にログインする」をチェックしてログインし、remember-me Cookie を生成する
            MockServletContext servletContext = new MockServletContext();
            org.springframework.mock.web.MockHttpServletRequest request
                    = formLogin()
                    .user("id", mvc.MAILADDR_TANAKA_TARO)
                    .password("password", "taro")
                    .buildRequest(servletContext);
            request.addParameter("remember-me", "true");
            SimpleRequestBuilder simpleRequestBuilder = new SimpleRequestBuilder(request);
            MvcResult result = mvc.noauth.perform(simpleRequestBuilder)
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN))
                    .andExpect(authenticated().withUsername(mvc.MAILADDR_TANAKA_TARO))
                    .andReturn();
            Cookie[] cookie = result.getResponse().getCookies();

            // remember-me Cookie を引き継いでログイン後の画面にアクセスするとアクセスできる
            mvc.noauth.perform(get(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN).cookie(cookie))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("text/html;charset=UTF-8"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(authenticated().withUsername(mvc.MAILADDR_TANAKA_TARO));

            // ログイン画面にアクセスしても有効な remember-me Cookie があればログイン後の画面にリダイレクトする 
            mvc.noauth.perform(get("/").cookie(cookie))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN))
                    .andExpect(authenticated().withUsername(mvc.MAILADDR_TANAKA_TARO));
        }
  • 次回から自動的にログインするをチェックすれば次はログインしていなくてもログイン後の画面にアクセスできる() メソッドの以下の点を変更します。
    • メソッド内の WebSecurityConfig.DEFAULT_SUCCESS_URLConstant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN へ変更します。
    • メソッド内の "/loginsuccess"Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN へ変更します。
    • .andExpect(view().name("loginsuccess")) を削除します。

■その3

        @Test
        public void 有効なユーザ名とパスワードを入力すればログインに成功する() throws Exception {
            mvc.noauth.perform(formLogin()
                            .user("id", mvc.MAILADDR_TANAKA_TARO)
                            .password("password", "taro")
            )
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN))
                    .andExpect(authenticated().withUsername(mvc.MAILADDR_TANAKA_TARO));
        }
  • 有効なユーザ名とパスワードを入力すればログインに成功する() メソッドの以下の点を変更します。
    • メソッド内の WebSecurityConfig.DEFAULT_SUCCESS_URLConstant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN へ変更します。

LibraryControllerTest.java

    @Test
    public void 正しい都道府県を指定した場合には図書館一覧が返る() throws Exception {
        mvc.noauth.perform(get("/webapi/library/getLibraryList?pref=東京都"))
                .andExpect(status().isOk())
                .andExpect(content().contentType("application/json;charset=UTF-8"))
                .andExpect(jsonPath("$.errcode", is(0)))
                .andExpect(jsonPath("$.errmsg", is("")))
                .andExpect(jsonPath("$.content[0].address", startsWith("東京都")))
                .andExpect(jsonPath("$.content[?(@.formal=='国立国会図書館東京本館')]").exists());
    }
  • 正しい都道府県を指定した場合には図書館一覧が返る() メソッドの以下の点を変更します。
    • $.content[?(@.formalName=='国立国会図書館東京本館')]$.content[?(@.formal=='国立国会図書館東京本館')] へ変更します。

RoleAwareAuthenticationSuccessHandler.java

■その1

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.config.WebSecurityConfig;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class RoleAwareAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws ServletException, IOException {
        // ログイン画面以外のURLを指定してアクセスされていた場合には、処理を SavedRequestAwareAuthenticationSuccessHandler へ委譲する
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            super.onAuthenticationSuccess(request, response, authentication);
            return;
        }

        // 特定の権限を持っている場合には対応するURLへリダイレクトする
        String targetUrl = WebSecurityConfig.DEFAULT_SUCCESS_URL;
        GrantedAuthority roleAdmin = new SimpleGrantedAuthority("ROLE_ADMIN");
        if (authentication.getAuthorities().contains(roleAdmin)) {
            // 管理権限 ( ROLE_ADMIN ) を持っている場合には検索対象図書館登録画面へ遷移させる
            targetUrl = Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN;
        }

        clearAuthenticationAttributes(request);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

}
  • targetUrl = "/admin/library";targetUrl = Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN; へ変更します。

■その2

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.helper.url.UrlAfterLoginHelper;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class RoleAwareAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws ServletException, IOException {
        // ログイン画面以外のURLを指定してアクセスされていた場合には、処理を SavedRequestAwareAuthenticationSuccessHandler へ委譲する
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            super.onAuthenticationSuccess(request, response, authentication);
            return;
        }

        String targetUrl = UrlAfterLoginHelper.getUrlAfterLogin(authentication);
        clearAuthenticationAttributes(request);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
    
}
  • targetUrl を決める処理は UrlAfterLoginHelper.getUrlAfterLogin を呼び出すように変更します。

AdminLibraryController.java

package ksbysample.webapp.lending.web.admin.library;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@RequestMapping("/admin/library")
public class AdminLibraryController {

    @Autowired
    private AdminLibraryService adminLibraryService;

    @RequestMapping
    public String index() {
        return "admin/library/library";
    }

    @RequestMapping("/addSearchLibrary")
    public String addSearchLibrary(SetSelectedLibraryForm setSelectedLibraryForm) {
        adminLibraryService.deleteAndInsertLibraryForSearch(setSelectedLibraryForm);
        return "redirect:" + Constant.URL_ADMIN_LIBRARY;
    }
    
}
  • return "redirect:/admin/library";return "redirect:" + Constant.URL_ADMIN_LIBRARY; へ変更します。

UrlAfterLoginHelper.java

package ksbysample.webapp.lending.helper.url;

import ksbysample.webapp.lending.config.Constant;
import ksbysample.webapp.lending.config.WebSecurityConfig;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

public class UrlAfterLoginHelper {

    public static String getUrlAfterLogin(Authentication authentication) {
        String targetUrl = WebSecurityConfig.DEFAULT_SUCCESS_URL;

        // 特定の権限を持っている場合には対応するURLへリダイレクトする
        GrantedAuthority roleAdmin = new SimpleGrantedAuthority("ROLE_ADMIN");
        if (authentication.getAuthorities().contains(roleAdmin)) {
            // 管理権限 ( ROLE_ADMIN ) を持っている場合には検索対象図書館登録画面へ遷移させる
            targetUrl = Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN;
        }

        return targetUrl;
    }

}

LoginController.java

    @RequestMapping
    public String index(HttpServletRequest request, HttpServletResponse response) {
        // 有効な remember-me Cookie が存在する場合にはログイン画面を表示させず自動ログインさせる
        TokenBasedRememberMeServices rememberMeServices
                = new TokenBasedRememberMeServices(WebSecurityConfig.REMEMBERME_KEY, userDetailsService);
        rememberMeServices.setCookieName("remember-me");
        Authentication rememberMeAuth = rememberMeServices.autoLogin(request, response);
        if (rememberMeAuth != null) {
            SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
            return "redirect:" + WebSecurityConfig.DEFAULT_SUCCESS_URL;
        }

        return "login";
    }
  • return "redirect:" + WebSecurityConfig.DEFAULT_SUCCESS_URL;return "redirect:" + UrlAfterLoginHelper.getUrlAfterLogin(rememberMeAuth); へ変更します。

build.gradle

dependencies {
    def jdbcDriver = "org.postgresql:postgresql:9.4-1201-jdbc41"

    // spring-boot-gradle-plugin によりバージョン番号が自動で設定されるもの
    // Appendix E. Dependency versions ( http://docs.spring.io/spring-boot/docs/current/reference/html/appendix-dependency-versions.html ) 参照
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile("org.thymeleaf.extras:thymeleaf-extras-springsecurity3")
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("org.springframework.boot:spring-boot-starter-velocity")
    compile("org.springframework.boot:spring-boot-starter-mail")
    compile("org.springframework.boot:spring-boot-starter-security")
    compile("org.springframework.boot:spring-boot-starter-redis")
    compile("org.codehaus.janino:janino")
    testCompile("org.springframework.boot:spring-boot-starter-test")
    // (ここから) gradle でテストを実行した場合に spring-security-test-4.0.1.RELEASE.jar しか classpath に指定されず
    // テストが失敗したため、3.2.7.RELEASE を明記している
    testCompile("org.springframework.security:spring-security-core:3.2.7.RELEASE")
    testCompile("org.springframework.security:spring-security-web:3.2.7.RELEASE")
    // (ここまで) ------------------------------------------------------------------------------------------------------
    testCompile("org.springframework.security:spring-security-test:4.0.1.RELEASE")
    testCompile("org.yaml:snakeyaml")

    // spring-boot-gradle-plugin によりバージョン番号が自動で設定されないもの
    compile("${jdbcDriver}")
    compile("org.seasar.doma:doma:2.3.1")
    compile("org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16")
    compile("org.apache.commons:commons-lang3:3.4")
    compile("org.projectlombok:lombok:1.16.4")
    compile("com.google.guava:guava:18.0")
    compile("org.springframework.session:spring-session:1.0.1.RELEASE")
    compile("org.simpleframework:simple-xml:2.7.1")
    compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.6.1")
    compile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.5.3")
    testCompile("org.dbunit:dbunit:2.5.1")
    testCompile("com.icegreen:greenmail:1.4.1")
    testCompile("org.assertj:assertj-core:3.1.0")
    testCompile("com.jayway.jsonpath:json-path:2.0.0")
    testCompile("org.jmockit:jmockit:1.19")

    // for Doma-Gen
    domaGenRuntime("org.seasar.doma:doma-gen:2.3.1")
    domaGenRuntime("${jdbcDriver}")
}
  • testCompile("org.jmockit:jmockit:1.19") を追加します。

TestDataResource.java

@Component
public class TestDataResource extends ExternalResource {

    private final String TESTDATA_DIR = "src/test/resources/testdata/base";
    private final String BACKUP_FILE_NAME = "ksbylending_backup";
    private final List<String> BACKUP_TABLES = Arrays.asList(
            "user_info"
            , "user_role"
            , "library_forsearch"
    );
  • BACKUP_TABLES の配列に "library_forsearch" を追加します。

testdata/base/table-ordering.txt, library_forsearch.csv

■table-ordering.txt

user_info
user_role
library_forsearch
  • library_forsearch を追加します。

■library_forsearch.csv

systemid,formal
  • 初期データは何も登録しないようにします。

LibraryHelperTest.java

package ksbysample.webapp.lending.helper.library;

import ksbysample.webapp.lending.Application;
import ksbysample.webapp.lending.dao.LibraryForsearchDao;
import ksbysample.webapp.lending.entity.LibraryForsearch;
import mockit.Delegate;
import mockit.Injectable;
import mockit.NonStrictExpectations;
import mockit.Tested;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class LibraryHelperTest {

    @Tested
    private LibraryHelper libraryHelper;

    @Injectable
    private LibraryForsearchDao libraryForsearchDao;

    @Test
    public void testGetSelectedLibrary_図書館が選択されていない場合() throws Exception {
        new NonStrictExpectations() {{
            libraryForsearchDao.selectSelectedLibrary(); result = null;
        }};

        String result = libraryHelper.getSelectedLibrary();
        assertThat(result).isEqualTo("※図書館が選択されていません");
    }

    @Test
    public void testGetSelectedLibrary_図書館が選択されている場合() throws Exception {
        new NonStrictExpectations() {{
            libraryForsearchDao.selectSelectedLibrary();
            result = new Delegate() {
                LibraryForsearch aDelegateMethod() {
                    LibraryForsearch libraryForsearch = new LibraryForsearch();
                    libraryForsearch.setSystemid("System_Id");
                    libraryForsearch.setFormal("図書館名");
                    return libraryForsearch;
                }
            };
        }};

        String result = libraryHelper.getSelectedLibrary();
        assertThat(result).isEqualTo("選択中:図書館名");
    }

}
  • DB にデータをセットして取得するのではなく、libraryForsearchDao をモックにして selectSelectedLibrary メソッドの戻り値を変更してテストするようにしています。
  • private LibraryHelper libraryHelper; は通常 @Autowired アノテーションを付加しますが、今回は内部のフィールドにモッククラスをインジェクションさせるので @Tested アノテーションを付加して JMockitインスタンスを生成してもらいます。@Tested 以外に @Autowired も付加すると Spring の DIコンテナに生成された LibraryHelper クラスのシングルインスタンスにモックがインジェクションされてしまい他のテストが正常に動作しなくなるので要注意です。

UrlAfterLoginHelperTest.java

package ksbysample.webapp.lending.helper.url;

import ksbysample.webapp.lending.Application;
import ksbysample.webapp.lending.config.Constant;
import ksbysample.webapp.lending.config.WebSecurityConfig;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class UrlAfterLoginHelperTest {

    @Test
    public void testGetUrlAfterLogin_管理権限がある場合() throws Exception {
        Authentication authentication = new TestingAuthenticationToken("test", "test", "ROLE_ADMIN", "ROLE_USER");
        String url = UrlAfterLoginHelper.getUrlAfterLogin(authentication);
        assertThat(url).isEqualTo(Constant.URL_AFTER_LOGIN_FOR_ROLE_ADMIN);
    }

    @Test
    public void testGetUrlAfterLogin_ユーザ権限しかない場合() throws Exception {
        Authentication authentication = new TestingAuthenticationToken("test", "test", "ROLE_USER");
        String url = UrlAfterLoginHelper.getUrlAfterLogin(authentication);
        assertThat(url).isEqualTo(WebSecurityConfig.DEFAULT_SUCCESS_URL);
    }

}

SetSelectedLibraryForm_001.yaml

!!ksbysample.webapp.lending.web.admin.library.SetSelectedLibraryForm
systemid: Tokyo_Test
formal: テスト図書館

testdata/001/table-ordering.txt, library_forsearch.csv

■table-ordering.txt

library_forsearch

■library_forsearch.csv

systemid,formal
Kanagawa_Sample,図書館サンプル

assertdata/001/table-ordering.txt, library_forsearch.csv

■table-ordering.txt

library_forsearch

■library_forsearch.csv

systemid,formal
Tokyo_Test,テスト図書館

AdminLibraryServiceTest.java

package ksbysample.webapp.lending.web.admin.library;

import ksbysample.common.test.TableDataAssert;
import ksbysample.common.test.TestDataLoader;
import ksbysample.common.test.TestDataLoaderResource;
import ksbysample.common.test.TestDataResource;
import ksbysample.webapp.lending.Application;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.csv.CsvDataSet;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.yaml.snakeyaml.Yaml;

import javax.sql.DataSource;
import java.io.File;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class AdminLibraryServiceTest {

    // テストデータ
    private SetSelectedLibraryForm setSelectedLibraryForm_001
            = (SetSelectedLibraryForm) new Yaml().load(getClass().getResourceAsStream("SetSelectedLibraryForm_001.yaml"));

    @Rule
    @Autowired
    public TestDataResource testDataResource;

    @Rule
    @Autowired
    public TestDataLoaderResource testDataLoaderResource;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private AdminLibraryService adminLibraryService;
    
    @Test
    @TestDataLoader("src/test/resources/ksbysample/webapp/lending/web/admin/library/testdata/001")
    public void testDeleteAndInsertLibraryForSearch() throws Exception {
        adminLibraryService.deleteAndInsertLibraryForSearch(setSelectedLibraryForm_001);

        IDataSet dataSet = new CsvDataSet(new File("src/test/resources/ksbysample/webapp/lending/web/admin/library/assertdata/001"));
        TableDataAssert tableDataAssert = new TableDataAssert(dataSet, dataSource);
        tableDataAssert.assertEquals("library_forsearch", null);
    }

}
  • testDeleteAndInsertLibraryForSearch は以下の順でテストを実行します。
    1. @TestDataLoader アノテーションに指定したテストデータを DB にロードします。
    2. adminLibraryService.deleteAndInsertLibraryForSearch でデータを更新します。
    3. tableDataAssert.assertEquals で library_forsearch テーブルのデータが CSV ファイルの内容と同じかチェックします。

AdminLibraryControllerTest.java

package ksbysample.webapp.lending.web.admin.library;

import ksbysample.common.test.*;
import ksbysample.webapp.lending.Application;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.csv.CsvDataSet;
import org.junit.Rule;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.yaml.snakeyaml.Yaml;

import javax.sql.DataSource;
import java.io.File;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(Enclosed.class)
public class AdminLibraryControllerTest {

    @RunWith(SpringJUnit4ClassRunner.class)
    @SpringApplicationConfiguration(classes = Application.class)
    @WebAppConfiguration
    public static class 検索対象図書館登録画面の初期表示のテスト {

        @Rule
        @Autowired
        public TestDataResource testDataResource;

        @Rule
        @Autowired
        public TestDataLoaderResource testDataLoaderResource;

        @Rule
        @Autowired
        public SecurityMockMvcResource mvc;

        @Test
        public void 管理権限を持つユーザは検索対象図書館登録画面を表示できる_図書館未選択時() throws Exception {
            mvc.authTanakaTaro.perform(get("/admin/library"))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("text/html;charset=UTF-8"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(xpath("//p[@class='navbar-text noselected-library']").string("※図書館が選択されていません"));
        }

        @Test
        @TestDataLoader("src/test/resources/ksbysample/webapp/lending/web/admin/library/testdata/001")
        public void 管理権限を持つユーザは検索対象図書館登録画面を表示できる_図書館選択時() throws Exception {
            mvc.authTanakaTaro.perform(get("/admin/library"))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType("text/html;charset=UTF-8"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(xpath("//p[@class='navbar-text selected-library']").string("選択中:図書館サンプル"));
        }

        @Test
        public void 管理権限のないユーザは検索対象図書館登録画面を表示できない() throws Exception {
            mvc.authSuzukiHanako.perform(get("/admin/library"))
                    .andExpect(status().isForbidden());
        }

    }

    @RunWith(SpringJUnit4ClassRunner.class)
    @SpringApplicationConfiguration(classes = Application.class)
    @WebAppConfiguration
    public static class 検索ボタンクリック時のテスト {

        // テストデータ
        private SetSelectedLibraryForm setSelectedLibraryForm_001
                = (SetSelectedLibraryForm) new Yaml().load(getClass().getResourceAsStream("SetSelectedLibraryForm_001.yaml"));

        @Rule
        @Autowired
        public TestDataResource testDataResource;

        @Rule
        @Autowired
        public TestDataLoaderResource testDataLoaderResource;

        @Autowired
        private DataSource dataSource;

        @Rule
        @Autowired
        public SecurityMockMvcResource mvc;

        @Test
        @TestDataLoader("src/test/resources/ksbysample/webapp/lending/web/admin/library/testdata/001")
        public void 管理権限を持つユーザが検索ボタンをクリックすると図書館を登録できる() throws Exception {
            mvc.authTanakaTaro.perform(TestHelper.postForm("/admin/library/addSearchLibrary", this.setSelectedLibraryForm_001).with(csrf()))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("/admin/library"))
                    .andExpect(model().hasNoErrors());

            IDataSet dataSet = new CsvDataSet(new File("src/test/resources/ksbysample/webapp/lending/web/admin/library/assertdata/001"));
            TableDataAssert tableDataAssert = new TableDataAssert(dataSet, dataSource);
            tableDataAssert.assertEquals("library_forsearch", null);
        }

    }

}
  • 管理権限を持つユーザが検索ボタンをクリックすると図書館を登録できる() テストメソッドでは .with(csrf()) を忘れないようにしましょう。これがないと POST でリクエストを送信した時に 403 が返ります。( 忘れていてちょっと苦労しました )

履歴

2015/09/15
初版発行。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( 番外編 )( Git for Windows を 1.9.5 → 2.5.1 へ )

Git for Windows の 2.5.1 がリリースされていたので、https://git-for-windows.github.io/ からインストーラーをダウンロードしてインストールします。

  1. https://github.com/git-for-windows/git/releases/tag/v2.5.1.windows.1 のページ下部の「Downloads」から Git-2.5.1-64-bit.exe をクリックしてインストーラーをダウンロードします。

  2. Git-2.5.1-64-bit.exe を実行します。

  3. 「Git 2.5.1 Setup」ダイアログが表示されます。[Next >]ボタンをクリックします。

  4. 「Information」画面が表示されます。[Next >]ボタンをクリックします。

  5. 「Select Destination Location」画面が表示されます。インストール先を "C:\Git\2.5.1" へ変更した後、[Next >]ボタンをクリックします。

  6. 「Select Components」画面が表示されます。全てのチェックを外した後 ( どの機能も入って欲しくなかったので )、[Next >]ボタンをクリックします。

  7. 「Select Start Menu Folder」画面が表示されます。画面下部の「Don't create a Start Menu folder」をチェックした後、[Next >]ボタンをクリックします。

  8. 「Adjusting your PATH environment」画面が表示されます。中央の「Use Git from the Windows Command Prompt」を選択した後、[Next >]ボタンをクリックします。

  9. 「Configuring the line ending conversions」画面が表示されます。何も変更せずに [Next >]ボタンをクリックします。

  10. 「Configuring the terminal emulator to use with Git Bash」画面が表示されます。自分はコマンドプロンプトConEmu を使用していてそちらの方が使い勝手がよいので、下の「Use Windows'default console window」を選択した後、[Next >]ボタンをクリックします。

  11. 「Configuring experimental performance tweaks」画面が表示されます。よく分からない設定項目なのとデフォルトではチェックされていないことから、今回は何も変更せずに [Next >]ボタンをクリックします。

  12. 「Installing」画面が表示されてインストールが開始されます。

  13. インストールが完了すると「Completing the Git Setup Wizard」のメッセージが表示された画面が表示されます。中央の「View ReleaseNotes.html」のチェックを外した後、「Finish」ボタンをクリックしてインストーラーを終了します。

環境変数 Path の値を確認したところインストーラーが C:\Git\1.9.5\bin → C:\Git\2.5.1\cmd へ変更していました。また C:\Git\1.9.5 も自動的にアンインストールされていました。。。 ( ディレクトリの中がほぼ空っぽでした ) 念のため切り戻せるように前のバージョンも残しておこうと思っていたのですが、要注意ですね。C:\Git\1.9.5 ディレクトリはアンインストールされてしまったので削除します。

コマンドプロンプトを起動して git のバージョンが 2.5.1 になっていることを確認します。

C:\Users\root>git --version
git version 2.5.1.windows.1

C:\Users\root>

IntelliJ IDEA が使用する git.exe もインストールした 2.5.1 のものになるよう設定を変更します。

  1. IntelliJ IDEA のメインメニューから「File」->「Settings」を選択します。

  2. 「Settings」ダイアログが表示されますので、画面左側で「Version Control」->「Git」を選択します。画面右側の一番上に「Path to Git executable」という項目が表示されますので、"C:\Git\2.5.1\cmd\git.exe" に変更した後「OK」ボタンをクリックします。

    f:id:ksby:20150906232306p:plain

コマンドプロンプトでの動作を確認してみました。

  • git log は 1.9.5 の時は文字化けして途中からメッセージが表示されなくなる問題があったのですが、2.5.1 では解消されてログが全て表示されるようになっていました。
  • git rebase -i HEAD~2 で rebase しようとしたら、日本語が文字化けして表示されませんでした。自分は rebase をコマンドラインでしか実施しないので、これはちょっと困りました。。。

    f:id:ksby:20150908000348p:plain

日本語を表示させる方法がないかいろいろ調べたり試したりしてみて、解決策を見つけました。以下の方法です。

  • http://www.fosshub.com/ConEmu.html の「Download ConEmu Preview, Installer (32-bit, 64-bit)」リンクから ConEmuSetup.150813c.exe をダウンロードしてインストールします。古い ConEmu だと git-bash.exe で新規 Console を作成した時に別 Window が開いてしまったので最新版をインストールします。
  • ConEmu 起動後に C:\Git\2.5.1\git-bash.exe で新規 Console を作成して、そこで rebase します。

※git-bash.exe を直接実行しても構わないのですが、ConEmu 上で実行できるようにしておくと画面を上下2分割して別々のコマンドを実行できたりするので個人的には気に入っています。

ConEmu のインストールはインストーラの指示に従ってインストールしてください。特に難しいことはありません。

インストールしたら ConEmu を起動した後、画面右上の「Create new console」ボタンをクリックします。

f:id:ksby:20150908004320p:plain

「ConEmu」ダイアログが表示されたら下の画像の赤枠の部分に "C:\Git\2.5.1\git-bash.exe" を指定した後、「Start」ボタンをクリックします。

f:id:ksby:20150908004449p:plain

ConEmu のウィンドウ内で git-bash.exe が起動します。

f:id:ksby:20150908005122p:plain

プロジェクトのディレクトリに移動して rebase してみます。

f:id:ksby:20150908005840p:plain

今度は日本語が文字化けせずに表示されました。ちなみに Web の情報では .vimrc を作成して文字コードを指定する方法が紹介されている場合がありますが、.vimrc は作成していません。

f:id:ksby:20150908010203p:plain

日本語を入力しても問題ないか確認してみます。git rebase -i HEAD~2 を実行後、エディタが起動したら最初の "pick"→"e" へ変更して保存します。

f:id:ksby:20150908011446p:plain

コンソールに戻りますので git commit --amend を実行します。

f:id:ksby:20150908012137p:plain

再びエディタになるので、先頭に "テスト" の文字を入力して保存します。

f:id:ksby:20150908012332p:plain

コンソールに戻り、入力した "テスト" の文字が表示されています。

f:id:ksby:20150908012726p:plain

git log --oneline でログを表示させてみましたが、こちらも問題なく "テスト" の文字が表示されていました。

f:id:ksby:20150908012907p:plain

問題なさそうですので、git reset --hard ORIG_HEAD を実行して元に戻します。

1.9.5 と比較しても問題なく使えそうですので、このまま 2.5.1 で作業を進めたいと思います。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その20 )( 検索対象図書館登録画面の作成2 )

概要

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その19 )( 検索対象図書館登録画面の作成 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 検索対象図書館登録画面 ( 管理者のみ ) の作成

参照したサイト・書籍

  1. Spring MVC and Thymeleaf: how to access data from templates
    http://www.thymeleaf.org/doc/articles/springmvcaccessdata.html

    • Thymeleaf の公式サイトのページです。Thymeleaf から Spring の Bean を呼び出す方法を調査した時に、このページの中の「Spring beans」の記述を参照しました。
  2. Tutorial: Using Thymeleaf - 18 Appendix B: Expression Utility Objects
    http://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf.html#appendix-b-expression-utility-objects

    • Thymeleaf の公式サイトのチュートリアルで、Thymeleaf テンプレート上で使用可能なユーティリティオブジェクトが記載されています。
    • 今回は変数にセットされている文字列が指定された文字列で始まっているかチェックするために ${#strings.startsWith(...)} を使用しました。
  3. Spring Framework Advent Calendar 2011 part.7 - Spring Security で認証成功時に条件によって遷移先を変える
    http://d.hatena.ne.jp/ocs/20111207/1323269801

    • ログインしたユーザが持つ権限に応じて遷移先の画面を変える方法を調査していた時に参照しました。
  4. Spring Boot Security Application
    http://kielczewski.eu/2014/12/spring-boot-security-application/

    • 特定の権限が付与されたユーザしか URL にアクセスできないようにする方法を調査した時に参照しました。
    • Controller クラスに @PreAuthorize アノテーションを付与することでアクセス制御できるようになります。ただしこの記事では権限の文字列を "ADMIN" と書いていますが、今回実装した際には "ROLE_ADMIN" と書かないとアクセス制御できませんでした。どこかのタイミングで Spring Security の仕様変更があったものと思われます。
  5. How do I add a thymeleaf dialect to spring boot?
    http://stackoverflow.com/questions/23531580/how-do-i-add-a-thymeleaf-dialect-to-spring-boot

    • 今回は作成していませんが Thymeleaf で Dialect を作成した時に Spring Boot で使用できるようにするための方法です。
    • AutoConfiguration 対象になっている Dialect へのリンクも貼られており、Dialect を実装する時の参考になりそうでしたのでメモとして残しておきます。

目次

  1. 選択した図書館を保存するテーブルを作成する
  2. 図書館を1つ選択してDBに登録する
  3. 選択中の図書館を画面上部のナビゲーションバーに表示する
  4. 管理権限 ( ROLE_ADMIN ) を持つユーザがログインした場合には最初に検索対象図書館登録画面に遷移させる
  5. 管理権限 ( ROLE_ADMIN ) を持たないユーザが検索対象図書館登録画面にアクセスしようとした場合にはエラーにする
  6. 次回は。。。

手順

選択した図書館を保存するテーブルを作成する

図書館を保存するテーブルを作成していなかったので作成します。

/sql の下の create_table.sqlリンク先の内容 に変更します。

コマンドプロンプトから以下のコマンドを実行して library_forsearch テーブルを作成します。

C:\project-springboot\ksbysample-webapp-lending>psql -U ksbylending_user ksbylending
ユーザ ksbylending_user のパスワード:
psql (9.4.1)
"help" でヘルプを表示します.

ksbylending=> create table library_forsearch (
ksbylending(>       systemid              text primary key
ksbylending(>     , formal                text
ksbylending(> );
CREATE TABLE
ksbylending=> \d library_forsearch
テーブル "public.library_forsearch"
   列     |  型  |  修飾語
----------+------+----------
 systemid | text | not null
 formal   | text |
インデックス:
    "library_forsearch_pkey" PRIMARY KEY, btree (systemid)


ksbylending=> \q

C:\project-springboot\ksbysample-webapp-lending>

最後に Gradle projects view から gen タスクを実行し、Entity, Dao クラスを作り直します。

図書館を1つ選択してDBに登録する

  1. src/main/java/ksbysample/webapp/lending/dao の下の LibraryForsearchDao.javaリンク先のその1の内容 に変更します。

  2. src/main/resources/META-INF/ksbysample/webapp/lending/dao/LibraryForsearchDao の下に deleteAll.sql を作成します。作成後、リンク先の内容 に変更します。

  3. src/main/java/ksbysample/webapp/lending/web/admin/library の下に SetSelectedLibraryForm.java を作成します。作成後、リンク先の内容 に変更します。

  4. src/main/java/ksbysample/webapp/lending/web/admin/library の下に AdminLibraryService.java を作成します。作成後、リンク先の内容 に変更します。

  5. src/main/java/ksbysample/webapp/lending/web/admin/library の下の AdminLibraryController.javaリンク先のその1の内容 に変更します。

  6. src/main/resources/templates/admin/library の下の library.html を リンク先の内容 に変更します。

  7. 動作確認します。Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  8. library_forsearch テーブルにデータが登録されていないことを確認します。

    f:id:ksby:20150906040759p:plain

  9. ブラウザを起動し http://localhost:8080/admin/library へアクセスします。最初はログイン画面が表示されますので ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

  10. 検索対象図書館登録画面が表示されたら、"東京都" で検索した後 "著作権情報センター資料室" の「選択」ボタンをクリックします。

    f:id:ksby:20150906041150p:plain

    選択後は検索前の画面に戻ります。

    f:id:ksby:20150906041435p:plain

  11. library_forsearch テーブルを見ると選択した図書館が保存されていることが確認できます。

    f:id:ksby:20150906041609p:plain

  12. 何度か別の図書館を選択して保存されることを確認した後、Ctrl+F2 を押して Tomcat を停止します。

  13. 一旦 commit します。

選択中の図書館を画面上部のナビゲーションバーに表示する

ログイン画面以外の画面でも表示させるので Controller クラスのメソッド内で表示するデータをセットするのではなく、表示するデータを返す Helper クラスを作成して Thymeleaf テンプレートファイルからは Helper クラスのメソッドを呼び出して表示するデータを取得するようにします。

  1. src/main/java/ksbysample/webapp/lending/dao の下の LibraryForsearchDao.javaリンク先のその2の内容 に変更します。

  2. src/main/resources/META-INF/ksbysample/webapp/lending/dao/LibraryForsearchDao の下に selectSelectedLibrary.sql を作成します。作成後、リンク先の内容 に変更します。

  3. src/main/java/ksbysample/webapp/lending/helper の下に library パッケージを作成します。

  4. src/main/java/ksbysample/webapp/lending/helper/library の下に LibraryHelper.java を作成します。作成後、リンク先の内容 に変更します。

  5. src/main/resources/templates/common の下の mainparts.html を リンク先のその1の内容 に変更します。

  6. 動作確認します。library_forsearch テーブルにデータが登録されている場合には削除しておきます。

  7. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  8. ブラウザを起動し http://localhost:8080/admin/library へアクセスします。図書館が選択されていない時にはナビゲーションバーにオレンジ色の文字で「※図書館が選択されていません」と表示されます。

    f:id:ksby:20150906105935p:plain

  9. "東京都" で検索して表示された図書館一覧から「国立国会図書館東京本館」を選択します。

    f:id:ksby:20150906110527p:plain

    図書館が選択されるとナビゲーションバーに今度は白い文字で選択中の図書館が表示されます。

    f:id:ksby:20150906110743p:plain

  10. 何度か別の図書館を選択して表示が切り替わることを確認した後、Ctrl+F2 を押して Tomcat を停止します。

  11. 一旦 commit します。

管理権限 ( ROLE_ADMIN ) を持つユーザがログインした場合には最初に検索対象図書館登録画面に遷移させる

  1. Spring Security で認証成功時に条件によって遷移先を変える の記事によると SavedRequestAwareAuthenticationSuccessHandler クラスを継承したクラスを作成して onAuthenticationSuccess メソッドを実装すれば実現できるようです。

    • IntelliJ IDEA の Diagram 生成機能で SavedRequestAwareAuthenticationSuccessHandler クラスのある org.springframework.security.web.authentication パッケージのクラス関連の Diagram を作成してみると以下のように表示されました。

      f:id:ksby:20150906150735p:plain

    • SavedRequestAwareAuthenticationSuccessHandler クラスのソースを見てみましたが onAuthenticationSuccess メソッドの中で sendRedirect している部分がありました。確かにこのメソッドでリダイレクトできるようです。

    • onAuthenticationSuccess メソッドを見ていて気づきましたが、非認証時にログイン画面以外の URL ( 例 http://localhost:8080/admin/library ) を指定すると一旦ログイン画面が表示されて、ログインすると指定した URL に遷移します。この機能はこのメソッドで実現されていました。一旦セッションの "SPRING_SECURITY_SAVED_REQUEST" というキーに指定された URL が保存されて ( HttpSessionRequestCache 参照 )、認証が成功して SPRING_SECURITY_SAVED_REQUEST に URL が保存されていたらその URL にリダイレクトされる仕組みになっていました。だからクラス名が "SavedRequestAware~" というネーミングなのですね ( 最初に見た時 SavedRequest って何だろう?と思いました )。

  2. 今回は以下の方針で実装します。

    • 非認証時にログイン画面以外の URL を指定してアクセスされた場合には、管理権限を持つユーザでログインされた場合でも指定された URL へリダイレクトします。
    • 最初にログイン画面にアクセスしてログインした場合に、ログインユーザが管理権限 ( ROLE_ADMIN ) を持つ場合には検索対象図書館登録画面 ( /admin/library ) に遷移させます。管理権限を持たない場合には /loginsuccess へ遷移させます。
  3. src/main/java/ksbysample/webapp/lending/security の下に RoleAwareAuthenticationSuccessHandler.java を作成します。作成後、リンク先の内容 に変更します。

  4. src/main/java/ksbysample/webapp/lending/config の下の WebSecurityConfig.javaリンク先の内容 に変更します。

  5. src/test/resources/testdata/base の下の user_info.csvリンク先の内容 に変更します。また DB の user_info テーブルのデータも直接修正しておきます。

  6. 動作確認します。ブラウザを起動している場合には一旦終了させます。

  7. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  8. ブラウザを起動し http://localhost:8080/ へアクセスします。ログイン画面が表示されますので ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

    tanaka.taro@sample.com は ROLE_ADMIN を付与されているので検索対象図書館登録画面 ( /admin/library ) に遷移します。

    f:id:ksby:20150906161940p:plain

    画面右上の「ログアウト」リンクをクリックしてログアウトします。

  9. ログイン画面に戻りますので、今度は ID に "suzuki.hanako@test.co.jp"、Password に "hanako" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

    suzuki.hanako@test.co.jp は ROLE_ADMIN を付与されていないので /loginsuccess に遷移します。

    f:id:ksby:20150906164320p:plain

    ブラウザを一旦終了させます。

  10. 今度はログイン画面以外の URL を指定した場合の動作を確認します。ブラウザを起動し http://localhost:8080/loginsuccess へアクセスします。

    認証していないのでログイン画面が表示されます。ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

    f:id:ksby:20150906164549p:plain

    tanaka.taro@sample.com は管理権限 ( ROLE_ADMIN ) を付与されていますが、今回は URL を指定してアクセスされたので /loginsuccess へ遷移しました。

    f:id:ksby:20150906164921p:plain

  11. Ctrl+F2 を押して Tomcat を停止します。

  12. 一旦 commit します。

管理権限 ( ROLE_ADMIN ) を持たないユーザが検索対象図書館登録画面にアクセスしようとした場合にはエラーにする

@PreAuthorize アノテーションを付与して管理権限 ( ROLE_ADMIN ) を付与されていないユーザが /admin/library にアクセスした場合にはエラーになるようにします。

  1. 最初に @PreAuthorize アノテーションを使用可能にするための @EnableGlobalMethodSecurity アノテーションを付与します。src/main/java/ksbysample/webapp/lending の Application.javaリンク先の内容 に変更します。

  2. src/main/java/ksbysample/webapp/lending/web/admin/library の下の AdminLibraryController.javaリンク先のその2の内容 に変更します。

  3. 動作確認します。ブラウザを起動している場合には一旦終了させます。

  4. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  5. ブラウザを起動し http://localhost:8080/ へアクセスします。ログイン画面が表示されますので ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

    tanaka.taro@sample.com では検索対象図書館登録画面が表示されて、検索及び選択ができることを確認します。確認後、画面右上の「ログアウト」リンクをクリックしてログアウトします。

  6. ログイン画面に戻りますので、今度は ID に "suzuki.hanako@test.co.jp"、Password に "hanako" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

    ログインすると /loginsuccess へ遷移します。ブラウザのアドレスバーの URL を http://localhost:8080/admin/library に変更します。

    suzuki.hanako@test.co.jp は管理権限を付与されていないので、HTTPステータスコードが 403 ( Forbidden ) が返りエラー画面が表示されます。

    f:id:ksby:20150906181131p:plain

  7. Ctrl+F2 を押して Tomcat を停止します。

  8. 一旦 commit します。

次回は。。。

Git for Windows が 2.5.1 にバージョンアップしたので、一旦番外編を入れてインストールする予定です。

その後に「検索対象図書館登録画面の作成3」としてテストを作成した後、マージします。

ソースコード

create_table.sql

create table user_info (
    user_id                 bigserial primary key
    , username              varchar(32) not null
    , password              varchar(256) not null
    , mail_address          varchar(256) not null
    , enabled               smallint not null default 1
    , cnt_badcredentials    smallint not null default 0
    , expired_account       timestamp not null default now() + interval '90 day'
    , expired_password      timestamp not null default now() + interval '30 day'
);
create index user_info_idx_01 on user_info(mail_address);

create table user_role (
    role_id                 bigserial primary key
    , user_id               bigint not null references user_info(user_id) on delete cascade
    , role                  varchar(32) not null
);

create table lending_app (
    lending_app_id          bigserial primary key
    , status                varchar(1) not null
    , lending_user_id       bigint not null references user_info(user_id)
    , approval_user_id      bigint not null references user_info(user_id)
);

create table lending_book (
    lending_book_id         bigserial primary key
    , lending_app_id        bigint not null references lending_app(lending_app_id) on delete cascade
    , isbn                  varchar(17) not null
    , book_name             varchar(128) not null
    , lending_state         varchar(16)
    , lending_app_flg       varchar(1)
    , lending_app_reason    varchar(128)
    , approval_result       varchar(1)
    , approval_reason       varchar(128)
);

create table library_forsearch (
      systemid              text primary key
    , formal                text
);
  • create table library_forsearch ( ... ); を追加します。

LibraryForsearchDao.java

■その1

package ksbysample.webapp.lending.dao;

import ksbysample.webapp.lending.entity.LibraryForsearch;
import ksbysample.webapp.lending.util.doma.ComponentAndAutowiredDomaConfig;
import org.seasar.doma.Dao;
import org.seasar.doma.Delete;
import org.seasar.doma.Insert;
import org.seasar.doma.Select;
import org.seasar.doma.Update;

/**
 */
@Dao
@ComponentAndAutowiredDomaConfig
public interface LibraryForsearchDao {

    /**
     * @param systemid
     * @return the LibraryForsearch entity
     */
    @Select
    LibraryForsearch selectById(String systemid);

    /**
     * @param entity
     * @return affected rows
     */
    @Insert
    int insert(LibraryForsearch entity);

    /**
     * @param entity
     * @return affected rows
     */
    @Update
    int update(LibraryForsearch entity);

    /**
     * @param entity
     * @return affected rows
     */
    @Delete
    int delete(LibraryForsearch entity);

    @Delete(sqlFile = true)
    int deleteAll();
}

■その2

package ksbysample.webapp.lending.dao;

import ksbysample.webapp.lending.entity.LibraryForsearch;
import ksbysample.webapp.lending.util.doma.ComponentAndAutowiredDomaConfig;
import org.seasar.doma.Dao;
import org.seasar.doma.Delete;
import org.seasar.doma.Insert;
import org.seasar.doma.Select;
import org.seasar.doma.Update;

/**
 */
@Dao
@ComponentAndAutowiredDomaConfig
public interface LibraryForsearchDao {

    /**
     * @param systemid
     * @return the LibraryForsearch entity
     */
    @Select
    LibraryForsearch selectById(String systemid);

    @Select
    LibraryForsearch selectSelectedLibrary();
    
    /**
     * @param entity
     * @return affected rows
     */
    @Insert
    int insert(LibraryForsearch entity);

    /**
     * @param entity
     * @return affected rows
     */
    @Update
    int update(LibraryForsearch entity);

    /**
     * @param entity
     * @return affected rows
     */
    @Delete
    int delete(LibraryForsearch entity);

    @Delete(sqlFile = true)
    int deleteAll();
}
  • LibraryForsearch selectSelectedLibrary(); を追加します。

deleteAll.sql

delete from
  library_forsearch

SetSelectedLibraryForm.java

package ksbysample.webapp.lending.web.admin.library;

import lombok.Data;

@Data
public class SetSelectedLibraryForm {

    private String systemid;

    private String formal;
    
}

AdminLibraryService.java

package ksbysample.webapp.lending.web.admin.library;

import ksbysample.webapp.lending.dao.LibraryForsearchDao;
import ksbysample.webapp.lending.entity.LibraryForsearch;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class AdminLibraryService {

    @Autowired
    private LibraryForsearchDao libraryForsearchDao;
    
    public void deleteAndInsertLibraryForSearch(SetSelectedLibraryForm setSelectedLibraryForm) {
        // library_forsearch テーブルのデータを全て削除する
        libraryForsearchDao.deleteAll();

        // 選択された図書館を登録する
        LibraryForsearch libraryForsearch = new LibraryForsearch();
        BeanUtils.copyProperties(setSelectedLibraryForm, libraryForsearch);
        libraryForsearchDao.insert(libraryForsearch);
    }

}

AdminLibraryController.java

■その1

package ksbysample.webapp.lending.web.admin.library;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/admin/library")
public class AdminLibraryController {

    @Autowired
    private AdminLibraryService adminLibraryService;
    
    @RequestMapping
    public String index() {
        return "admin/library/library";
    }

    @RequestMapping("/addSearchLibrary")
    public String addSearchLibrary(SetSelectedLibraryForm setSelectedLibraryForm) {
        adminLibraryService.deleteAndInsertLibraryForSearch(setSelectedLibraryForm);
        return "redirect:/admin/library";
    }
    
}
  • private AdminLibraryService adminLibraryService; を追加します。
  • addSearchLibrary メソッドの以下の点を変更します。
    • 引数に SetSelectedLibraryForm setSelectedLibraryForm を追加します。
    • 中の処理に adminLibraryService.deleteAndInsertLibraryForSearch(setSelectedLibraryForm); を追加します。
    • return 時に /admin/library にリダイレクトするように変更します。

■その2

package ksbysample.webapp.lending.web.admin.library;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@RequestMapping("/admin/library")
public class AdminLibraryController {

    @Autowired
    private AdminLibraryService adminLibraryService;

    @RequestMapping
    public String index() {
        return "admin/library/library";
    }

    @RequestMapping("/addSearchLibrary")
    public String addSearchLibrary(SetSelectedLibraryForm setSelectedLibraryForm) {
        adminLibraryService.deleteAndInsertLibraryForSearch(setSelectedLibraryForm);
        return "redirect:/admin/library";
    }
    
}
  • クラスに @PreAuthorize("hasAuthority('ROLE_ADMIN')") を付与します。

library.html

<script th:replace="common/bottom-js"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('#pref').focus();

        $('.js-search-library').click(function(){
            if (!$('#pref').val()) {
                alert("都道府県名を入力してください");
                return false;
            }
            
            $.ajax({
                type: "get",
                url: "/webapi/library/getLibraryList",
                data: { pref: $('#pref').val() },
                async: false,
                dataType: "json",
                success: function(json) {
                    if (json.errcode == 0) {
                        $('#library-list-body tr').remove();
                        for (var i = 0; i < json.content.length; i++) {
                            var library = "<tr>"
                                                + "<th>"
                                                    + "<button class=\"btn btn-sm bg-blue js-select-library\""
                                                        + "data-systemid=\"" + json.content[i].systemid + "\""
                                                        + "data-formal=\"" + json.content[i].formal + "\">選択</button>"
                                                + "</th>"
                                                + "<th class=\"hidden-xs\">" + json.content[i].systemname + "</th>"
                                                + "<th>" + json.content[i].formal + "</th>"
                                                + "<th>" + json.content[i].address + "</th>"
                                            + "</tr>";
                            $('#library-list-body').append(library);
                        }
                    }
                    else {
                        alert(json.errmsg);
                    }
                }
            });

            $('.js-select-library').each(function(){
                $(this).click(function(){
                    $("#setSelectedLibraryForm input[name='systemid']").val($(this).attr('data-systemid'));
                    $("#setSelectedLibraryForm input[name='formal']").val($(this).attr('data-formal'));
                    $('#setSelectedLibraryForm').submit();
                });
            });

            $('#pref').focus().select();
            
            return false;
        });
    });
    -->
</script>
</body>
</html>
  • 選択ボタンクリック時のイベントハンドラを割り当てていた $('.js-select-library').each(function(){ ... }); の処理を $('#pref').focus().select(); の前に移動して、一覧更新時に割り当てるように変更します。

selectSelectedLibrary.sql

select
  /*%expand*/*
from
  library_forsearch

LibraryHelper.java

package ksbysample.webapp.lending.helper.library;

import ksbysample.webapp.lending.dao.LibraryForsearchDao;
import ksbysample.webapp.lending.entity.LibraryForsearch;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class LibraryHelper {

    @Autowired
    private LibraryForsearchDao libraryForsearchDao;
    
    public String getSelectedLibrary() {
        String result;
        LibraryForsearch libraryForsearch = libraryForsearchDao.selectSelectedLibrary();
        if (libraryForsearch == null) {
            result = "※図書館が選択されていません";
        }
        else {
            result = "選択中:" + libraryForsearch.getFormal();
        }

        return result;
    }

}

mainparts.html

■その1

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>検索対象図書館登録</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
        <script src="/js/html5shiv.min.js"></script>
        <script src="/js/respond.min.js"></script>
    <![endif]-->

    <style>
        .noselected-library {
            color: #ff8679 !important;
            font-size: 100%;
            font-weight: 700;
        }
        .selected-library {
            color: #ffffff !important;
            font-size: 100%;
            font-weight: 700;
        }
        .content-wrapper {
            background-color: #fffafa;
        }
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <!-- Main Header -->
    <header class="main-header" th:fragment="main-header">
        <nav class="navbar navbar-static-top">
            <div class="container">
                <div class="navbar-header">
                    <a href="#" class="navbar-brand"><b>ksbysample-lending</b></a>
                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar-collapse">
                        <i class="fa fa-bars"></i>
                    </button>
                </div>

                <!-- Collect the nav links, forms, and other content for toggling -->
                <div class="collapse navbar-collapse pull-left" id="navbar-collapse">
                    <ul class="nav navbar-nav">
                        <li class="dropdown">
                            <a href="#" class="dropdown-toggle" data-toggle="dropdown">メニュー <span class="caret"></span></a>
                            <ul class="dropdown-menu" role="menu">
                                <li><a href="/booklist">貸出希望書籍登録</a></li>
                                <li><a href="/lendingapp">貸出申請</a></li>
                                <li><a href="/confirmresult">貸出申請結果確認</a></li>
                                <li class="divider"></li>
                                <li><a href="/lendingapproval">貸出承認</a></li>
                                <li class="divider"></li>
                                <li><a href="/admin/library">検索対象図書館登録</a></li>
                            </ul>
                        </li>
                    </ul>
                </div>
                <!-- /.navbar-collapse -->

                <!-- Navbar Right Menu -->
                <div class="navbar-custom-menu">
                    <p class="navbar-text" th:classappend="${#strings.startsWith(@libraryHelper.getSelectedLibrary(), '※')} ? 'noselected-library' : 'selected-library'" th:text="${@libraryHelper.getSelectedLibrary()}">※図書館が選択されていません</p>
                    <ul class="nav navbar-nav">
                        <li><a href="/logout">ログアウト</a></li>
                    </ul>
                </div>
                <!-- /.navbar-custom-menu -->
            </div>
            <!-- /.container-fluid -->
        </nav>
    </header>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>画面名</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                ここに画面本体を入れる
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<!-- jQuery 2.1.4 -->
<script src="/js/jQuery-2.1.4.min.js" type="text/javascript"></script>
<!-- Bootstrap 3.3.2 JS -->
<script src="/js/bootstrap.min.js" type="text/javascript"></script>
<!-- AdminLTE App -->
<script src="/js/app.min.js" type="text/javascript"></script>
</body>
</html>
  • <p class="navbar-text noselected-library">※図書館が選択されていません</p><p class="navbar-text" th:classappend="${#strings.startsWith(@libraryHelper.getSelectedLibrary(), '※')} ? 'noselected-library' : 'selected-library'" th:text="${@libraryHelper.getSelectedLibrary()}">※図書館が選択されていません</p> へ変更します。

RoleAwareAuthenticationSuccessHandler.java

package ksbysample.webapp.lending.security;

import ksbysample.webapp.lending.config.WebSecurityConfig;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class RoleAwareAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws ServletException, IOException {
        // ログイン画面以外のURLを指定してアクセスされていた場合には、処理を SavedRequestAwareAuthenticationSuccessHandler へ委譲する
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            super.onAuthenticationSuccess(request, response, authentication);
            return;
        }

        // 特定の権限を持っている場合には対応するURLへリダイレクトする
        String targetUrl = WebSecurityConfig.DEFAULT_SUCCESS_URL;
        GrantedAuthority roleAdmin = new SimpleGrantedAuthority("ROLE_ADMIN");
        if (authentication.getAuthorities().contains(roleAdmin)) {
            // 管理権限 ( ROLE_ADMIN ) を持っている場合には検索対象図書館登録画面へ遷移させる
            targetUrl = "/admin/library";
        }

        clearAuthenticationAttributes(request);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

}

WebSecurityConfig.java

package ksbysample.webapp.lending.config;

import ksbysample.webapp.lending.security.RoleAwareAuthenticationSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    public static final String DEFAULT_SUCCESS_URL = "/loginsuccess";
    public static final String REMEMBERME_KEY = "ksbysample-webapp-lending";
    
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                // 認証の対象外にしたいURLがある場合には、以下のような記述を追加します
                // 複数URLがある場合はantMatchersメソッドにカンマ区切りで対象URLを複数列挙します
                // .antMatchers("/country/**").permitAll()
                .antMatchers("/fonts/**").permitAll()
                .antMatchers("/html/**").permitAll()
                .antMatchers("/encode").permitAll()
                .antMatchers("/urllogin").permitAll()
                .antMatchers("/webapi/**").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .loginPage("/")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl(WebSecurityConfig.DEFAULT_SUCCESS_URL)
                .failureUrl("/")
                .usernameParameter("id")
                .passwordParameter("password")
                .successHandler(new RoleAwareAuthenticationSuccessHandler())
                .permitAll()
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/")
                .deleteCookies("JSESSIONID")
                .deleteCookies("remember-me")
                .invalidateHttpSession(true)
                .permitAll()
                .and()
                .rememberMe()
                .key(REMEMBERME_KEY)
                .tokenValiditySeconds(60 * 60 * 24 * 30);
    }

    @Bean
    public AuthenticationProvider daoAuhthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
        daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
        return daoAuthenticationProvider;
    }

    @Autowired
    public void configAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuhthenticationProvider())
                .userDetailsService(userDetailsService);
    }

}
  • configure メソッド内で login の処理を定義している部分に .successHandler(new RoleAwareAuthenticationSuccessHandler()) を追加します。

user_info.csv

user_id,username,password,mail_address,enabled,cnt_badcredentials,expired_account,expired_password
1,"tanaka taro",$2a$10$LKKepbcPCiT82NxSIdzJr.9ph.786Mxvr.VoXFl4hNcaaAn9u7jje,tanaka.taro@sample.com,1,0,"2015-10-17 12:46:14.790000","2016-08-18 12:46:30.354000"
2,"suzuki hanako",$2a$10$.fiPEZ155Rl41/e.mdM3A.mG0iEQNPmhjFL/aIiV8dZnXsCd.oqji,suzuki.hanako@test.co.jp,1,0,"2015-09-30 22:19:02.783000","2015-08-31 22:19:22.176000"
3,"kimura masao",$2a$10$yP1dLPIq9j7WQVH6ruSwkepf8jIkPxTtncbSnYM0/jAGQ4HCQO8R.,kimura.masao@test.co.jp,0,0,"2015-12-31 22:30:54.425000","2015-10-15 22:31:03.316000"
4,"endo yoko",$2a$10$PVFe8Lh1Pkjc54DWS9mJL.q407x51ZK8MSXhwuTF9zxCnnt80LKwy,endo.yoko@sample.com,1,0,"2015-01-10 22:31:55.454000","2015-12-31 22:32:11.886000"
5,"sato masahiko",$2a$10$qIU0kM/p1pa7KSIjF6YA4eORd2wL1Eo6TlvH./DmPs7D.xXQPEq7a,sato.masahiko@sample.com,1,0,"2015-12-31 22:34:14.827000","2014-08-05 22:34:22.818000"
6,"takahasi naoko",$2a$10$iXp/d4wXmfaLKTjQKBvik.kETgx4nQ.FL1NjYt4ALJOGSyVOSchW6,takahasi.naoko@test.co.jp,1,0,"2015-12-01 22:39:48.475000","2015-11-10 22:39:55.422000"
7,"kato hiroshi",$2a$10$g5dtFTtNBdJO30aHg50rluGNa2pEAzArcwYkYyCG91ElBZPs9sDi2,kato.hiroshi@sample.com,0,5,"2014-01-01 15:58:53.295000","2013-12-31 15:59:07.668000"
  • No.2 の suzuki hanako の「expired_password」を "2015-08-31 22:19:22.176000" → "2016-08-31 22:19:22.176000" へ変更します。

Application.java

package ksbysample.webapp.lending;

import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ImportResource;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

import java.io.Serializable;
import java.text.MessageFormat;

@ImportResource("classpath:applicationContext-${spring.profiles.active}.xml")
@SpringBootApplication
@ComponentScan("ksbysample")
@EnableRedisHttpSession
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class Application {

    public static void main(String[] args) {
        String springProfilesActive = System.getProperty("spring.profiles.active");
        if (!StringUtils.equals(springProfilesActive, "product")
                && !StringUtils.equals(springProfilesActive, "develop")
                && !StringUtils.equals(springProfilesActive, "unittest")) {
            throw new UnsupportedOperationException(MessageFormat.format("JVMの起動時引数 -Dspring.profiles.active で develop か unittest か product を指定して下さい ( -Dspring.profiles.active={0} )。", springProfilesActive));
        }

        SpringApplication.run(Application.class, args);
    }

}
  • @EnableGlobalMethodSecurity(prePostEnabled = true) を追加します。

履歴

2015/09/06
初版発行。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その19 )( 検索対象図書館登録画面の作成 )

概要

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その18 )( 図書館一覧取得 WebAPI の作成2 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。2~3回に分けて書きます。
    • 検索対象図書館登録画面 ( 管理者のみ ) の作成

参照したサイト・書籍

  1. HTML Table width in percentage, table rows separated equaly
    http://stackoverflow.com/questions/7700534/html-table-width-in-percentage-table-rows-separated-equaly

    • テーブルレイアウトで <colgroup>...</colgroup> を使用してカラムの幅を調整する方法を参照しました。
  2. CSSでのフォント指定について考える(2014年)
    http://www.dtp-transit.jp/misc/web/post_1881.html

    • CSS で指定する日本語フォントを参考にしました。
  3. Bootstrapのレスポンシブなテーブルの列の幅を動的に変える
    http://qiita.com/tkm_kj/items/2c60a49d9c8e9d8fbd85

    • Bootstrap で table タグを使用してテーブル表示した時に、ブラウザの画面の幅を狭めてもカラムの幅が固定のままになった原因を調査した時に参照しました。
  4. Twitter Bootstrapで端末サイズ毎にHTML要素の表示・非表示を切り替え
    http://easyramble.com/twitter-bootstrap-responsive-utilities.html

    • Bootstrap で画面の幅を狭めた時にテーブルの一部のカラムを非表示にする方法を調べた時に参照しました。
  5. twitter bootstrap text in navbar
    http://stackoverflow.com/questions/9844861/twitter-bootstrap-text-in-navbar

    • Bootstrap の navbar 内にテキストを表示する方法を参照しました。
  6. 意外と要注意 HTML5データ属性とjQuery Data APIの関係まとめ
    http://tokkono.cute.coocan.jp/blog/slow/index.php/xhtmlcss/html5-data-attributes-vs-jquery-data-api/

    • jQueryHTML5 のデータ属性を取り扱う方法を調べた時に参照しました。
  7. jQueryのカスタムデータ属性(data-*)の設定/取得の挙動が思ったのと違った件
    http://qiita.com/Kta-M/items/2eda39750abd10df9801

    • jQueryHTML5 のデータ属性を取り扱う方法を調べた時に参照しました。
  8. jQuery入門道場 - 2章 セレクタ
    http://jquery-master.net/selectors/

    • jQuery で name 属性の値を指定して要素を取得する方法を調べた時に参照しました。
  9. jQuery.ajax で form データを送信
    http://surferonwww.info/BlogEngine/post/2012/07/14/posting-form-data-using-jquery-ajax.aspx

    • jQuery で WebAPI を呼び出す時に日本語をエンコードする方法を参考にしました。
  10. jQuery+jQuery UI+jQuery Mobile逆引きハンドブック

    jQuery+jQuery UI+jQuery Mobile逆引きハンドブック

    jQuery+jQuery UI+jQuery Mobile逆引きハンドブック

    • jQuery 関連で分からないところを調べる時に参照しました。
  11. Change field name in JSON using Jackson
    http://stackoverflow.com/questions/7263282/change-field-name-in-json-using-jackson

    • RestController クラスから JSON を返す時に変数名をクラスのフィールド名から変更する方法を参考にしました。

目次

  1. はじめに
  2. 1.0.x-make-admin-library ブランチの作成
  3. 検索対象図書館登録画面の HTML ファイルの作成
  4. Thymeleaf テンプレートファイルの作成
  5. AdminLibraryController クラスの作成
  6. 動作確認
  7. 都道府県名を入力して検索ボタンを押したら図書館一覧を表示する

手順

はじめに

管理権限 ( ROLE_ADMIN ) を持つユーザのみが使用できる検索対象図書館登録画面を作成します。

  • Javascript で図書館一覧取得 WebAPI ( /webapi/library/getLibraryList ) を呼び出して取得した JSON データを画面上に表示します。
  • 図書館選択時はサーバへリクエストを送信してデータを登録します。
  • 管理権限 ( ROLE_ADMIN ) を持つユーザがログインした場合には、最初に検索対象図書館登録画面へ遷移するようにします。
  • メニューの項目は管理権限 ( ROLE_ADMIN ) を持つユーザにのみ表示されるようにします。

1.0.x-make-admin-library ブランチの作成

  1. IntelliJ IDEA で 1.0.x-make-admin-library ブランチを作成します。

検索対象図書館登録画面の HTML ファイルの作成

  1. src/main/resources/static/html の下の template.html をコピーして adminlibrary.html を作成します。作成後、リンク先の内容 に変更します。

    以下のような画面になります。

    f:id:ksby:20150904044141p:plain

    f:id:ksby:20150905141448p:plain

    • 上の Extra small devices の時 ( col-xs 適用時 ) には以下のようにしています。
      • 都道府県名入力フィールドと「検索」ボタンの幅が変わるようにしました。
      • 一覧表の systemname 欄が表示されないようにしました。

Thymeleaf テンプレートファイルの作成

  1. src/main/resources/templates の下に admin/library ディレクトリを作成します。

  2. src/main/resources/templates/admin/library の下に src/main/resources/static/html/adminlibrary.html をコピーして、ファイル名を library.html に変更します。

  3. src/main/resources/templates の下に common ディレクトリを作成します。

  4. src/main/resources/templates/common の下に head-cssjs.html を作成します。作成後、リンク先の内容 に変更します。

  5. src/main/resources/templates/common の下に bottom-js.html を作成します。作成後、リンク先の内容 に変更します。

  6. src/main/resources/templates/common の下に mainparts.html を作成します。作成後、リンク先の内容 に変更します。

  7. src/main/resources/templates/admin/library の下の library.html を リンク先のその1の内容 に変更します。

AdminLibraryController クラスの作成

  1. src/main/java/ksbysample/webapp/lending/web の下に admin.library パッケージを作成します。

  2. src/main/java/ksbysample/webapp/lending/web/admin/library の下に AdminLibraryController.java を作成します。作成後、リンク先の内容 に変更します。

動作確認

  1. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  2. ブラウザを起動し http://localhost:8080/admin/library へアクセスします。最初はログイン画面が表示されますので ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

    以下の画面が表示されることを確認します。

    f:id:ksby:20150905054057p:plain

  3. Ctrl+F2 を押して Tomcat を停止します。

  4. 一旦 commit します。

都道府県名を入力して検索ボタンを押したら図書館一覧を表示する

  1. 最初に図書館一覧取得 WebAPI で返る JSON で formal が formalName になっていることに気づいたので修正します。src/main/java/ksbysample/webapp/lending/service.calilapi の下の Library.javaリンク先の内容 に変更します。

  2. src/main/resources/templates/admin/library の下の library.html を リンク先のその2の内容 に変更します。

  3. 動作確認します。Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  4. ブラウザを起動し http://localhost:8080/admin/library へアクセスします ( ログイン画面が表示されたら tanaka.taro@sample.com / taro でログインします )。最初に以下の画面が表示されます。

    f:id:ksby:20150905161510p:plain

  5. "東京都" と入力して「検索」ボタンを押すとカーリル図書館APIで検索してヒットした図書館一覧が表示されます。

    f:id:ksby:20150905161753p:plain

  6. "神奈川県" で検索すると今度は神奈川県の検索結果が表示されます。

    f:id:ksby:20150905162020p:plain

  7. Ctrl+F2 を押して Tomcat を停止します。

  8. 一旦 commit します。

ソースコード

adminlibrary.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>検索対象図書館登録</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
        <script src="/js/html5shiv.min.js"></script>
        <script src="/js/respond.min.js"></script>
    <![endif]-->

    <style>
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        .noselected-library {
            color: #ff8679 !important;
            font-size: 100%;
            font-weight: 700;
        }
        .selected-library {
            color: #ffffff !important;
            font-size: 100%;
            font-weight: 700;
        }
        .content-wrapper {
            background-color: #fffafa;
        }
        .table>tbody>tr>td
        , .table>tbody>tr>th
        , .table>tfoot>tr>td
        , .table>tfoot>tr>th
        , .table>thead>tr>td
        , .table>thead>tr>th {
            padding: 5px;
            font-size: 90%;
        }
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <header class="main-header">
        <nav class="navbar navbar-static-top">
            <div class="container">
                <div class="navbar-header">
                    <a href="#" class="navbar-brand"><b>ksbysample-lending</b></a>
                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar-collapse">
                        <i class="fa fa-bars"></i>
                    </button>
                </div>

                <!-- Collect the nav links, forms, and other content for toggling -->
                <div class="collapse navbar-collapse pull-left" id="navbar-collapse">
                    <ul class="nav navbar-nav">
                        <li class="dropdown">
                            <a href="#" class="dropdown-toggle" data-toggle="dropdown">メニュー <span class="caret"></span></a>
                            <ul class="dropdown-menu" role="menu">
                                <li><a href="/booklist">貸出希望書籍登録</a></li>
                                <li><a href="/lendingapp">貸出申請</a></li>
                                <li><a href="/confirmresult">貸出申請結果確認</a></li>
                                <li class="divider"></li>
                                <li><a href="/lendingapproval">貸出承認</a></li>
                                <li class="divider"></li>
                                <li><a href="/admin/library">検索対象図書館登録</a></li>
                            </ul>
                        </li>
                    </ul>
                </div>
                <!-- /.navbar-collapse -->
                
                <!-- Navbar Right Menu -->
                <div class="navbar-custom-menu">
                    <p class="navbar-text noselected-library">※図書館が選択されていません</p>
                    <ul class="nav navbar-nav">
                        <li><a href="/logout">ログアウト</a></li>
                    </ul>
                </div>
                <!-- /.navbar-custom-menu -->
            </div>
            <!-- /.container-fluid -->
        </nav>
    </header>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>検索対象図書館登録</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                <div class="row">
                    <div class="col-xs-12">
                        <div class="box">
                            <div class="box-head">
                                <form>
                                    <table class="table">
                                        <tbody>
                                            <tr>
                                                <th class="col-xs-8 col-sm-4">
                                                    <input type="text" name="pref" id="pref" class="form-control input-sm" value="" placeholder="都道府県名を入力してください"/>
                                                </th>
                                                <th class="col-xs-2 col-sm-2">
                                                    <button class="btn btn-sm bg-orange js-search-library"><i class="fa fa-search"></i> 検索</button>
                                                </th>
                                                <th></th>
                                            </tr>
                                        </tbody>
                                    </table>
                                </form>
                            </div>
                            <div class="box-body no-padding">
                                <table class="table table-hover">
                                    <colgroup>
                                        <col width="5%"/>
                                        <col width="20%"/>
                                        <col width="30%"/>
                                        <col width="45%"/>
                                    </colgroup>
                                    <thead class="bg-purple">
                                        <tr>
                                            <th></th>
                                            <th class="hidden-xs">systemname</th>
                                            <th>formal</th>
                                            <th>address</th>
                                        </tr>
                                    </thead>
                                    <tbody class="jp-gothic">
                                    <tr>
                                        <th><button class="btn btn-sm bg-blue">選択</button></th>
                                        <th class="hidden-xs">アジア・アフリカ図書館</th>
                                        <th>アジア・アフリカ図書館</th>
                                        <th>東京都三鷹市新川5-14-16</th>
                                    </tr>
                                    <tr>
                                        <th><button class="btn btn-sm bg-blue">選択</button></th>
                                        <th class="hidden-xs">米国大使館レファレンス資料室</th>
                                        <th>アメリカンセンターJapan</th>
                                        <th>東京都港区赤坂1-1-14 NOF溜池ビル8階</th>
                                    </tr>
                                    <tr>
                                        <th><button class="btn btn-sm bg-blue">選択</button></th>
                                        <th class="hidden-xs">農林水産関係試験研究機関総合目録</th>
                                        <th>農林水産省図書館農林水産政策研究所分館</th>
                                        <th>東京都千代田区霞が関3-1-1 中央合同庁舎第4号館9F</th>
                                    </tr>
                                    </tbody>
                                </table>
                            </div>
                        </div>
                    </div>
                </div>
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<!-- jQuery 2.1.4 -->
<script src="/js/jQuery-2.1.4.min.js" type="text/javascript"></script>
<!-- Bootstrap 3.3.2 JS -->
<script src="/js/bootstrap.min.js" type="text/javascript"></script>
<!-- AdminLTE App -->
<script src="/js/app.min.js" type="text/javascript"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('#pref').focus();
    });
    -->
</script>
</body>
</html>

head-cssjs.html

    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>
    
    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
        <script src="/js/html5shiv.min.js"></script>
        <script src="/js/respond.min.js"></script>
    <![endif]-->

bottom-js.html

<!-- jQuery 2.1.4 -->
<script src="/js/jQuery-2.1.4.min.js" type="text/javascript"></script>
<!-- Bootstrap 3.3.2 JS -->
<script src="/js/bootstrap.min.js" type="text/javascript"></script>
<!-- AdminLTE App -->
<script src="/js/app.min.js" type="text/javascript"></script>

mainparts.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>検索対象図書館登録</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
        <script src="/js/html5shiv.min.js"></script>
        <script src="/js/respond.min.js"></script>
    <![endif]-->

    <style>
        .noselected-library {
            color: #ff8679 !important;
            font-size: 100%;
            font-weight: 700;
        }
        .selected-library {
            color: #ffffff !important;
            font-size: 100%;
            font-weight: 700;
        }
        .content-wrapper {
            background-color: #fffafa;
        }
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <!-- Main Header -->
    <header class="main-header" th:fragment="main-header">
        <nav class="navbar navbar-static-top">
            <div class="container">
                <div class="navbar-header">
                    <a href="#" class="navbar-brand"><b>ksbysample-lending</b></a>
                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar-collapse">
                        <i class="fa fa-bars"></i>
                    </button>
                </div>

                <!-- Collect the nav links, forms, and other content for toggling -->
                <div class="collapse navbar-collapse pull-left" id="navbar-collapse">
                    <ul class="nav navbar-nav">
                        <li class="dropdown">
                            <a href="#" class="dropdown-toggle" data-toggle="dropdown">メニュー <span class="caret"></span></a>
                            <ul class="dropdown-menu" role="menu">
                                <li><a href="/booklist">貸出希望書籍登録</a></li>
                                <li><a href="/lendingapp">貸出申請</a></li>
                                <li><a href="/confirmresult">貸出申請結果確認</a></li>
                                <li class="divider"></li>
                                <li><a href="/lendingapproval">貸出承認</a></li>
                                <li class="divider"></li>
                                <li><a href="/admin/library">検索対象図書館登録</a></li>
                            </ul>
                        </li>
                    </ul>
                </div>
                <!-- /.navbar-collapse -->

                <!-- Navbar Right Menu -->
                <div class="navbar-custom-menu">
                    <p class="navbar-text noselected-library">※図書館が選択されていません</p>
                    <ul class="nav navbar-nav">
                        <li><a href="/logout">ログアウト</a></li>
                    </ul>
                </div>
                <!-- /.navbar-custom-menu -->
            </div>
            <!-- /.container-fluid -->
        </nav>
    </header>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>画面名</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                ここに画面本体を入れる
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<!-- jQuery 2.1.4 -->
<script src="/js/jQuery-2.1.4.min.js" type="text/javascript"></script>
<!-- Bootstrap 3.3.2 JS -->
<script src="/js/bootstrap.min.js" type="text/javascript"></script>
<!-- AdminLTE App -->
<script src="/js/app.min.js" type="text/javascript"></script>
</body>
</html>

library.html

■その1

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>検索対象図書館登録</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <link th:replace="common/head-cssjs"/>

    <style>
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        .noselected-library {
            color: #ff8679 !important;
            font-size: 100%;
            font-weight: 700;
        }
        .selected-library {
            color: #ffffff !important;
            font-size: 100%;
            font-weight: 700;
        }
        .content-wrapper {
            background-color: #fffafa;
        }
        .table>tbody>tr>td
        , .table>tbody>tr>th
        , .table>tfoot>tr>td
        , .table>tfoot>tr>th
        , .table>thead>tr>td
        , .table>thead>tr>th {
            padding: 5px;
            font-size: 90%;
        }
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <!-- Main Header -->
    <div th:replace="common/mainparts :: main-header"></div>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>検索対象図書館登録</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                <div class="row">
                    <div class="col-xs-12">
                        <div class="box">
                            <div class="box-head">
                                <form id="searchLibraryForm" method="post" action="#" th:action="@{/}">
                                    <table class="table">
                                        <tbody>
                                            <tr>
                                                <th class="col-xs-8 col-sm-4">
                                                    <input type="text" name="pref" id="pref" class="form-control input-sm" value="" placeholder="都道府県名を入力してください"/>
                                                </th>
                                                <th class="col-xs-2 col-sm-2">
                                                    <button class="btn btn-sm bg-orange js-search-library"><i class="fa fa-search"></i> 検索</button>
                                                </th>
                                                <th></th>
                                            </tr>
                                        </tbody>
                                    </table>
                                </form>
                            </div>
                            <div class="box-body no-padding">
                                <table class="table table-hover">
                                    <colgroup>
                                        <col width="5%"/>
                                        <col width="20%"/>
                                        <col width="30%"/>
                                        <col width="45%"/>
                                    </colgroup>
                                    <thead class="bg-purple">
                                        <tr>
                                            <th></th>
                                            <th class="hidden-xs">systemname</th>
                                            <th>formal</th>
                                            <th>address</th>
                                        </tr>
                                    </thead>
                                    <tbody class="jp-gothic">
                                    <tr>
                                        <th>
                                            <button class="btn btn-sm bg-blue js-select-library"
                                                    data-systemid="Special_Aacf"
                                                    data-formal="アジア・アフリカ図書館">選択</button>
                                        </th>
                                        <th class="hidden-xs">アジア・アフリカ図書館</th>
                                        <th>アジア・アフリカ図書館</th>
                                        <th>東京都三鷹市新川5-14-16</th>
                                    </tr>
                                    <tr>
                                        <th>
                                            <button class="btn btn-sm bg-blue js-select-library"
                                                    data-systemid="Special_Acrs"
                                                    data-formal="アメリカンセンターJapan">選択</button>
                                        </th>
                                        <th class="hidden-xs">米国大使館レファレンス資料室</th>
                                        <th>アメリカンセンターJapan</th>
                                        <th>東京都港区赤坂1-1-14 NOF溜池ビル8階</th>
                                    </tr>
                                    <tr>
                                        <th>
                                            <button class="btn btn-sm bg-blue js-select-library"
                                                    data-systemid="Special_Affrc"
                                                    data-formal="農林水産省図書館農林水産政策研究所分館">選択</button>
                                        </th>
                                        <th class="hidden-xs">農林水産関係試験研究機関総合目録</th>
                                        <th>農林水産省図書館農林水産政策研究所分館</th>
                                        <th>東京都千代田区霞が関3-1-1 中央合同庁舎第4号館9F</th>
                                    </tr>
                                    </tbody>
                                </table>
                                <form id="setSelectedLibraryForm" method="post" action="/admin/library/addSearchLibrary" th:action="@{/admin/library/addSearchLibrary}">
                                    <input type="hidden" name="systemid" value=""/>
                                    <input type="hidden" name="formal" value=""/>
                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<script th:replace="common/bottom-js"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('#pref').focus();

        $('.js-search-library').click(function(){
            return false;
        });
        
        $('.js-select-library').each(function(){
            $(this).click(function(){
                $("#setSelectedLibraryForm input[name='systemid']").val($(this).attr('data-systemid'));
                $("#setSelectedLibraryForm input[name='formal']").val($(this).attr('data-formal'));
                $('#setSelectedLibraryForm').submit();
            });
        });
    });
    -->
</script>
</body>
</html>
  • 一番上の <html> タグを Thymeleaf テンプレートファイル用のものに変更します。
  • 共通部分の HTML を common/head-cssjs, commono/mainparts.html, common/bottom-js へ切り出し、th:replace で置換するようにします。
  • form タグに id, action 等を追記します。
  • Javascript の処理を割り当てたいボタンに js- で始まるセレクタを追加します。
  • 図書館一覧の「選択」ボタンの button タグに data-systemid, data-formal データ属性を追加します。
  • 「選択」ボタンが押された時の処理に使用するフォーム <form id="setSelectedLibraryForm" ...> ... </form> を追加します。
  • 「検索」ボタン、「選択」ボタンが押された時の Javascript の処理を追加します。

■その2

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>検索対象図書館登録</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <link th:replace="common/head-cssjs"/>

    <style>
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        .noselected-library {
            color: #ff8679 !important;
            font-size: 100%;
            font-weight: 700;
        }
        .selected-library {
            color: #ffffff !important;
            font-size: 100%;
            font-weight: 700;
        }
        .content-wrapper {
            background-color: #fffafa;
        }
        .table>tbody>tr>td
        , .table>tbody>tr>th
        , .table>tfoot>tr>td
        , .table>tfoot>tr>th
        , .table>thead>tr>td
        , .table>thead>tr>th {
            padding: 5px;
            font-size: 90%;
        }
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <!-- Main Header -->
    <div th:replace="common/mainparts :: main-header"></div>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>検索対象図書館登録</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                <div class="row">
                    <div class="col-xs-12">
                        <div class="box">
                            <div class="box-head">
                                <form id="searchLibraryForm" method="post" action="#" th:action="@{/}">
                                    <table class="table">
                                        <tbody>
                                            <tr>
                                                <th class="col-xs-8 col-sm-4">
                                                    <input type="text" name="pref" id="pref" class="form-control input-sm" value="" placeholder="都道府県名を入力してください"/>
                                                </th>
                                                <th class="col-xs-2 col-sm-2">
                                                    <button class="btn btn-sm bg-orange js-search-library"><i class="fa fa-search"></i> 検索</button>
                                                </th>
                                                <th></th>
                                            </tr>
                                        </tbody>
                                    </table>
                                </form>
                            </div>
                            <div class="box-body no-padding">
                                <table id="library-list" class="table table-hover">
                                    <colgroup>
                                        <col width="5%"/>
                                        <col width="20%"/>
                                        <col width="30%"/>
                                        <col width="45%"/>
                                    </colgroup>
                                    <thead class="bg-purple">
                                        <tr>
                                            <th></th>
                                            <th>systemname</th>
                                            <th class="hidden-xs">formal</th>
                                            <th>address</th>
                                        </tr>
                                    </thead>
                                    <tbody id="library-list-body" class="jp-gothic">
                                        <!-- ここにカーリル図書館APIで取得した図書館データを表示する -->
                                    </tbody>
                                </table>
                                <form id="setSelectedLibraryForm" method="post" action="/admin/library/addSearchLibrary" th:action="@{/admin/library/addSearchLibrary}">
                                    <input type="hidden" name="systemid" value=""/>
                                    <input type="hidden" name="formal" value=""/>
                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<script th:replace="common/bottom-js"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('#pref').focus();

        $('.js-search-library').click(function(){
            if (!$('#pref').val()) {
                alert("都道府県名を入力してください");
                return false;
            }
            
            $.ajax({
                type: "get",
                url: "/webapi/library/getLibraryList",
                data: { pref: $('#pref').val() },
                async: false,
                dataType: "json",
                success: function(json) {
                    if (json.errcode == 0) {
                        $('#library-list-body tr').remove();
                        for (var i = 0; i < json.content.length; i++) {
                            var library = "<tr>"
                                                + "<th>"
                                                    + "<button class=\"btn btn-sm bg-blue js-select-library\""
                                                        + "data-systemid=\"" + json.content[i].systemid + "\""
                                                        + "data-formal=\"" + json.content[i].formal + "\">選択</button>"
                                                + "</th>"
                                                + "<th class=\"hidden-xs\">" + json.content[i].systemname + "</th>"
                                                + "<th>" + json.content[i].formal + "</th>"
                                                + "<th>" + json.content[i].address + "</th>"
                                            + "</tr>";
                            $('#library-list-body').append(library);
                        }
                    }
                    else {
                        alert(json.errmsg);
                    }
                }
            });

            $('#pref').focus().select();
            
            return false;
        });
        
        $('.js-select-library').each(function(){
            $(this).click(function(){
                $("#setSelectedLibraryForm input[name='systemid']").val($(this).attr('data-systemid'));
                $("#setSelectedLibraryForm input[name='formal']").val($(this).attr('data-formal'));
                $('#setSelectedLibraryForm').submit();
            });
        });
    });
    -->
</script>
</body>
</html>
  • <tbody id="library-list-body" class="jp-gothic"> ... </tbody> の中は Javascript で動的に出力するので記述を全て削除します。
  • 「検索」ボタンが押された時の処理を $('.js-search-library').click(function(){ ... }); に記述します。
  • エラー時に WebAPI の HTTPステータスコードを 200 にしたままだったのでエラーハンドラで処理を記述することができないことに気づきましたが、今回はそのままにします。。。

AdminLibraryController.java

package ksbysample.webapp.lending.web.admin.library;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/admin/library")
public class AdminLibraryController {

    @RequestMapping
    public String index() {
        return "admin/library/library";
    }

    @RequestMapping("/addSearchLibrary")
    public String addSearchLibrary() {
        return "admin/library/library";
    }
    
}

Library.java

package ksbysample.webapp.lending.service.calilapi;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.Root;

@Data
@Root(name = "Library", strict = false)
public class Library {

    @Element
    private String systemid;

    @Element
    private String systemname;

    @Element(name = "formal")
    @JsonProperty("formal")
    private String formalName;

    @Element
    private String address;

}
  • JSON で返す時に名前を変えるために private String formalName;@JsonProperty("formal") を付加します。

履歴

2015/09/05
初版発行。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( 番外編 )( RestTemplate&WebAPI をいろいろ試してみる )

概要

Spring Framework の RestTemplate で WebAPI を呼び出したり、JSON 等でレスポンスを返したりするのがかなり簡単で気に入ったので、いろいろ試してみたいと思います。

  • 今回の手順で確認できるのは以下の内容です。
    • RestTemplate で JSONP を返す WebAPI を呼び出してみる
    • WebAPI で XML でデータを返す
    • RestTemplate でカーリル図書館APIの図書館データベースAPIを呼び出して、XML のレスポンスを jackson-dataformat-xml で処理する

参照したサイト・書籍

  1. Spring 4 MVC REST Service Example using @RestController
    http://websystique.com/springmvc/spring-4-mvc-rest-service-example-using-restcontroller/

  2. FasterXML/jackson-dataformat-xml
    https://github.com/FasterXML/jackson-dataformat-xml

    • JAXB のアノテーションを使いつつ、内部では Jackson で XML を取り扱えるようになるライブラリ、だと思います。実は内部の仕組みがよく分かっていません。。。
    • build.gradle に compile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.5.3") を記述して、使用される ObjectMapper クラスで findAndRegisterModules メソッドを呼び出して認識させるだけでよく 、XML の出力が格段に楽になるので、個人的には気に入りました。
  3. CLOVER - JAXBをXML Schemaなしで使ってみる
    http://d.hatena.ne.jp/Kazuhira/20120716/1342435297

目次

  1. 1.0.x-try-resttemplate-and-webapi ブランチの作成
  2. RestTemplate で JSONP を返す WebAPI を呼び出してみる
  3. WebAPI で XML でデータを返す
  4. RestTemplate でカーリル図書館APIの図書館データベースAPIを呼び出して、XML のレスポンスを jackson-dataformat-xml で処理する
    1. 変換先の Java クラスを作成し、RestTemplate で図書館データベースAPIを呼び出してみる
    2. <Library>...</Library> が1件も取り込まれなかった原因を調べる
    3. <Library>...</Library> を List へ取り込めるようにする
    4. RestTemplate の getForEntity メソッドからダイレクトに LibrariesForJackson2Xml クラスに変換する
    5. <Library>...</Library> が1件も取り込まれなかった原因を調べる ( その2 )
    6. RestTemplate の getForEntity メソッドからダイレクトに LibrariesForJackson2Xml クラスに変換する ( その2 )
  5. commit、Push、Pull Request、マージ
  6. @RestController&RestTemplate の XML 対応をまとめてみる

手順

1.0.x-try-resttemplate-and-webapi ブランチの作成

  1. IntelliJ IDEA で 1.0.x-try-resttemplate-and-webapi ブランチを作成します。

RestTemplate で JSONP を返す WebAPI を呼び出してみる

RestTemplate には JSONP を自動で解析してくれる機能はないようなので、レスポンスを String で受け取り、処理内で関数名の部分を取り除くようにします。

  1. src/main/java/ksbysample/webapp/lending/service/openweathermapapi の下の OpenWeatherMapApiService.javaリンク先の内容 に変更します。

  2. テストクラスを作成して動作を確認します。src/test/java/ksbysample/webapp/lending/service/openweathermapapi の下の OpenWeatherMapApiServiceTest.javaリンク先の内容 に変更します。

  3. テストを実行してみます。testGetFiveDayThreeHourForecastByJSONP メソッドにカーソルを移動し、コンテキストメニューを表示後「Run 'testGetFiveDayThre...()' with Coverage」を選択します。

    テストが成功することが確認できます。

    f:id:ksby:20150826153526p:plain

  4. System.out.println で出力されたデータの内容が問題ないかも確認した後、testGetFiveDayThreeHourForecastByJSONP メソッドの System.out.println している部分をコメントアウトします。

  5. 一旦ここで commit します。

WebAPI で XML でデータを返す

  1. JSON で返す場合には @RestController アノテーションを付加したクラスから Java オブジェクトを return しましたが ( 自動で Jackson により Java オブジェクトが JSON に変換されます )、XML で返す場合には JAXB のアノテーションを付加したクラスを作成し、そのクラスのオブジェクトを return します。

    ただし実際に試してみたところ以下のような問題があり Jackson で JSON に出力する場合と比較すると使いづらい感じがしました。

    • Map<String, String> のデータを出力しようとすると、<key>value</key> と出力されて欲しいところが <entry><key>...</key><value>...</value></entry> と出力される。

      f:id:ksby:20150829015536p:plain

    • LocalDateTime のデータを適切に出力できない。

  2. Jackson と同じような使い勝手で XML を出力できないか調べたところ、FasterXML/jackson-dataformat-xml というライブラリがありましたので、それを使用することにします。

    • build.gradle にライブラリの記述をするだけでよく、Java クラスには JAXB のアノテーションをそのまま使用することができます。
    • Map<String, String> のデータを出力する時に <key>value</key> のフォーマットで出力されるようになります。
    • FasterXML/jackson-datatype-jsr310 と組み合わせることで、LocalDateTime のデータを指定したフォーマットで出力できるようになります。
  3. build.gradle を リンク先の内容 に変更します。

  4. Gradle projects View の左上にある「Refresh all Gradle projects」アイコンをクリックして、変更した build.gradle の内容を反映します。

  5. 作成した OpenWeatherMapApiService クラスの getFiveDayThreeHourForecast メソッドで取得したFiveDayThreeHourForecastData クラスのデータのうち、以下の内容を XML で戻す WebAPI を作成します。

    • FiveDayThreeHourForecastData クラスのフィールドの内、city, cnt, list のみ返す。
    • city のデータは全て返さない。name, coord, country のみ返す。
    • list の ForecastData クラスのフィールドは全て返さず、dt, weather, wind, dt_txt のみ返す。
  6. src/main/ksbysample/webapp/lending/webapi の下に weather パッケージを作成します。

  7. src/main/ksbysample/webapp/lending/webapi/weather の下に CityResponse.java を作成します。作成後、リンク先の内容 に変更します。

  8. src/main/ksbysample/webapp/lending/webapi/weather の下に ForecastResponse.java を作成します。作成後、リンク先の内容 に変更します。

  9. src/main/ksbysample/webapp/lending/webapi/weather の下に FiveDayThreeHourForecastResponse.java を作成します。作成後、リンク先の内容 に変更します。

  10. src/main/ksbysample/webapp/lending/webapi/weather の下に WeatherController.java を作成します。作成後、リンク先の内容 に変更します。

  11. 動作確認します。Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  12. ブラウザを起動して http://localhost:8080/webapi/weather/getFiveDayThreeHourForecast?cityname=Tokyo にアクセスします。

    レスポンスとして XML データが返り、ブラウザ上に表示されます。

    f:id:ksby:20150829133504p:plain f:id:ksby:20150829133909p:plain

    http://api.openweathermap.org/data/2.5/forecast?q=Tokyo から取得した元データ ( JSON ) も以下に記載します。JSON のデータが XML の方に出力されていることが確認できます。

    f:id:ksby:20150829134119p:plain f:id:ksby:20150829134418p:plain

  13. Ctrl+F2 を押して Tomcat を停止します。

  14. 一旦ここで commit します。

RestTemplate でカーリル図書館APIの図書館データベースAPIを呼び出して、XML のレスポンスを jackson-dataformat-xml で処理する

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その17 )( 図書館一覧取得 WebAPI の作成 ) では XMLJava オブジェクトへの変換に Simple ライブラリを使用しましたが、FasterXML/jackson-dataformat-xml を使用すれば Jackson で XML を処理できるようになるようなので、Jackson で XMLJava オブジェクトへの変換ができるか試してみます。

変換先の Java クラスを作成し、RestTemplate で図書館データベースAPIを呼び出してみる

  1. src/main/java/ksbysample/webapp/lending/service/calilapi の下に LibraryForJackson2Xml.java を作成します。作成後、リンク先の内容 に変更します。

  2. src/main/java/ksbysample/webapp/lending/service/calilapi の下に LibrariesForJackson2Xml.java を作成します。作成後、リンク先のその1の内容 に変更します。

  3. src/main/java/ksbysample/webapp/lending/service/calilapi の下の CalilApiService.javaリンク先のその1の内容 に変更します。

  4. 動作確認するためにテストメソッドを作成します。src/test/java/ksbysample/webapp/lending/service/calilapi の下の CalilApiServiceTest.javaリンク先のその1の内容 に変更します。

  5. テストを実行してみます。testGetLibraryListByJackson2Xml メソッドにカーソルを移動し、コンテキストメニューを表示後「Run 'testGetLibraryList...()' with Coverage」を選択します。

    テストは成功しますが、XMLJava オブジェクトへの変換が出来ていませんでした。<Library>...</Library> の部分が1件も取り込まれていません。原因を調査します。

    f:id:ksby:20150830170224p:plain

<Library>...</Library> が1件も取り込まれなかった原因を調べる

今のままだと調査しづらいので、Simple ライブラリの時のように一旦図書館データベースAPIXML レスポンスを String へ入れてから変換するようにします。src/main/java/ksbysample/webapp/lending/service/calilapi の下の CalilApiService.javaリンク先のその2の内容 に変更します。

再度テストを実行します。今度は com.fasterxml.jackson.databind.JsonMappingException: Can not instantiate value of type [simple type, class ksbysample.webapp.lending.service.calilapi.LibraryForJackson2Xml] from String value ('Special_Aacf'); no single-String constructor/factory method というエラーメッセージが出力されました。

f:id:ksby:20150830171949p:plain

エラーメッセージの原因を調べて分かったことは ( 全然分からなくてかなり時間がかかりました。。。 )、図書館データベースAPIXML レスポンスは以下の形式で返ってきますが、

<Libraries>
  <Library>
    <systemid>Special_Aacf</systemid>
    ....
  </Library>
  <Library>
    ....
  </Library>
  .....
</Libraries>

RootElement 直下の <Library>...</Library> が繰り返される部分を以下の書き方で List にセットすることはできない、ということでした。

@XmlElement(name = "Library")  
private List<LibraryForJackson2Xml> libraryList;  

List ではなく以下のように書くと、一番最後の <Library>...</Library> のデータが libraryList にセットされます。

@XmlElement(name = "Library")  
private LibraryForJackson2Xml libraryList;  

f:id:ksby:20150830173454p:plain

XML が以下のような形式であれば、

<Libraries>
  <LibraryList>
    <Library>
      <systemid>Special_Aacf</systemid>
      ....
    </Library>
    <Library>
      ....
    </Library>
    .....
  </LibraryList>
</Libraries>

以下の書き方で List に取り込めるようです。

@XmlElementWrapper(name = "LibraryList")
@XmlElement(name = "Library")  
private List<LibraryForJackson2Xml> libraryList;  

Simple ライブラリを使った時も @ElementList(inline = true) のように inline = true という定義を追加して取り込めるようになっていましたが、RootElement 直下に同じ Element 名のデータが列挙されると JAXB の @XmlElement アノテーションを単に付けただけでは List に取り込めない、という原因でした。

@XmlElement アノテーションはフィールドだけでなくメソッドにも付加できることが調査中に分かりましたので、メソッドに付加して、メソッドが呼び出されたらリストに追加するように変更します。

<Library>...</Library> を List へ取り込めるようにする

  1. src/main/java/ksbysample/webapp/lending/service/calilapi の下の LibrariesForJackson2Xml.javaリンク先のその2の内容 に変更します。

  2. <Library>...</Library> のデータが取り込めているか確認しやすいようにするためにテストメソッドを変更します。src/test/java/ksbysample/webapp/lending/service/calilapi の下の CalilApiServiceTest.javaリンク先のその2の内容 に変更します。

  3. テストを実行します。testGetLibraryListByJackson2Xml メソッドにカーソルを移動し、コンテキストメニューを表示後「Run 'testGetLibraryList...()' with Coverage」を選択します。

    今度は <Library>...</Library> のデータを正常に取り込めました。

    f:id:ksby:20150830175851p:plain

RestTemplate の getForEntity メソッドからダイレクトに LibrariesForJackson2Xml クラスに変換する

  1. RestTemplate の getForEntity メソッドからダイレクトに LibrariesForJackson2Xml クラスに変換してみます。src/main/java/ksbysample/webapp/lending/service/calilapi の下の CalilApiService.javaリンク先のその1の内容 に戻します。

  2. テストを実行します。testGetLibraryListByJackson2Xml メソッドにカーソルを移動し、コンテキストメニューを表示後「Run 'testGetLibraryList...()' with Coverage」を選択します。

    なぜか <Library>...</Library> の部分が1件も取り込まれていませんでした。。。 原因を調査します。

    f:id:ksby:20150830184451p:plain

<Library>...</Library> が1件も取り込まれなかった原因を調べる ( その2 )

  • デバッガでソースの中を追って調べるしかないかと思い、調べてみましたが全然分かりませんでした。。。 ただし RestTemplate の MessageConverter には MappingJackson2XmlHttpMessageConverter が使用されていることは分かりました。
  • 取り込まれた時と取り込まれていない時の実装を比較してみると、
    • MappingJackson2XmlHttpMessageConverter を使用しているので、XmlMapper が使用されているという考えで間違いないはず。
    • そうすると考えられるのは ObjectMapper クラスの findAndRegisterModules メソッドが呼び出されておらず、変換時に jackson-dataformat-xml が有効になっていない???
    • jackson-datatype-jsr310 の時は何もしなくても良かったので同じものと考えていたのですが、MappingJackson2XmlHttpMessageConverter が使用される時は設定されていないのかもしれません。

ということで RestTemplate に MessageConverter を設定するよう修正します。

RestTemplate の getForEntity メソッドからダイレクトに LibrariesForJackson2Xml クラスに変換する ( その2 )

  1. src/main/java/ksbysample/webapp/lending/service/calilapi の下の CalilApiService.javaリンク先のその3の内容 に変更します。

  2. テストを実行します。testGetLibraryListByJackson2Xml メソッドにカーソルを移動し、コンテキストメニューを表示後「Run 'testGetLibraryList...()' with Coverage」を選択します。

    getForEntity メソッドからダイレクトに LibrariesForJackson2Xml クラスに変換して <Library>...</Library> のデータを正常に取り込めるようになりました。

    f:id:ksby:20150830192311p:plain

  3. テストメソッドを「testGetLibraryList_都道府県名が正しい場合()」メソッドに合わせます。src/test/java/ksbysample/webapp/lending/service/calilapi の下の CalilApiServiceTest.javaリンク先のその3の内容 に変更します。テストも実行して成功することを確認します。

  4. 一旦ここで commit します。

commit、Push、Pull Request、マージ

  1. コマンドラインから git rebase コマンドで commit を1つにまとめます。

  2. GitHub へ Push、1.0.x-try-resttemplate-and-webapi -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-try-resttemplate-and-webapi ブランチを削除、をします。

@RestController&RestTemplate の XML 対応をまとめてみる

今まで調査・実装した結果をまとめておきます。詳しく使い込んでいる訳ではないので間違っている部分もあるかもしれません。

  • JSON のみで OK ならば XML なんて取り扱わない方がいいです。Jackson & JSON は使い勝手もよく、Spring Boot & Spring Framework のサポートも秀逸です。今ってこんな簡単に実装できてしまうんだ、と本当に思いました。
  • JAXB の使い勝手が悪い気がします。標準仕様なのかもしれませんが、Jackson の使い勝手がよいので避けられるなら避けたいですね。
  • @RestController アノテーションを付加したクラスから XML データを返す場合の感想です。
    • jackson-dataformat-xml を使ってやるのが一番楽だと思います。Java オブジェクト → XML 変換には RestTemplate の時のような苦労はなく Jackson & JSON の時と同じ感じで使える印象です。
    • Simple ライブラリが使えれば楽なのですが、標準では対応していないと思います。Web で調べていた時には Spring for Android と一緒に見かけたのでこれがあればよさそうな気がしますが、試していないので分かりません。
  • RestTemplate で XML データを返す WebAPI を呼び出す場合の感想です。
    • jackson-dataformat-xml を使えば JSON の時とある程度同様の使い勝手が得られるのですが、JAXB の仕様の使い勝手の悪さに一部引きずられている気がします。うまく動かない時の調査はすごく大変でした。
    • Simple ライブラリは JAXB の仕様を引きずっておらず、使いやすいライブラリでした。可能ならばこのライブラリを使用することをお勧めします。ただし RestController で返す処理も実装する場合には、RestTemplate の時は Simple ライブラリ、RestController の時は jackson-dataformat-xml になるのはあまり良い気はしないので、その場合には jackson-dataformat-xml で揃えた方がよいのかな。。。

ソースコード

OpenWeatherMapApiService.java

package ksbysample.webapp.lending.service.openweathermapapi;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Service
public class OpenWeatherMapApiService {

    private int CONNECT_TIMEOUT = 5000;
    private int READ_TIMEOUT = 5000;

    private final String URL_WEATHERAPI_5DAY3HOURFORECAST = "http://api.openweathermap.org/data/2.5/forecast?q={cityname}";

    public FiveDayThreeHourForecastData getFiveDayThreeHourForecast(String cityname) {
        RestTemplate restTemplate = new RestTemplate(getClientHttpRequestFactory());
        ResponseEntity<FiveDayThreeHourForecastData> response
                = restTemplate.getForEntity(URL_WEATHERAPI_5DAY3HOURFORECAST, FiveDayThreeHourForecastData.class, cityname);
        return response.getBody();
    }

    public FiveDayThreeHourForecastData getFiveDayThreeHourForecastByJSONP(String cityname, String callback) throws IOException {
        assert(StringUtils.isNotBlank(callback));

        // callback パラメータを付加して WebAPI を呼び出し、レスポンスを String で受け取る
        // ※今回は RestTemplate に Map でパラメータを渡す
        RestTemplate restTemplate = new RestTemplate(getClientHttpRequestFactory());
        String url = URL_WEATHERAPI_5DAY3HOURFORECAST + "&callback={callback}";
        Map<String, String> vars = new HashMap<>();
        vars.put("cityname", cityname);
        vars.put("callback", callback);
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class, vars);

        // レスポンスの文字列から "callback関数名(" + ");" の部分を削除して JSON 文字列を抽出する
        String body = response.getBody();
        StringBuilder json = new StringBuilder(body.length() - callback.length() - 3);
        json.append(body.substring(callback.length() + 1, body.length() - 3));
        
        // JSON 文字列から Java オブジェクトへ変換する
        // ※自分で ObjectMapper オブジェクトを生成した時には findAndRegisterModules メソッドを呼び出さないと
        //   jackson-datatype-jsr310 による LocalDateTime 変換が行われない
        ObjectMapper mapper = new ObjectMapper();
        mapper.findAndRegisterModules();
        FiveDayThreeHourForecastData fiveDayThreeHourForecastData
                = mapper.readValue(json.toString(), FiveDayThreeHourForecastData.class); 
        
        return fiveDayThreeHourForecastData;
    }

    private ClientHttpRequestFactory getClientHttpRequestFactory() {
        // 接続タイムアウト、受信タイムアウトを 5秒に設定する
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(CONNECT_TIMEOUT);
        factory.setReadTimeout(READ_TIMEOUT);
        return factory;
    }

}
  • getFiveDayThreeHourForecastByJSONP メソッドを追加します。

OpenWeatherMapApiServiceTest.java

package ksbysample.webapp.lending.service.openweathermapapi;

import ksbysample.webapp.lending.Application;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class OpenWeatherMapApiServiceTest {

    @Autowired
    private OpenWeatherMapApiService openWeatherMapApiService;
    
    @Test
    public void testGetFiveDayThreeHourForecast() throws Exception {
        FiveDayThreeHourForecastData fiveDayThreeHourForecastData
                = openWeatherMapApiService.getFiveDayThreeHourForecast("Tokyo");
        assertThat(fiveDayThreeHourForecastData.getCity().getName()).isEqualTo("Tokyo");
        assertThat(fiveDayThreeHourForecastData.getCity().getCountry()).isEqualTo("JP");
        assertThat(fiveDayThreeHourForecastData.getList().size()).isGreaterThan(0);
//        System.out.println(fiveDayThreeHourForecastData);
    }

    @Test
    public void testGetFiveDayThreeHourForecastByJSONP() throws Exception {
        FiveDayThreeHourForecastData fiveDayThreeHourForecastData
                = openWeatherMapApiService.getFiveDayThreeHourForecastByJSONP("Tokyo", "func");
        assertThat(fiveDayThreeHourForecastData.getCity().getName()).isEqualTo("Tokyo");
        assertThat(fiveDayThreeHourForecastData.getCity().getCountry()).isEqualTo("JP");
        assertThat(fiveDayThreeHourForecastData.getList().size()).isGreaterThan(0);
        System.out.println(fiveDayThreeHourForecastData);
    }

}
  • testGetFiveDayThreeHourForecastByJSONP メソッドを追加します。

build.gradle

dependencies {
    def jdbcDriver = "org.postgresql:postgresql:9.4-1201-jdbc41"

    // spring-boot-gradle-plugin によりバージョン番号が自動で設定されるもの
    // Appendix E. Dependency versions ( http://docs.spring.io/spring-boot/docs/current/reference/html/appendix-dependency-versions.html ) 参照
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile("org.thymeleaf.extras:thymeleaf-extras-springsecurity3")
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("org.springframework.boot:spring-boot-starter-velocity")
    compile("org.springframework.boot:spring-boot-starter-mail")
    compile("org.springframework.boot:spring-boot-starter-security")
    compile("org.springframework.boot:spring-boot-starter-redis")
    compile("org.codehaus.janino:janino")
    testCompile("org.springframework.boot:spring-boot-starter-test")
    // (ここから) gradle でテストを実行した場合に spring-security-test-4.0.1.RELEASE.jar しか classpath に指定されず
    // テストが失敗したため、3.2.7.RELEASE を明記している
    testCompile("org.springframework.security:spring-security-core:3.2.7.RELEASE")
    testCompile("org.springframework.security:spring-security-web:3.2.7.RELEASE")
    // (ここまで) ------------------------------------------------------------------------------------------------------
    testCompile("org.springframework.security:spring-security-test:4.0.1.RELEASE")
    testCompile("org.yaml:snakeyaml")

    // spring-boot-gradle-plugin によりバージョン番号が自動で設定されないもの
    compile("${jdbcDriver}")
    compile("org.seasar.doma:doma:2.3.1")
    compile("org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16")
    compile("org.apache.commons:commons-lang3:3.4")
    compile("org.projectlombok:lombok:1.16.4")
    compile("com.google.guava:guava:18.0")
    compile("org.springframework.session:spring-session:1.0.1.RELEASE")
    compile("org.simpleframework:simple-xml:2.7.1")
    compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.6.1")
    compile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.5.3")
    testCompile("org.dbunit:dbunit:2.5.1")
    testCompile("com.icegreen:greenmail:1.4.1")
    testCompile("org.assertj:assertj-core:3.1.0")
    testCompile("com.jayway.jsonpath:json-path:2.0.0")

    // for Doma-Gen
    domaGenRuntime("org.seasar.doma:doma-gen:2.3.1")
    domaGenRuntime("${jdbcDriver}")
}
  • compile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.5.3") を追加します。

CityResponse.java

package ksbysample.webapp.lending.webapi.weather;

import ksbysample.webapp.lending.service.openweathermapapi.CityData;
import lombok.Data;
import org.springframework.beans.BeanUtils;

import java.util.Map;

@Data
public class CityResponse {

    private String name;

    private Map<String, String> coord;

    private String country;
    
    CityResponse(CityData cityData) {
        BeanUtils.copyProperties(cityData, this);
    }
    
}

ForecastResponse.java

package ksbysample.webapp.lending.webapi.weather;

import com.fasterxml.jackson.annotation.JsonFormat;
import ksbysample.webapp.lending.service.openweathermapapi.ForecastData;
import lombok.Data;
import org.springframework.beans.BeanUtils;

import javax.xml.bind.annotation.XmlElement;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@Data
public class ForecastResponse {

    private BigDecimal dt;

    private List<Map<String, String>> weather;

    private Map<String, String> wind;

    @XmlElement(name = "dt_txt")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime dtTxt;
    
    ForecastResponse(ForecastData forecastData) {
        BeanUtils.copyProperties(forecastData, this);
    }
    
}
  • 出力時の項目名を JSON の "dt_txt" と合わせるために @XmlElement(name = "dt_txt") を付加しています。
  • LocalDateTime のフィールドの値をフォーマットするために @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") を付加しています。XML で出力する時も JSON の時と同じ @JsonFormat アノテーションを使用します。
  • JSON 用データクラス ForecastData からデータをコピーするためのコンストラクタを追加します。

FiveDayThreeHourForecastResponse.java

package ksbysample.webapp.lending.webapi.weather;

import ksbysample.webapp.lending.service.openweathermapapi.FiveDayThreeHourForecastData;
import lombok.Data;
import org.springframework.beans.BeanUtils;

import javax.xml.bind.annotation.XmlRootElement;
import java.util.List;
import java.util.stream.Collectors;

@XmlRootElement(name = "FiveDayThreeHourForecastData")
@Data
public class FiveDayThreeHourForecastResponse {

    private CityResponse city;

    private int cnt;

    private List<ForecastResponse> list;

    FiveDayThreeHourForecastResponse(FiveDayThreeHourForecastData fiveDayThreeHourForecastData) {
        BeanUtils.copyProperties(fiveDayThreeHourForecastData, this, "city", "list");
        city = new CityResponse(fiveDayThreeHourForecastData.getCity());
        list = fiveDayThreeHourForecastData.getList().stream()
                .map(ForecastResponse::new)
                .collect(Collectors.toList());
    }

}
  • JAXB の @XmlRootElement アノテーションを付加します。
  • JSON 用データクラス FiveDayThreeHourForecastData からデータをコピーするためのコンストラクタを追加します。
  • 本来 JAXB のアノテーションを付加したクラスには引数がないコンストラクタを作成する必要がありますが、内部の処理を Jackson がしているので作成する必要はありません。

WeatherController.java

package ksbysample.webapp.lending.webapi.weather;

import ksbysample.webapp.lending.service.openweathermapapi.FiveDayThreeHourForecastData;
import ksbysample.webapp.lending.service.openweathermapapi.OpenWeatherMapApiService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/webapi/weather")
public class WeatherController {

    @Autowired
    private OpenWeatherMapApiService openWeatherMapApiService;
    
    @RequestMapping("/getFiveDayThreeHourForecast")
    public FiveDayThreeHourForecastResponse getFiveDayThreeHourForecast(String cityname) {
        FiveDayThreeHourForecastData fiveDayThreeHourForecastData
                = openWeatherMapApiService.getFiveDayThreeHourForecast(cityname);
        FiveDayThreeHourForecastResponse fiveDayThreeHourForecastResponse
                = new FiveDayThreeHourForecastResponse(fiveDayThreeHourForecastData);
        return fiveDayThreeHourForecastResponse;
    }

}

LibraryForJackson2Xml.java

package ksbysample.webapp.lending.service.calilapi;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.ToString;

@JsonIgnoreProperties(ignoreUnknown = true)
@Data
@ToString
public class LibraryForJackson2Xml {

    private String systemid;

    private String systemname;

    @XmlElement(name = "formal")
    private String formalName;

    private String address;
    
}
  • 定義していない項目は無視するために @JsonIgnoreProperties(ignoreUnknown = true) を付加します。
  • データ確認用に lombok の @ToString アノテーションを付加します。

LibrariesForJackson2Xml.java

■その1

package ksbysample.webapp.lending.service.calilapi;

import lombok.Data;

import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.List;

@XmlRootElement(name = "Libraries")
@Data
public class LibrariesForJackson2Xml {

    @XmlElement(name = "Library")
    private List<LibraryForJackson2Xml> libraryList;

}

■その2

package ksbysample.webapp.lending.service.calilapi;

import lombok.Data;

import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.ArrayList;
import java.util.List;

@XmlRootElement(name = "Libraries")
@Data
public class LibrariesForJackson2Xml {

    private List<LibraryForJackson2Xml> libraryList = new ArrayList<>();

    @XmlElement(name = "Library")
    public void addLibraryList(LibraryForJackson2Xml library) {
        libraryList.add(library);
    }    
    
}
  • addLibraryList メソッドを追加し、このメソッド@XmlElement(name = "Library") を付加します。これで <Library>...</Library> が現れる度にこのメソッドが呼び出されるようになります。

CalilApiService.java

■その1

package ksbysample.webapp.lending.service.calilapi;

import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
@PropertySource("classpath:calilapi.properties")
public class CalilApiService {

    private int CONNECT_TIMEOUT = 5000;
    private int READ_TIMEOUT = 5000;
    
    private final String URL_CALILAPI_LIBRALY = "http://api.calil.jp/library?appkey={appkey}&pref={pref}";

    @Value("${calil.apikey}")
    private String calilApiKey;

    public Libraries getLibraryList(String pref) throws Exception {
        // 図書館データベースAPIを呼び出して XMLレスポンスを受信する
        RestTemplate restTemplate = new RestTemplate(getClientHttpRequestFactory());
        ResponseEntity<String> response
                = restTemplate.getForEntity(URL_CALILAPI_LIBRALY, String.class, this.calilApiKey, pref);
        
        // 受信した XMLレスポンスを Javaオブジェクトに変換する
        Serializer serializer = new Persister();
        Libraries libraries = serializer.read(Libraries.class, response.getBody());
        
        return libraries;
    }

    public LibrariesForJackson2Xml getLibraryListByJackson2Xml(String pref) throws Exception {
        // 図書館データベースAPIを呼び出して XMLレスポンスを受信する
        RestTemplate restTemplate = new RestTemplate(getClientHttpRequestFactory());
        ResponseEntity<LibrariesForJackson2Xml> response
                = restTemplate.getForEntity(URL_CALILAPI_LIBRALY, LibrariesForJackson2Xml.class, this.calilApiKey, pref);
        return response.getBody();
    }

    private ClientHttpRequestFactory getClientHttpRequestFactory() {
        // 接続タイムアウト、受信タイムアウトを 5秒に設定する
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(CONNECT_TIMEOUT);
        factory.setReadTimeout(READ_TIMEOUT);
        return factory;
    }
    
}
  • getLibraryListByJackson2Xml メソッドを追加します。XMLJava オブジェクトへ自動変換される想定で、単に RestTemplate の getForEntity メソッドを呼び出すだけにしています。

■その2

    public LibrariesForJackson2Xml getLibraryListByJackson2Xml(String pref) throws Exception {
        // 図書館データベースAPIを呼び出して XMLレスポンスを受信する
        RestTemplate restTemplate = new RestTemplate(getClientHttpRequestFactory());
        ResponseEntity<String> response
                = restTemplate.getForEntity(URL_CALILAPI_LIBRALY, String.class, this.calilApiKey, pref);

        ObjectMapper mapper = new XmlMapper();
        mapper.findAndRegisterModules();
        LibrariesForJackson2Xml libraries = mapper.readValue(response.getBody(), LibrariesForJackson2Xml.class);

        return libraries;
    }
  • getLibraryListByJackson2Xml メソッドを上記の内容に変更します。
    • restTemplate.getForEntity では String.class で結果を受け取るようにします。
    • その後、XmlMapper のインスタンスを生成して readValue メソッドXMLJava オブジェクトへ変換します。

■その3

package ksbysample.webapp.lending.service.calilapi;

import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter;
import org.springframework.stereotype.Service;
import org.springframework.util.ClassUtils;
import org.springframework.web.client.RestTemplate;

import java.util.ArrayList;
import java.util.List;

@Service
@PropertySource("classpath:calilapi.properties")
public class CalilApiService {

    private int CONNECT_TIMEOUT = 5000;
    private int READ_TIMEOUT = 5000;
    
    private final String URL_CALILAPI_LIBRALY = "http://api.calil.jp/library?appkey={appkey}&pref={pref}";

    @Value("${calil.apikey}")
    private String calilApiKey;

    public Libraries getLibraryList(String pref) throws Exception {
        // 図書館データベースAPIを呼び出して XMLレスポンスを受信する
        RestTemplate restTemplate = new RestTemplate(getClientHttpRequestFactory());
        ResponseEntity<String> response
                = restTemplate.getForEntity(URL_CALILAPI_LIBRALY, String.class, this.calilApiKey, pref);
        
        // 受信した XMLレスポンスを Javaオブジェクトに変換する
        Serializer serializer = new Persister();
        Libraries libraries = serializer.read(Libraries.class, response.getBody());
        
        return libraries;
    }

    public LibrariesForJackson2Xml getLibraryListByJackson2Xml(String pref) throws Exception {
        // 図書館データベースAPIを呼び出して XMLレスポンスを受信する
        RestTemplate restTemplate = new RestTemplate(getClientHttpRequestFactory());
        restTemplate.setMessageConverters(getMessageConvertersforJackson2XML());
        ResponseEntity<LibrariesForJackson2Xml> response
                = restTemplate.getForEntity(URL_CALILAPI_LIBRALY, LibrariesForJackson2Xml.class, this.calilApiKey, pref);
        return response.getBody();
    }

    private ClientHttpRequestFactory getClientHttpRequestFactory() {
        // 接続タイムアウト、受信タイムアウトを 5秒に設定する
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(CONNECT_TIMEOUT);
        factory.setReadTimeout(READ_TIMEOUT);
        return factory;
    }

    private List<HttpMessageConverter<?>> getMessageConvertersforJackson2XML() {
        // build.gralde に compile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:...") を記述して jackson-dataformat-xml
        // が使用できるようになっていない場合にはエラーにする
        assert(ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", RestTemplate.class.getClassLoader()));

        MappingJackson2XmlHttpMessageConverter mappingJackson2XmlHttpMessageConverter
                = new MappingJackson2XmlHttpMessageConverter();
        // findAndRegisterModules メソッドを呼び出して jackson-dataformat-xml が機能するようにする
        mappingJackson2XmlHttpMessageConverter.setObjectMapper(new XmlMapper().findAndRegisterModules());

        List<MediaType> mediaTypes = new ArrayList<>();
        mediaTypes.add(MediaType.APPLICATION_XML);
        mediaTypes.add(MediaType.TEXT_XML);
        mappingJackson2XmlHttpMessageConverter.setSupportedMediaTypes(mediaTypes);

        List<HttpMessageConverter<?>> messageConverters = new ArrayList<>();
        messageConverters.add(mappingJackson2XmlHttpMessageConverter);
        return messageConverters;
    }
    
}
  • getMessageConvertersforJackson2XML を追加します。assert の部分は RestTemplate クラスのソースを見て入れました。このメソッドは jackson-dataformat-xml が有効な時だけ使えるようにしています。
  • getLibraryListByJackson2Xml メソッド内の処理に restTemplate.setMessageConverters(getMessageConvertersforJackson2XML()); を追加します。

CalilApiServiceTest.java

■その1

    @Test
    public void testGetLibraryListByJackson2Xml() throws Exception {
        LibrariesForJackson2Xml libraries = calilApiService.getLibraryListByJackson2Xml("東京都");
        assertThat(libraries).isNotNull();
        System.out.println(libraries);
    }
  • testGetLibraryListByJackson2Xml メソッドを追加します。

■その2

    @Test
    public void testGetLibraryListByJackson2Xml() throws Exception {
        LibrariesForJackson2Xml libraries = calilApiService.getLibraryListByJackson2Xml("東京都");
        assertThat(libraries).isNotNull();
        System.out.println(libraries.toString());
        assertThat(libraries.getLibraryList()).isNotNull();
        libraries.getLibraryList().stream()
                .forEach(s -> System.out.println(s.toString()));
    }
  • libraries.getLibraryList() のデータを1件1行で出力されるようにします。

■その3

    @Test
    public void testGetLibraryListByJackson2Xml() throws Exception {
        LibrariesForJackson2Xml libraries = calilApiService.getLibraryListByJackson2Xml("東京都");
        assertThat(libraries).isNotNull();
        // systemname が "国立国会図書館" のデータがあるかチェックする
        assertThat(libraries.getLibraryList())
                .extracting("systemname")
                .contains("国立国会図書館");
    }

履歴

2015/08/30
初版発行。

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( 番外編 )( プロジェクトで使用する Java SE を 8u51 → 8u60 へ )

Java SE の 8u60 がリリースされていたので、インストーラーをダウンロードしてインストールし、環境変数 JAVA_HOME のパスを C:\Java\jdk1.8.0_60 へ変更しました。

ksbysample-webapp-lending で使用する Java SE を 8u60 へ変更します。

  1. IntelliJ IDEA のメインメニューから「File」->「Project Structure...」を選択します。

  2. 「Project Structure」ダイアログが表示されます。左側のリストから「Project Settings」->「Project」を選択し、右側の画面の「Project SDK」の中の「New...」->「JDK」を選択します。

    f:id:ksby:20150825011853p:plain

  3. 「Select Home Directory for JDK」ダイアログが表示されます。環境変数 JAVA_HOME に設定されている C:\Java\jdk1.8.0_60 が選択された状態で表示されますので、何もせずに「OK」ボタンをクリックします。

    f:id:ksby:20150825012010p:plain

  4. 「Project Structure」ダイアログに戻ります。「Project SDK」のドロップダウンリストに選択されている JDK の表示が 1.8 (java version "1.8.0_60") となっているので、1.8 → 1.8.0_60 に変更します。「Project SDK」の中の「Edit」ボタンをクリックします。

    f:id:ksby:20150825012200p:plain

  5. 「Platform Settings」->「SDKs」が選択された状態になります。画面右上の「Name」に表示されている文字列を "1.8" → "1.8.0_60" に変更します。

    f:id:ksby:20150825012408p:plain

  6. 中央のリストに表示されている「1.8.0_51」を選択した後、「Delete」ボタンをクリックして削除します。

    f:id:ksby:20150825012611p:plain

  7. 左側のリストから「Project Settings」->「Project」を選択し、右側の画面の「Project language level」のドロップダウンリストで「SDK default (8 - Lambdas, type annotations etc.)」を選択します。選択後、「OK」ボタンをクリックして「Project Structure」ダイアログを閉じます。

    f:id:ksby:20150825012749p:plain

  8. メイン画面に戻ると画面下部に処理進行中のメッセージが表示されますので、終了するまで待機します。

  9. Gradle projects View から clean タスクを実行します。

  10. メインメニューから「Build」->「Rebuild Project」を選択して build し直し、エラーメッセージが何も表示されないことを確認します。

  11. Gradle projects View から build タスクを実行して "BUILD SUCCESSFUL" のメッセージが表示されることを確認します。

    f:id:ksby:20150825013119p:plain

  12. 特に問題は発生しませんでした。8u60 で開発を進めたいと思います。

※8u60 の新機能の件は書いてあった記事やソースコードを見てみたのですが、自分には難しくてよく分かりませんでした。すぐに必要な機能でもなさそうでしたので先に進むことにします。