Google先生がなかなか教えてくれなかったので、穴埋め係させていただきます!
※Angular2/4 の前身の某JSのことを A......JS と伏せ字にしていますが、検索キーワードに引っかかってほしくないためです。別に裏の意図はありません……。
何がしたい?
Angular経験1ヶ月程度の初心者さんなのですが、ふつうにhttpモジュールでPOSTリクエストをしようとして、CORSで壮大にコケたというお話です。OPTIONSという謎のリクエストが走ったり。どうやって突破するのか?
CORS(Cross-Origin Resource Sharing)とは?
今見ているサイトとは違うサイトから、情報を取ってきたり渡したりしたらダメだよ、というブラウザのセキュリティ対策です。詳しくはこちらとか。→ HTTP アクセス制御 (CORS)
そもそも、フロントとバックが別のサイトを参照している、なんて不自然な状況かもしれませんが、開発段階で、フロントはローカル環境、バックは本サーバ(テストサーバ)という状況はよくあるんじゃないかと思うんです。
結論 ―― とりあえず解決する方法
理屈だとか、高度なセキュリティだとかは置いといて、とにかく動くようにしたい場合の結論ファースト。
フロント側 Angular の書き方
結局は、A......JS のドキュメントでも言われているとおり、リクエストヘッダに1文加える方法がカンタンです。Augular2/Angular4では、下記のようにします。
import { Component, OnInit } from '@angular/core';
import 'rxjs/add/operator/map'
import { Http, Headers } from "@angular/http"; // Http に加えて Headers をインポート
@Component({
selector: 'app-sample',
templateUrl: './sample.component.html',
styleUrls: ['./sample.component.scss']
})
export class SampleComponent implements OnInit {
headers :Headers;
constructor( private http: Http ) {
}
ngOnInit() {
// POST で使うヘッダーを作っておく
this.headers = new Headers();
this.headers.append('Content-Type', 'application/x-www-form-urlencoded');
}
// 何かしらHTTPリクエストを走らせるイベントなど
onSampleSubmitted( postParam :Array )
{
this.http.post('https://myhost.com/api/', postParam, {headers: headers} ) // ここでheadersを追加
.subscribe( response => console.log(response.json()) );
}
}
サーバー側の書き方
CORS問題はフロント側だけは解決できません……。
htaccess に書く
APIのエンドポイント(上記で言うと /api ディレクトリ )に、下記を設置してしまうのがカンタンです。言語を選ばないし。
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin *
</IfModule>
PHPでJSONを受け取る
PHPの場合は、これにもうひと工夫必要です。Angularから送られてくるPOST情報は、PHPが理解できるForm形式( key=value1&key2=value2 というURLパラメータの形式)ではなく、JSONです。なので、いつもの $_POST で受け取ろうとすると空っぽ。そこで次のようにする必要があります。
switch( $_SERVER["REQUEST_METHOD"] ){
case 'POST':
$json_string = file_get_contents('php://input');
$posted_json = json_decode($json_string,true); // これを $_POST代わりに使う
break;
}
2017.10.15 加筆 json_decode は第2引数を true にして配列に。 $_POST と同じように使えるので。
解説 ―― もう少しちゃんとした解決策
上記解決策はいくつか「キモチワルイ」ところがあります。それをちゃんと解決するために、実際にフロントとサーバーとどういうやり取りをしているのか、詳しく見ながら検証します。
Allow-Origin * 問題
せっかくCORSのために強力なセキュリティが用意されているのに、そのドアを「全開」にしておくのは褒められたものではありません。Angularであれば、基本的にフロントは特定のサーバー(この場合はローカルホスト)しかないので、Allow-Originもそのサーバーに限定します。
(localhostに限定って、localhostは誰でも建てられるからセキュリティとして全然用をなしてないという声はスルーします……)
問題はどうやってそれを書くか? サブドメインは? http は要るの要らないの?
実は難しく考える必要はありません。先の .htaccess を一旦削除してAPIアクセスをして、ブラウザのコンソールを見てください。
親切に「Access-Control-Allow-Origin で http://localhost:4200 が許可されていません」と表示されています。それをそのまま書きます。
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin http://localhost:4200
</IfModule>
httpだけでなく、ポート番号まで要るとは思いませんでした……。よく考えたら、これはブラウザが単純に文字列判定するためのもので、サーバー側からアクセスできるかどうかは関係ない、ということなのかもしれません。
Content-Type: form-urlencoded 問題
そもそもなぜ application/x-www-form-urlencoded にするのか?というと、こうしておくと、ブラウザがCORSのための「プリフライトリクエスト」を行わないため、とあります。わかりやすく言うと、CORSのセキュリティを1つ省略する=ちょっとセキュリティが弱くなるということ。
もう1つ問題があって、リクエストヘッダで「コンテンツはFORMだ」と言ってるのに実際はJSONと、宣言と中身が違うこと。PHPなどの受け取り側はあまり気にしてないので違ってても支障はありませんが、礼儀として、中身がJSONなら Content-Type も application/json としたいところです。
下記が、別のツールを使って正しい form-urlencoded と json のリクエストを作ってみたところ。宣言と中身が一致しているリクエストです。
content-length: 21
content-type: application/x-www-form-urlencoded # FORM
key1=value1&key2=value2 # FORM
content-length: 33
content-type: application/x-www-form-urlencoded # FORMなのに
{"key1":"value1","key2":"value2"} # JSON
content-length: 33
content-type: application/json # JSON!
{"key1":"value1","key2":"value2"} # JSON!!
プリフライトリクエスト OPTIONS に応答する
結論を先に言うと、難しいことを考える必要はなくて、.htaccess に必要なことを書いてあげるだけです。
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin http://localhost:4200
Header set Access-Control-Allow-Headers content-type
Header set Access-Control-Allow-Methods GET, POST, PUT, DELETE
</IfModule>
Angular を修正
サーバーの準備できたら、Angularも修正します。
下記のようにすると、OPTIONSのプリフライトリクエストが走るようになりますが、サーバーからちゃんと許可が降りると、その後すぐにPOSTが走ります。
ngOnInit() {
this.headers = new Headers();
// this.headers.append('Content-Type', 'application/x-www-form-urlencoded');
this.headers.append('Content-Type', 'application/json');
}
プリフライトリクエスト OPTIONS とは?
プリフライトリクエストとは、ブラウザが、CORSにあたるとき、別サーバーに対して何が許可されているかを実際のリクエストをする前に確認するリクエストで、実際にPOSTの前にOPTIONSというリクエストを飛ばします。
このOPTIONSというリクエストに対して、Allow-Methods として POST が返ってきたら、本番の POST をリクエストするという仕組みです。
補足 やらなくていいこと
OPTIONS を含める
許可メソッドに OPTIONS を含める必要はありません。
OPTIONS 以外に何が許可されているかを問い合わせるのが OPTIONS なので、OPTIONS がなくても動きます。
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin http://localhost:4200
Header set Access-Control-Allow-Headers content-type
Header set Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS
</IfModule>
と書いたところで……。どうも Allow-Methods 自体も必要ないようです。これは、上の図のように、サーバーが Allow-Methodsを返してフロントで判断するのではなく、その前にサーバーが Request-Methods を見て許可するか判断しろ、ということなのかも。
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin http://localhost:4200
Header set Access-Control-Allow-Headers content-type
</IfModule>
OPTIONSのリクエストに明示的に応える
例えばPHPのプログラム内で、MethodがOPTIONSの場合の処理を用意する必要はありません。.htaccessの応答だけで十分。仮に何かを出力しても、OPTIONSの応答では、ブラウザは本文を無視します。
我が家では下記のように、なにもしないことを明記しています。
switch( $_SERVER["REQUEST_METHOD"] ){
case 'POST':
$json_string = file_get_contents('php://input');
$posted_json = json_decode($json_string); // これを $_POST代わりに使う
break;
case 'OPTIONS':
exit;
}
感想
というわけで、AngularでPOSTしたかっただけなのにどっぷりハマって、HTTPのリクエストと応答の仕組みまでいろいろ勉強させていただきました。FORM形式とJSONの違いとか、JSONだとPHPは認識しないなど、知らなかったこともわかったし、クライアントとサーバー間のやりとりを、もう少しディープに書くことができるようになった、気がします。