AngularからRedmineのチケット一覧を取得する

#はじめに

この記事では、AngularからRedmineのREST APIにアクセスして、チケット一覧を取得する方法について説明します。
(環境は、この記事の執筆時点の最新バージョンである Angular 4.3.4 と Redmine 3.4.2 です)

解説は、「恋に落ちるコード.js」に登場するJavaScript好きの女子高生、絵子と樹里がお送りします。

##人物紹介

瀬尾絵子:「恋に落ちるコード.js」の登場人物。JavaScript勉強中の女子高生。
篠宮樹里: 同じく「恋に落ちるコード.js」の登場人物。JavaScriptに詳しい女子高生。
藤倉紅子:「Redmineで始める異世界人心掌握術」の登場人物。自称美人SE。二人の高校の先輩でRedmineに詳しい。

#ひな形を作成する

樹里「ではさっそく、Angular CLIを使って、RedmineのREST APIにアクセスするWebアプリを作ってみよう」
絵子「ホントにいきなりだね。Angularとは？とかRedmineとは？とか、その辺は説明しないの？」
樹里「その辺は、Qiitaに有益な記事がすでにいくつも公開されているので、そちらを読んでほしい」
絵子「……確かに。お世話になってます」
樹里「とりあえずnode.jsとAngular CLIはセットアップしておこう。乙女の新常識だ」
絵子「そうなんだ。いつの間に」
樹里「あと、Redmineが同一のホスト上で稼働しているという前提で話を進めるが……Redmine側にも準備が必要だ」
絵子「どんな？」
樹里「まず、APIを有効にするため、設定画面で『RESTによるWebサービスを有効にする』と『JSONPを有効にする』の機能をオンにしておく必要がある。また、今回は公開されたプロジェクトにアクセスするが、認証が必要なプロジェクトのチケットにアクセスするには、事前に認証用のキーを発行してパラメータに付与する必要がある」
絵子「うーん、わかった。詳しいところはあとで紅子さんに聞いてみよう」
樹里「じゃ、準備も整ったところで、サクッとひな形を作るぞ」
絵子「らじゃー」

`> ng new sample`

絵子「……これでひな形が出来たの？便利だねー」
樹里「今回は『sample』という名前のアプリにしたが、名前はなんでも良い。では、これをベースに作っていくぞ」

#JSONデータを取得するサービスのひな形を作成する

樹里「では、出来上がったディレクトリに移動しよう」

`> cd sample`

絵子「樹里！なんだか山のようにファイルが出来てるんだけど！」
樹里「とりあえず必要なのはsrcディレクトリ、さらにその中のappディレクトリだ。それ以外のファイルの役割については徐々に覚えていけばいい」
絵子「……良かった。早々に挫折するところだった」
樹里「ではさっそく、出来上がったファイルを修正していくのだが……」
絵子「まだ準備がいるの？」
樹里「今出来上がったのは、コンポーネントやモジュールといった基本的な機能を持つファイルなのだが……JSONファイルを取得する、といった独立した機能については、『サービス』として分けて管理する方が都合が良い。今後のためにも分けておこう」
絵子「はーい」
樹里「ではサービスのひな形を作成する。これも便利なコマンドがある」

`> ng g service issues`

樹里「これで、appディレクトリ内に『issues.service.spec.ts』と『issues.service.ts』が、必要なコードがすでに記述された状態で作成される」
絵子「便利すぎて逆に不安になるね」

#データを格納するクラスを作る

樹里「それから、もう一つ……」
絵子「まだあるの？早く作ろうよ」
樹里「まあ聞きなさい。JSONを取得するリクエストを投げた時、返ってくるデータは？」
絵子「……JSON、だよね……？」
樹里「そう。JSON形式のデータが返ってくる。しかし、その構造は自由自在だし、どんなデータが返ってくるかわからないのでは不安だ。なので、ちゃんと事前にクラスを定義しておき、こういう構造のデータが返ってくるよ……という受け入れ態勢を作っておく方がWebアプリとしては望ましい」
絵子「確かに、事前の心構えは大事だよね」
樹里「なので、データの構造を定義するクラスファイルをappディレクトリ内に作成しておく。名前は『issues.ts』にしよう」
絵子「はーい」
樹里「絵子も、『オレンジジュースをあげるよ』と渡されたら美味しく飲めるが、『何かしらの汁をあげるよ』と渡されたら飲みづらかろう」
絵子「渡す側に悪意があるよね」

