PHP
Go
golang
CORS

CORS を分かってないから動くコード書いて理解する

CORS がわからない。

エンジニアになって1年以上たちますが、CORS について何も理解しないままやってきました。
何度か目にしたことはあったのですが、そのたびに "なんとなく" のコードを貼り付けて、やり過ごしてまいりました。

そんな僕は、あるとき "ひどい" 一日を過ごすことになります。CTOから「俺、明日休むからこのタスクおわらしといてねー」という案件がありました。普段使ってない言語とフレームワークだけど、なんとかなるだろうと取り組んだのですが、完全に迷子になって何も片付けることができませんでした。翌日はCTOに :poop: 呼ばわり。

もうこんな悲しみを味わいたくない、という思いから記事にまとめます。

理解するためのステップ

  • まずは、Go で json を返すサーバをたてる
  • PHP でクライアント用のサーバをたてる
  • PHP から Go へ XMLHttpRequest を使ってリクエスト投げる
  • GET をできるようにする
  • ただの POST は GET と同じ設定で動くことを確認する
  • POST かつ application/json で プリフライトリクエストになることを確認する
  • POST かつ application/json で動くようにする
  • Cookie が取得できないことを確認
  • Cookie を取得できるようにする

定義的なもの

Cross-Origin Resource Sharing (CORS) is a mechanism that uses additional HTTP headers to let a user agent gain permission to access selected resources from a server on a different origin (domain) than the site currently in use.

from MDN

(ざっくり訳)
CORS = HTTP ヘッダーを使うことで、異なるドメインのリソースへアクセスできるようになる仕組み。

なぜ理解できないのか?

「なんでいつまでたっても理解してないんだろう?」

と考えた結果、ドキュメントをちょこちょこ読んで、「なんとなく、こんな感じかぁ」では、ダメだと思いました。ちゃんとサーバ用意してクライアントからリクエストして、ヘッダーをひとつひとつ変えてみて、どんな挙動になるのか?どんなエラーがでるのか?を見ないと僕は理解できないんだ。と至りました。

ということで、上のステップをひとつずつ進めたいと思います。

Step1. Go で json 返すサーバを書く

まずは、Go でサーバを書きます。

server.go
package main

import (
    "net/http"
    "encoding/json"
    "log"
)

type Ping struct {
    Status int `json:"status"`
    Result string `json:"result"`
}

func rootHandler(w http.ResponseWriter, r *http.Request) {
    ping := Ping{http.StatusOK, "ok"}
    res, _ := json.Marshal(ping)

    w.Header().Set("Content-Type", "application/json")
    w.Write(res)
}


func main() {
    var httpServer http.Server
    http.HandleFunc("/", rootHandler)
    httpServer.Addr = ":8888"
    log.Println(httpServer.ListenAndServe())
}

※ 上のコードはみやすさのため書いていませんが、実際はログとか見たかったので、こんな感じでやってました。

$ go run server.go

# または、realize など。ぼくは開発中に再起動がめんどうなのでコレをつかっています。
$ realize start

これで、カール叩くと以下を得ます。

$ curl http://localhost:8888
{"status":200,"result":"ok"}%

Step2. PHP でクライアント用サーバをたてる

続いて、クライアント側として、PHP と JS を書きます。

$ tree .
.
├── index.php
└── script.js

XMLHttpReques でのリクエストの状況は、Chrome のデベロパーツールで見る気概なので、余計なことはしてません。

index.php
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <script src="./script.js"></script>
    <title>CORS PHP Client</title>
  </head>
  <body>
    Hello PHP
  </body>
</html>

まずは、GET で json をとりに行くだけです。

script.js
var xhr = new XMLHttpRequest()
var url = 'http://localhost:8888/'

const handler = () => {
  // コンソールに出力
  console.log(xhr.responseText)
}

const getRequest = () => {
  xhr.open('GET', url)
  xhr.onloadend = handler
  xhr.send()
}

document.addEventListener('DOMContentLoaded', () => {
  getRequest()
})

起動します。
$ php -S localhost:9999

これで準備ができました。

Step3. さっそく localhost:9999 (PHP) にアクセスして、エラーを眺める

では、意気揚々と http://localhost:9999/ へアクセスしてみます。 xhr で Go 側へのリクエストが走っていますので、基本的にデベロッパーツールを眺めます。 :eyes:

スクリーンショット 2017-11-03 20.30.31.png

無事に、 以下のエラーを発生させることに成功しました。

No 'Access-Control-Allow-Origin' header is present on the requested resource. 
Origin 'http://localhost:9999' is therefore not allowed access.

Access-Control-Allow-Origin ヘッダーが無いぞ。と言われていますので、まずはそれを追加してみるところからです。

Step4. GET をできるようにする

server.go
func rootHandler(w http.ResponseWriter, r *http.Request) {
    ping := Ping{http.StatusOK, "ok"}
    res, _ := json.Marshal(ping)

    w.Header().Set("Content-Type", "application/json")

+   // ここを追加
+   w.Header().Set("Access-Control-Allow-Origin", "*")

    w.Write(res)
}

