フューチャー技術ブログ

curlを讃えよ


Web開発者を支える重要なツールにcurlがあります。素晴らしいツールなのですが、ウェブAPIのリクエストがJSONという時代にあって、JSON書くのが面倒とかいろいろあるのですが、そのためだけに他のツールを使うのではなく、もうちょっと世の中がcurlでテストしやすいようになったらいいのでは、と思っていました。

ということで書いてみました。

curlに合わせるためのミドルウェア

というわけで実装しました。環境変数がなければ何もしません。

package glorytocurl

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"

"github.com/goccy/go-yaml"
)

func GloryToCurl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := os.LookupEnv("GLORY_TO_CURL"); !ok {
next.ServeHTTP(w, r)
return
}
var bodyBuffer bytes.Buffer
tee := io.TeeReader(r.Body, &bodyBuffer)
r.Body = io.NopCloser(tee)
defer r.Body.Close()
dw := httptest.NewRecorder()

next.ServeHTTP(dw, r)

// can't recover in this middleware
if dw.Code < 400 && dw.Code >= 500 {
for k, v := range dw.Header() {
w.Header()[k] = v
}
w.Write(bodyBuffer.Bytes())
w.WriteHeader(dw.Code)
return
}

ct := r.Header.Get("Content-Type")
var jsonBody []byte
// recover curl's -d option to json
switch ct {
case "":
r.Header.Set("Content-Type", "application/json")
fallthrough
case "application/json":
body := make(map[string]any)
// can't recover because the body is broken
if err := yaml.Unmarshal(bodyBuffer.Bytes(), &body); err != nil {
for k, v := range dw.Header() {
w.Header()[k] = v
}
w.Write(bodyBuffer.Bytes())
w.WriteHeader(dw.Code)
}
jsonBody, _ = json.Marshal(body)
case "application/x-www-form-urlencoded":
r.ParseForm()
body := make(map[string]any, len(r.Form))
for k, v := range r.Form {
if len(v) == 1 {
if i, err := strconv.ParseInt(v[0], 10, 64); err == nil {
body[k] = i
} else {
body[k] = v[0]
}
} else {
body[k] = v
}
}
jsonBody, _ = json.Marshal(body)
}
r.Body = io.NopCloser(bytes.NewReader(jsonBody))
r.ContentLength = int64(len(jsonBody))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Content-Length", strconv.Itoa(len(jsonBody)))
r.Form = nil
r.PostForm = nil
r.MultipartForm = nil
next.ServeHTTP(w, r)
})
}

やっていることは以下の通りです。

  • 最初のリクエストでダメだった場合に、リクエストを作り直して再度リクエストを送る
  • 最初のリクエストでBodyが消費されると2度目は読み出せなくなってしまうし、ステータスコードが書かれるとそこで処理が完了してしまうので、ResponseWriterはユニットテスト用のRecorderを作ってハンドラに渡すし、RequestBodyTeeReaderを使ってコピーを残すようにした
  • 400系エラーになったら、ちょっと頑張って再処理する。 -d形式のオプションはJSONにする。JSONの文法間違いはyamlパーサー後からを借りてちょっと修正する

リトライ用にいろいろ準備するところは本来のnet/httpのユースケースから大きく外れるところなので記述量が多少増えてしまっていますが仕方ないですね。

YAMLのフロースタイルはほぼJSONなんですが、ダブルクオートがなくても文字列っぽかったら文字列にしてパースします。そういう曖昧なところがちょっと嫌われているところであったりするのですが、JavaScriptで書く時はキーにダブルクオートはつけなくてもうまくいくし、テスト的に動かす場合は気軽に書きたいですよね?

サンプル

サンプルコードです。

package main

import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/shibukawa/glorytocurl"
)

type RequestBody struct {
Name string `json:"name"`
Email string `json:"email"`
}

func handlePost(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "Content-Type must be application/json", http.StatusBadRequest)
return
}
var reqBody RequestBody
err := json.NewDecoder(r.Body).Decode(&reqBody)
if err != nil {
http.Error(w, "Error decoding JSON", http.StatusBadRequest)
return
}

fmt.Printf("Received: Name=%s, Email=%s\n", reqBody.Name, reqBody.Email)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
response := map[string]string{"status": "success"}
json.NewEncoder(w).Encode(response)
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /post", handlePost)

server := &http.Server{
Addr: ":8080",
Handler: glorytocurl.GloryToCurl(mux),
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
fmt.Println("Server is running on port 8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Could not listen on port 8080: %v\n", err)
}
}()

<-ctx.Done()
stop()
fmt.Println("Shutting down gracefully, press Ctrl+C again to force")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}

fmt.Println("Server exiting")
}

これで、厳密じゃないリクエストも多少はカバーしてくれるようになりました。curlで簡単にテストできます。

# JSONのキーや値のダブルクオートを忘れた
$ curl "Content-Type: application/json" -d "{name: shibukawa, email: shibu@example.com}" http://localhost:8080/post

# JSONじゃなくてForm形式で送ってみた
curl -v -d name=Shibukawa -d email=shibu@example.com http://localhost:8080/post

まとめ

ツイートした勢いでネタで書いたコードでした。

以前はRubyのmethod_missingを使って、間違ったメソッド名があった時に編集距離が近いメソッドを無理やり呼ぶような、より開発者にフレンドリーなモードを作ってみたことがあるのですが、容易にフレンドリーファイヤーが起きてしまってよくなかった、ということがありました。まあシステム側が開発者に合わせ過ぎようとするのもよくないのですが、多少フォーマットが違うとかぐらいは開発時ぐらいあってもバチはあたらないのではないか、と思っています。