Kotlin/NativeでHTTPサーバーを作るアドベントカレンダー(22日目:POSTリクエスト)

この記事はkdnaktの1人 Advent Calendar 2020の22日目の記事です(3日遅れ)。

 

2020年は会社でKotlin dojoを主催して週1回30分Kotlinと戯れていました。12月はその集大成ということで、Kotlin/NativeでHTTPサーバーを作ってみたいと思います。どこまでできるか、お楽しみ……。

 

 

[POSTリクエストを処理する]

POSTリクエストを処理するのはHTTPサーバー(Webサーバー)の役割か、というとちょっと違う気もしますが、あまり気にせず実装してみます。完全なPOSTリクエストを処理しようとすると大変なので、シンプルな内容にとどめます。

 

▼HTMLファイルの用意

POSTリクエストにはリクエストボディに含まれるコンテンツのMIMEタイプを表すために、Content-Typeヘッダーが含まれます。Content-Typeヘッダーには以下の3種類があります。

  • application/x-www-form-urlencoded: 初期値。属性を指定していない場合、この値が使用されます。
  • multipart/form-data: type 属性で "file" を指定した <input>要素のために使用する値です。
  • text/plain: (HTML5)

developer.mozilla.org

 

とりあえず、application/x-www-form-urlencodedでPOSTリクエストを送ることができるように、以下のHTMLファイルをpost.htmlという名前で作成し、publicディレクトリに追加します。

<html>
<form action="/api/simple-post" method="post">
<div>
<label for="name">Who are you?</label>
<input name="name" id="name" />
</div>
<hr />
<div>
<input type="submit" value="Send" />
</div>
</form>
</html>

 

HTTPサーバーを起動し、/post.htmlにアクセスすると以下のような画面が表示されます。

f:id:kidani_a:20201225003117p:plain

 

▼テストを追加しておく

E2Eテストとして、以下の内容でtest/post_simple.htmlファイルを作成します。

フォームに「E2ETest」と入力して送信すると、「Hello, E2ETest san!」というメッセージが返ってくることを期待しています。

#!/bin/bash

msg "post simple form"

RES=$(http --check-status --ignore-stdin --timeout=4.5 --form post $SERVER_PATH/api/simple-post name=E2Etest)
diff -w <(echo "$RES") <(echo "Hello, E2Etest san!")
if [ "$?" != "0" ] ; then
  fail "wrong response"
else
  ok "right response"
fi

 

上記のテストを実行するために、run_test.shを以下のように修正します。これで今後テストを追加した際に、自動的にテストを実行してくれるようになります。

# 修正前
. ./test/get_index_html.sh

# 修正後
for f in ./test/*.sh; do
  . "$f"
done

 

▼実装を修正する

現在の実装では、main.kt内でGETリクエストオブジェクトを作成し、ターゲットのファイルを読み込んでレスポンスとして返しています。

POSTメソッドと処理を分けるために、RequestHandler.ktファイルを以下の内容で作成します。

package com.kdnakt.kttpd.handler

import com.kdnakt.kttpd.HttpVersion
import com.kdnakt.kttpd.RequestContext
import com.kdnakt.kttpd.Response

abstract class RequestHandler {
    fun handle(req: RequestContext): String {
        val (content, res) = handleImpl(req)

        return when (req.httpVersion) {
            HttpVersion.HTTP_0_9 -> content
            HttpVersion.HTTP_1_0,
            HttpVersion.HTTP_1_1 ->
                "${req.httpVersion.version} ${res.status} ${res.reason}\r\n"
                        .plus("Content-Length: ${content.length}\r\n")
                        .plus("\r\n").plus("${content}\r\n")
        }
    }

    protected abstract fun handleImpl(req: RequestContext): Pair<String, Response>
}

 

GETリクエストの処理をしていたコードを、以下のようにGetHandler.ktに分割します。

package com.kdnakt.kttpd.handler

import com.kdnakt.kttpd.*

class GetHandler: RequestHandler() {
    override fun handleImpl(req: RequestContext): Pair<String, Response> {
        var content: String
        var res = OkResponse() as Response
        try {
            content = FileReader("public" + req.requestTarget).content()
        } catch (e: NotFoundException) {
            res = ErrorResponse(e)
            content = "${e.status} ${e.reason}"
        }
        return Pair(content, res)
    }
} 

 

POSTリクエストの処理は、とりあえず受け取ったリクエストボディに含まれる入力内容を取得できればよいので、PostHandler.ktは以下のように雑な実装としておきます。

package com.kdnakt.kttpd.handler

import com.kdnakt.kttpd.OkResponse
import com.kdnakt.kttpd.RequestContext
import com.kdnakt.kttpd.Response

class PostHandler(
        val contentType: String,
        val body: String): RequestHandler() {
    override fun handleImpl(req: RequestContext): Pair<String, Response> {
        val res = "Hello, ${body.split("=")[1]} san!"
        return Pair(res, OkResponse())
    }
} 

 

もともとmain.kt内でGETリクエストを処理していた部分のコードを以下の内容で置き換えます。リクエストボディをソケットから読み込む部分が非常に雑なので実用には耐えませんが、最低限の動作確認は可能です。

val handler = when(request.method) {
    HttpMethod.GET -> GetHandler()
    HttpMethod.POST -> {
        val contentType = request.headers["Content-Type"]
            ?: throw BadRequestException()
        val contentLength = request.headers["Content-Length"]
            ?.toInt()?: throw BadRequestException()
        val body = requestString.let {
            val bodyStart = it.indexOf("\r\n\r\n") + 4
            it.substring(bodyStart, bodyStart + contentLength)
        }
        println("[DEBUG $connectionIdString] body $body")
        PostHandler(contentType, body)
    }
}

write(handler.handle(request))

 

▼動作確認

事前に実装しておいたテストを実行すると、無事成功しました。

f:id:kidani_a:20201225011507p:plain

 

ブラウザでも実際に動かしてみます。

名前を入力してSendボタンを押します。

f:id:kidani_a:20201224095023p:plain

レスポンスが帰ってきました!🎉

f:id:kidani_a:20201224095108p:plain

 

[まとめ]

  • 最低限のPOSTリクエストを処理する実装を追加した
  • 実装中のコードは以下のリポジトリにまとめてある

github.com