これで、再度 http://localhost:9999/ へアクセスすると以下のようになります。

スクリーンショット 2017-11-03 20.38.56.png

無事できました。

Step5. POST もできるようになってる

script.js を以下のようにして、今度は POST で Go へリクエストを送ってみます。

script
const postRequest = () => {
  xhr.open('POST', url)
  xhr.onloadend = handler
  xhr.send()
}

document.addEventListener('DOMContentLoaded', () => {
  postRequest()
})

これはなにもしなくても、Step4. と同じように成功します。

Step.6 POST で、application/json にしてみる。そしてエラーを眺める。

プリフライトリクエスト MDN に書いてあるように、

GET、HEAD、POST 以外のメソッドを使用した場合。また application/x-www-form-urlencoded、multipart/form-data、または text/plain 以外の Content-Type とともに POST を使用してリクエストを行う場合、例えば application/xml または text/xml を使用して POST で XML のペイロードをサーバーへ送るときは、リクエストでプリフライトを行います。

カスタムヘッダをリクエストに設定した場合 (例えば、X-PINGOTHER のようなヘッダを用いるリクエスト)。

POSTでも、たとえば、 Content-Type: application/json としてリクエストすると、プリフライトリクエストが走ります。

ところで、

プリフライトリクエストってなんだよ!!

簡単にいいますと...

POST メソッドのリクエストの前に、OPTIONS メソッドが走ります...。(説明不足

とにかく!Content-Typeapplication/json に変更して動かしてみます。 :runner_tone5:

script.js
const postJsonRequest = () => {
  xhr.open('POST', url)
  xhr.onloadend = handler
  xhr.setRequestHeader('Content-Type', 'application/json')
  xhr.send()
}

document.addEventListener('DOMContentLoaded', () => {
  postJsonRequest()
})

これで、http://localhost:9999/ へアクセスすると。

スクリーンショット 2017-11-03 20.59.42.png

OPTIONS メソッドが飛んでいます。そしてエラーも。

Request header field Content-Type is not allowed by Access-Control-Allow-Headers in preflight response.

リクエストの Content-Type が許可されてないそうです。

Step.7 プリフライトのエラーを解消する

server.go
func rootHandler(w http.ResponseWriter, r *http.Request) {
    ping := Ping{http.StatusOK, "ok"}
    res, _ := json.Marshal(ping)

    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("Access-Control-Allow-Origin", "*")

+   // ここを追加
+   w.Header().Set("Access-Control-Allow-Headers","Content-Type")

    w.Write(res)
}

これで、無事通ります。

もしも、JS からリクエストするときに、カスタムヘッダーなども追加している場合は、例えば、X-Hogeというカスタムヘッダーだとすると、以下のようにそれも追記しておく必要があります。

w.Header().Set("Access-Control-Allow-Headers","Content-Type, X-Hoge")

Step.8 Cookie を取得しようとして、エラーに遭遇しよう

JS 側で Cookie をセットするようにしてみます。

script.js
const getCookieRequest = () => {
  xhr.open('GET', url)
  xhr.onloadend = handler
  xhr.send()
}

document.addEventListener('DOMContentLoaded', () => {
  document.cookie = 'hoge=123'
  getCookieRequest()
})

Go 側で、Cookie を読んで、それを json に入れて返却するようなコードを書きます。(もちろんまだちゃんと取得できない)

server.go
func rootHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("Access-Control-Allow-Origin", "*")
    w.Header().Set("Access-Control-Allow-Headers","Content-Type")

    // Cookie 取得
    cookie, _ := r.Cookie("hoge")
    var result string
    if cookie != nil {
        result = cookie.Value
    } else {
        result = "Cookie 取得できませんでした"
    }
    ping := Ping{http.StatusOK, result}
    res, _ := json.Marshal(ping)
    w.Write(res)
}

この状態で、http://localhost:9999/ へアクセスすると。

スクリーンショット 2017-11-03 21.23.14.png

エラーにはならないけど、無事、Cookie が取れていない。

Step.9 Cookie をちゃんと取得する

やることは 3 つです。

  • script.js 側で、withCredentials を true にする
  • server.go 側で、Access-Control-Allow-Credentials ヘッダーを設定する
  • server.go 側で、Access-Control-Allow-Originのワイルドカード指定をやめる
script.js
const getCookieRequest = () => {
  xhr.open('GET', url)
+  xhr.withCredentials = true
  xhr.onloadend = handler
  xhr.send()
}
server.go
    w.Header().Set("Content-Type", "application/json")
-   w.Header().Set("Access-Control-Allow-Origin", "*")
+   w.Header().Set("Access-Control-Allow-Origin", "http://localhost:9999")

    w.Header().Set("Access-Control-Allow-Headers","Content-Type")

+   // これを追加すると Cookie が取得できる
+   w.Header().Set("Access-Control-Allow-Credentials", "true")

これで、http://localhost:9999/ へアクセスすると無事とれた。

スクリーンショット 2017-11-03 21.28.26.png

以上です。

コード

あげておきました。

mochizukikotaro/cors-sample

:pear: