-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1393 from future-architect/feature
curl
- Loading branch information
Showing
4 changed files
with
220 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
--- | ||
title: "curlを讃えよ" | ||
date: 2024/09/24 00:00:00 | ||
postid: a | ||
tag: | ||
- curl | ||
- Go | ||
category: | ||
- Programming | ||
thumbnail: /images/20240924a/thumbnail.png | ||
author: 澁川喜規 | ||
lede: "Web開発者を支える重要なツールにcurlがあります。素晴らしいツールなのですが、ウェブAPIのリクエストがJSONという時代にあって、JSON書くのが面倒とかいろいろあるのですが、そのためだけに他のツールを使うのではなく、もうちょっと世の中がcurlでテストしやすいようになったらいいのでは、と思っていました。" | ||
--- | ||
|
||
<img src="/images/20240924a/images.png" alt="" width="410" height="123"> | ||
|
||
--- | ||
|
||
Web開発者を支える重要なツールにcurlがあります。素晴らしいツールなのですが、ウェブAPIのリクエストがJSONという時代にあって、JSON書くのが面倒とかいろいろあるのですが、そのためだけに他のツールを使うのではなく、もうちょっと世の中がcurlでテストしやすいようになったらいいのでは、と思っていました。 | ||
|
||
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">curlはJSONがめんどいというか、世の中のWebサービスのcurlへの敬意が足りない <a href="https://t.co/tIWDToedIg">https://t.co/tIWDToedIg</a></p>— 渋川よしき (@shibu_jp) <a href="https://twitter.com/shibu_jp/status/1837669917916561886?ref_src=twsrc%5Etfw">September 22, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> | ||
|
||
ということで書いてみました。 | ||
|
||
# curlに合わせるためのミドルウェア | ||
|
||
というわけで実装しました。環境変数がなければ何もしません。 | ||
|
||
```go | ||
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`を作ってハンドラに渡すし、`Request`の`Body`は`TeeReader`を使ってコピーを残すようにした | ||
* 400系エラーになったら、ちょっと頑張って再処理する。 `-d`形式のオプションはJSONにする。JSONの文法間違いはyamlパーサー後からを借りてちょっと修正する | ||
|
||
リトライ用にいろいろ準備するところは本来のnet/httpのユースケースから大きく外れるところなので記述量が多少増えてしまっていますが仕方ないですね。 | ||
|
||
YAMLのフロースタイルはほぼJSONなんですが、ダブルクオートがなくても文字列っぽかったら文字列にしてパースします。そういう曖昧なところがちょっと嫌われているところであったりするのですが、JavaScriptで書く時はキーにダブルクオートはつけなくてもうまくいくし、テスト的に動かす場合は気軽に書きたいですよね? | ||
|
||
# サンプル | ||
|
||
サンプルコードです。 | ||
|
||
```go | ||
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で簡単にテストできます。 | ||
|
||
```sh | ||
# JSONのキーや値のダブルクオートを忘れた | ||
$ curl "Content-Type: application/json" -d "{name: shibukawa, email: [email protected]}" http://localhost:8080/post | ||
|
||
# JSONじゃなくてForm形式で送ってみた | ||
curl -v -d name=Shibukawa -d [email protected] http://localhost:8080/post | ||
``` | ||
|
||
# まとめ | ||
|
||
ツイートした勢いでネタで書いたコードでした。 | ||
|
||
以前はRubyのmethod_missingを使って、間違ったメソッド名があった時に編集距離が近いメソッドを無理やり呼ぶような、より開発者にフレンドリーなモードを作ってみたことがあるのですが、容易にフレンドリーファイヤーが起きてしまってよくなかった、ということがありました。まあシステム側が開発者に合わせ過ぎようとするのもよくないのですが、多少フォーマットが違うとかぐらいは開発時ぐらいあってもバチはあたらないのではないか、と思っています。 | ||
|
||
|
Empty file.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.