この記事は Fujitsu Advent Calendar 2024 の 11日目 の記事です。
昨日は @earth429 さんの「Cloud Digital Leader受験記」でした。資格勉強は独学で手探りで進めがちなので、こうやって他の方の体験談が聞けるのはありがたいです!
TL; DR
- AWKで手軽にWeb APIサーバーを起動可能
$ docker run -it -p 8080:8080 ghcr.io/syuparn/webawk:0.4.2 'GET("/names") { b["names"][1]="Taro"; res(200, b) }'
$ curl localhost:8080/names
{"names":["Taro"]}
はじめに
Webアプリケーション開発の際、動作確認に必要な依存先のAPIサーバーがまだない、または使えないといった経験はないでしょうか?
例:
- 2つのアプリケーションを並行開発しており、呼び出したい別アプリケーションのAPIが未実装
- ローカル環境でNginx等の設定を検証したいが、ルーティング先のサーバーへ疎通できない
検証のためにモックサーバーを開発するのは少し面倒です。手軽に、欲を言えば1行でAPIサーバーが起動したいです。
ワンライナーといえばAWK(※個人の感想です)。というわけで、本記事では以前作った AWK製のワンライナーAPIサーバー について紹介したいと思います。
ワンライナーAPIサーバー
リポジトリはこちらです。Dockerイメージを利用すれば環境構築不要で使えます。
セキュリティの考慮はされていないためインターネットへの公開は非推奨です。あらかじめご了承ください。
AWKのワンライナーを指定し以下のコンテナを実行するとAPIサーバーが起動します。
# APIサーバーを起動
$ docker run -it -p 8080:8080 ghcr.io/syuparn/webawk:0.4.2 'GET("/names") { b["names"][1]="Taro"; res(200, b) }'
リクエストをしてみましょう。
$ curl localhost:8080/names
{"names":["Taro"]}
想定通りレスポンスが返ってきました。
続いて、APIの定義の書き方を紹介します。
書き方
パターンとアクションの組み合わせでAPIを定義します(一般的なAWKの書き方と同様です)。
# パターン: リクエストが条件にマッチするか確認、マッチしたらアクション実行
GET("/names") {
# アクション: レスポンスを組み立てる
b["names"][1]="Taro";
res(200, b);
}
以下見やすさのためソースコードに改行を入れますが、ワンライナーでも実行可能です。
レスポンスの組み立て
返すレスポンスを res
関数へ渡します。第二引数の連想配列はそのままJSONにシリアライズされます。
res(ステータスコード, 連想配列)
# 連想配列bを作成
# NOTE: gawkの仕様上連想配列は要素代入時に暗黙的に初期化される
# (添え字が1オリジンなので注意!)
b["names"][1]="Taro";
# ステータスコード200("OK")、ボディbでレスポンスを返す
# bはJSON `{"names":["Taro"]}` にシリアライズされる
res(200, b);
リクエストボディ
body
でリクエストボディを取得可能です。引数には欲しい要素をjqのフィルタで指定します1。
# body(".name") でリクエストボディの nameフィールドを取得
POST("/users") { b1["name"]=body(".name"); res(201, b1) }
$ curl -XPOST -d '{"name": "Hanako"}' -H 'Content-Type: application/json' localhost:8080/users
{"name":"Hanako"}
パターンマッチにも使用可能です(以下紹介する他の関数も同様)。
# nameフィールドがある場合: 正常系
POST("/names") && (n=body(".name")) { b1["name"]=n; res(201, b1) }
# それ以外の場合: バリデーションエラー
POST("/names") { e["error"]="name required"; res(400, e) }
$ curl -XPOST -d '{"name": "Hanako"}' -H 'Content-Type: application/json' localhost:8080/names
{"name":"Hanako"}
# nameフィールドが無い場合
$ curl -XPOST -d '{}' -H 'Content-Type: application/json' localhost:8080/names
{"error":"name required"}
パスパラメータ
パターンに指定するパスに :
を付けた部分がパスパラメータになります。ワイルドカードとしてマッチし、マッチした要素をアクション部で使用することも可能です。
GET("/users/:user") { b["name"]=path("user"); res(200, b) }
$ curl localhost:8080/users/Taro
{"name":"Taro"}
ルーティング
パターンでHTTPメソッド、パスを指定します。パターンは複数記載可能で、上から順にマッチされます。
いずれにもマッチしない場合はデフォルトの404レスポンスを返します。
POST("/users") { b1["name"]=body(".name"); res(201, b1) }
GET("/users") { b2["users"][1]["name"]="Taro"; res(200, b2) }
DELETE("/users/:user") { res(204) }
# POST("/users")
# NOTE: Content-Typeヘッダの指定が必要です
$ curl -XPOST -d '{"name": "Hanako"}' -H 'Content-Type: application/json' -i localhost:8080/users
HTTP/1.1 201 Created
Connection: keep-alive
Content-Length: 17
Content-Type: application/json
Date: Fri, 06 Dec 2024 03:55:14 GMT
{"name":"Hanako"}
# GET("/users")
$ curl -i localhost:8080/users
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 18
Content-Type: application/json
Date: Fri, 06 Dec 2024 03:56:24 GMT
{"users":[{"name":"Taro"}]}
# DELETE("/users/:user")
$ curl -XDELETE -i localhost:8080/users/Hanako
HTTP/1.1 204 No Content
Connection: keep-alive
Date: Fri, 06 Dec 2024 03:59:13 GMT
# 未定義のパス
$ curl -i localhost:8080/articles
HTTP/1.1 404 Not Found
Connection: keep-alive
Content-Length: 63
Content-Type: application/json
Date: Fri, 06 Dec 2024 04:00:07 GMT
{"error":"Oops! Any of patterns did not match to the request."}
クエリパラメータ
getquery
で指定した名前のクエリパラメータを取得できます。HTTPでは同名のクエリパラメータを複数指定可能なので、配列形式で返しています。
# getqueryでクエリパラメータを取得
# HACK: awkでは戻り値で配列を返すことができないため、第二引数の空の配列(q1)に破壊的変更を加え値を設定している
GET("/queries") { getquery("q", q1); res(200, q1) }
$ curl 'localhost:8080/queries?q=foo&q2=bar&q=baz'
["foo","baz"]
パターンに使用する際は query
を使用してください。
# クエリパラメータtag=programming指定の場合
GET("/articles") && query("tag", "programming") { b["articles"][1]["name"]="learning awk"; res(200, b) }
# クエリパラメータtagがある場合(値は不問)
GET("/articles") && query("tag") { getquery("tag", q); b2["articles"][1]["name"]="best practices of " q[1]; res(200, b2) }
# 無い場合
GET("/articles") { b3["articles"][1]["name"]="10 places to visit"; res(200, b3) }
# クエリパラメータ "tag" に "programming" を指定
$ curl 'localhost:8080/articles?tag=programming'
{"articles":[{"name":"learning awk"}]}
# クエリパラメータ "tag" 指定
$ curl 'localhost:8080/articles?tag=management'
{"articles":[{"name":"best practices of management"}]}
# クエリパラメータ "tag" なし
$ curl 'localhost:8080/articles'
{"articles":[{"name":"10 places to visit"}]}
リクエスト、レスポンスヘッダ
リクエストヘッダは header
で取得できます。
# Content-Lengthヘッダを取得
POST("/count") { b["length"]=int(header("Content-Length")); res(200, b) }
# NOTE: curlで暗黙的にリクエストヘッダ "Content-Length: 18" が指定される
curl -d '{"hello": "world"}' -H 'Content-Type: application/json' localhost:8080/count
{"length":18}
レスポンスヘッダを返す場合は res
の第3引数に指定します。
GET("/hello") { b["hello"] = "world"; h["X-Request-ID"]="1234"; res(200, b, h) }
# X-Request-ID が指定されている
$ curl -i localhost:8080/hello
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 17
Content-Type: application/json
Date: Fri, 06 Dec 2024 05:35:56 GMT
X-Request-ID: 1234
{"hello":"world"}
仕組み
GNU AWKの双方向パイプでは、入出力に /inet/tcp/${port}/0/0
を指定することでTCP通信を行うことができます。
これを利用し、以下の流れでHTTP通信を行っています。HTTP/1.1を使用しているため、処理の大半は一般的なAWKプログラムと同じ単なる文字列処理です。
- パイプからHTTPリクエストを受け取る
- HTTPリクエストをパースしグローバル変数へ格納
- パターン用関数(
GET
等)でグローバル変数を参照しマッチするか判別 - マッチしたらアクション実行、HTTPレスポンスを作成
- HTTPレスポンスをパイプへ渡す
技術的な詳細については以前投稿した以下の記事をご覧ください。
おわりに
以上、AWK製のワンライナーHTTP APIサーバーの紹介でした。ちょっとモックのAPIサーバーが欲しいときに少しでもお役に立てれば幸いです。
(※繰り返しになりますが、セキュリティの考慮はされていないためインターネットへの公開は非推奨です。ご了承ください。)
明日は @SogoK さんの記事です。お楽しみに!
-
内部的にはJSONのパース部分をjqへ委譲しています。 ↩