```js:issues.ts
export class Issues {
  issues: Issue[];
  total_count: number;
  offset: number;
  limit: number;
}
export class Issue {
  id: number;
  project: FieldObject;
  tracker: FieldObject;
  status: FieldObject;
  priority: FieldObject;
  author: FieldObject;
  assigned_to: FieldObject;
  subject: string;
  description: string;
  due_date: string;
  done_ratio: number;
  custom_fields: CustomFieldObject[];
  created_on: string;
  updated_on: string;
}
export class FieldObject {
  id: number;
  name: string;
}
export class CustomFieldObject {
  id: number;
  name: string;
  value: string;
}
```

樹里「細かい説明は省くが……Redmineチケット一覧をJSON形式で取得すると、こういう構造のデータが返ってくる」
絵子「これもあとで紅子さんに聞いとこう」
樹里「概要だけ言うと……`issues`の中にチケットの情報が配列で入っている。そのチケットの構造が`Issue`オブジェクトの通りだ。`Issue`には、例えば`id`の情報がnumber型で格納されている。他には`project`の情報もあるが、これはプロジェクトの`id`と`name`が対になったオブジェクトとして格納されているので、それを`FieldObject`という名前で定義している。他も同様だ」
絵子「うん。わかった気になった」

#サービスのひな形を修正する

樹里「ファイルの作成は以上だ。あとは、出来上がったファイルを修正していく」
絵子「待ってました！」
樹里「まずはJSONを取得するサービスからだ。さっき作ったファイルのうち、今回はissues.service.spec.tsは使わない。issues.service.tsを修正していく」

```js:issues.service.ts
import { Injectable } from '@angular/core';
/* ↓(1) 必要なモジュール等のインポート */
import { Jsonp }       from '@angular/http';
import { Observable }  from 'rxjs/Observable';
import { Issues }      from './issues';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
/* ↑ */

@Injectable()
export class IssuesService {
/* ↓(2) JSONファイルのURLを定義 */
  private issuesUrl = 'http://localhost/redmine/issues.json?callback=JSONP_CALLBACK';
/* ↑ */

/* ↓(3) コンストラクタを書き換え */
//constructor() { }
  constructor(private jsonp: Jsonp) { }
/* ↑ */

/* ↓(4) JSONを取得する処理 */
  getIssues(): Observable<Issues> {
    return this.jsonp
      .get(this.issuesUrl)
      .map(response => response.json() as Issues)
      .catch(this.handleError);
  }

  private handleError(error: any): Observable<any> {
    console.error('An error occurred', error);
    return Observable.throw(error.message || error);
  }
/* ↑ */
}
```

##必要なモジュール等のインポート

樹里「ではさっそくコードの説明だが……最初は使用するモジュールや関数のインポートだ。コード内で使用するものは、最初にインポートする必要がある、とだけ覚えておけば良い。個々の役割についてはあとで説明する」
絵子「うん。あとで聞く」

##JSONファイルのURLを定義

樹里「続いて、RedmineのREST APIにアクセスするURLの定義だが……まず、『localhost/redmine/』というのがRedmineのURLだ。別ホストのRedmineにアクセスする場合には各自直すように」
絵子「おっけー」
樹里「続いて『issues.json』が、チケット一覧を取得するためのAPIだ。この後ろにパラメータを付与することにより、特定のプロジェクトやトラッカーのみの一覧を取得することも可能だ。詳しいことは[Redmine API](http://www.redmine.org/projects/redmine/wiki/Rest_api)の仕様を確認しておくように」
絵子「紅子さんに聞くこといっぱいあるな……」
樹里「最後に、『?callback=JSONP_CALLBACK』。これは、AngularからJSONP形式のデータを取得するための約束事だと思えばいい。詳しい事は割愛する」
絵子「あ、逃げた」
樹里「ちなみに、なぜJSONじゃなくてJSONP形式でデータを取得するのかと言うと……JSONはクロスドメインがどうとか、セキュリティ上の制約が厳しくて色々あるので……」
絵子「色々あるよ、こんな世の中だもの」

##コンストラクタを書き換え

樹里「これは、最初にJsonpモジュールを呼び出して変数に格納しているだけだ」

##JSONを取得する処理

樹里「この`getIssues()`関数でJSONデータを取得している。ちなみに戻り値の`Observable`とは……JSONのようなHTTPリクエストは、この`Observable`オブジェクトの形式で返ってくる……らしい」
絵子「あんまり深く聞くと可哀想だからやめておこう」
樹里「……処理の流れは、

- `get()`関数で指定したURLからデータを取得し、
- `map()`関数で実データ部分を先ほど定義した`Issues`クラスにマッピングして返す。
- エラーが発生したら`catch()`関数でエラー処理に飛ぶ。

……と言った具合だ」
絵子「ま、これで意図したデータが取れるならそれでオッケーだね」

#コンポーネントを修正する

樹里「次は、ページの動作を定義するコンポーネント、app.component.tsを修正する」
絵子「一番キモの部分ね」

```js:app.component.ts
/* ↓(1) 必要なモジュール等のインポート */
// import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { Issues }            from './issues';
import { IssuesService }     from './issues.service';
/* ↑ */

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
/*
export class AppComponent {
  title = 'app';
}
*/
/* ↓(2) JSONを取得する処理 */
export class AppComponent implements OnInit {
  issues: Issues;

  constructor(private issuesService: IssuesService) { }

  ngOnInit(): void {
    this
      .issuesService
      .getIssues()
      .subscribe( issues => this.issues = issues );
  }
}
/* ↑ */
```

##必要なモジュール等のインポート
樹里「さっきと似たような感じだが……OnInitモジュール、さっき作ったIssuesクラスとIssuesServiceサービスをインポートしている」

##JSONを取得する処理
樹里「ここがメインとなる処理だが……まずIssuesクラスを変数に格納し、IssuesServiceサービスのインスタンスを生成する。それから、OnInitモジュールをインポートすることで`ngOnInit()`メソッドを使用できるようになる」
絵子「`ngOnInit()`メソッドって？」
樹里「データバインド後に一度だけ呼び出される処理なのだが……初期化の処理はこういう風に書くことが推奨されてるらしいので、ここに書く」
絵子「はーい」
樹里「で、さっき作ったIssuesServiceの`getIssues()`を実行して、取得したデータを変数`issues`に格納する。やっている事はこれだけだ」
絵子「ほう。じゃあ、取得したデータはどう使うの？」
樹里「それは、次に説明するテンプレートの中で使われる」

```html:app.component.html
<h1>チケット一覧</h1>
<div *ngIf="issues">
  <p>
    total_count:<span>{{issues.total_count}}</span>
  </p>
  <ul>
    <li *ngFor="let issue of issues.issues">
      <span>{{issue.id}}</span> <span>{{issue.subject}}</span>
    </li>
  </ul>
</div>
```

樹里「これがテンプレートだ。見慣れたHTMLと、Angularで使用される構文が混在している」
絵子「ほんとだ」
樹里「この『{{』と『}}』に囲まれた部分に変数を記述すると、展開して表示してくれる」
絵子「『カーリーブラケット』だね。こないだ覚えた」
樹里「また、`ngFor`は、いわゆるfor文と同じく反復処理だ。データの数だけ繰り返し表示される」
絵子「なるほどー。表示させるのもカンタンだね」

#モジュールを修正する

樹里「最後に、app.module.tsの修正だ。……最後というか、本来はこのファイルが最初に呼び出される。このアプリケーションではどのようなモジュールやサービスが使われるか、という事を最初に宣言するためだ」
絵子「登場人物紹介みたいなもんね」

```js:app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { JsonpModule } from '@angular/http';  /* 追加 */
import { IssuesService } from './issues.service';  /* 追加 */

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    JsonpModule  /* 追加 */
  ],
  providers: [
    IssuesService  /* 追加 */
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }
```

樹里「このように、`JsonpModule`を`imports`の配列に、`IssuesService`を`providers`の配列にそれぞれ追加する」
絵子「とりあえず言われた通りにやっておこう」

#動作確認

樹里「これで完成だ。さっそく動かしてみよう」

`> ng serve`

絵子「……おー、チケットのIDと題名が表示されてる」
樹里「たったこれだけのコードで、しかもすべてJavaScript……まあ正確にはTypeScriptだが、ともかくこれだけでRedmineのデータを表示するWebアプリケーションを作ることが出来る。便利な時代になったものだ」
絵子「ところで……Redmineの画面を直接表示するのに比べて、どんなメリットがあるの？」
樹里「それを言われると言葉に詰まるが……せっかく便利なAPIが用意されてて、データの入出力が自由に出来るんだ。フロントエンドは自分の好きな言語で書いてみたいじゃないか」
絵子「確かに、JavaScriptでもフロントエンドが作れる！ってのは嬉しいよねー。私達からしたら」

#おわりに

というわけで、AngularからRedmineのチケット一覧を取得する方法についてお話ししました。
ほぼ必要最小限の事にしか触れていませんが、個々のファイルやメソッド等の詳細については、[Angularの公式ドキュメント](https://angular.io/docs)等で調べてみてください。(私も勉強します……)