コンテンツにスキップ

2. フォームとバリデーション

1章で残ったのは、挨拶を返し、保存のたびにリロードするプロジェクトでした。 クエリパラメータを読むのと、人が入力したものを受け取るのとは別の話です。 人は何も書かずに送信ボタンを押しますし、200行を貼り付けることもあります。

この章でフォームを足します。メモの置き場所はいったんメモリの上です。テーブルに移すのは 3章なので、変更は3ファイル、30分ほど。途中で一度止まって、残り2ファイルが無い状態の 1つ目が何をするのかを見ます。

1章で断った Tailwind を、ここで入れます。

Terminal window
pw add tailwind

ウィザードが開き、書き込む前に「何を作り、何に追記するか」を並べた確認画面が出ます。 pw add にはフラグで飛ばす方法がありません。これから触るのは、すでに動いている プロジェクトのファイルだからです。承認すると、ツールチェーンのピン留め、 assets/app.css、CSS のビルド手順が入ります。

初期化で断った機能が後からそのまま入る、というのはこういうことです。3章はデータベースを、 4章はログインを、同じやり方で足します。

ページの仕事が2つになりました。書かれたものを並べることと、次の1件を書く箱を出すこと。 handlers/home.pw.html を置き換えます。

handlers/home.pw.html
package handlers
type Memo {
id: int
body: string
}
export component Home(memos: Memo[]): html {
<h1 class="text-3xl font-bold">Memos</h1>
<form method="post" action="/memos" class="mt-6 space-y-2">
<textarea name="body" rows="3" required maxlength="200"
class="w-full rounded-lg border border-slate-300 p-3 focus:border-indigo-500 focus:outline-none"></textarea>
<button type="submit"
class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-500">Add</button>
</form>
<ul class="mt-8 space-y-2">
{for memo in memos}
<li class="rounded-lg border border-slate-200 p-3">{memo.body}</li>
{/for}
</ul>
}

type Memo は Go の構造体になる複合型の宣言です。テンプレートが描画する行と ハンドラが組み立てる行が1つの型になるので、2つの型を足並みそろえて保守する必要が ありません。Memo[] はそのスライスです。

requiredmaxlength="200" は、フォーム自身が受け付ける条件の宣言です。 このあと書く createMemoInputcheck 規則と同じ内容を、ブラウザにも言わせています。 空のまま、あるいは200文字を超えて送信ボタンを押しても、リクエストは飛びません。

これはサーバー側の検証を置き換えるものではありません。**フォームは利便性で、 ハンドラが境界です。**両方書くのは重複ではなく、効く相手が違います。5節でその違いを 実際に見ます。

テンプレートを保存するとビルドが止まります。

handlers/home_handler.go:21:37: unknown field Name in struct literal of type HomeParams

1章では、これをわざと出しました。今回はついでに出ています。コンポーネントが受け取るのは メモになり、名前を受け取るものは無くなったので、ハンドラはもう存在しない形の関数を 呼んでいます。いま渡せるものを渡してください。つまり、何も渡しません。

// handlers/home_handler.go — 3節で並べるものができるまでの home
func home(w http.ResponseWriter, r *http.Request) {
pw.WriteHTML(w, r, Home(HomeParams{}))
}

homeInputpw.Parse の呼び出しも消してください。クエリ文字列を読むものは もうありません。

ページは機能の半分です。存在しないルートへ POST するフォーム。それでもここで 立ち止まる価値があります。いま書いた3つのうち2つは、もう確かめられるからです。

テンプレート単体。 http://127.0.0.1:18081 のコンソールを開き、storybook ペインで Home を選んでください。アプリケーションを通さずに描画されます。リストは 2件で、どちらも Body と表示されます。引数は型から合成され、文字列のフィールドには その名前が入るからです。ストーリーの下の引数は編集できるので、書き換えて描画し直せば 別の問いを投げられます。ハンドラもルートも1行のデータも無いまま、 「このテンプレートは思った通りのものか」に答えが出ます。

単体で描画されたテンプレート。描画結果と HTML の切り替えタブ、その下に編集できる引数

ページとして。 http://localhost:8080/ をリロードします。フォームがあり、その下の リストは空です。空のスライスはそう描画されます。何か入力して Add を押してください。

404 page not found

壊れてはいません。POST /memos は誰も登録していないルートなので、mux は照合する 先を持たず、そう答えます。登録するのは4節です。

メモはリクエストをまたいで残る必要があります。1章ぶんならミューテックス付きの スライスで足ります。

handlers/memos.go
package handlers
import "sync"
// memos はメモリ上のリスト。3章でテーブルに置き換わる。
var memos = &store{}
type store struct {
mu sync.Mutex
nextID int
items []Memo
}
func (s *store) list() []Memo {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Memo(nil), s.items...)
}
func (s *store) add(body string) {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
s.items = append([]Memo{{Id: s.nextID, Body: body}}, s.items...)
}

ここでの Memo はテンプレートが宣言した型です。表示用の型と保存用の型の変換が どこにもないのは、この規模では型が1つしかないからです。2つ目の型と、それに伴う変換は 3章で出てきます。

handlers/home_handler.go
package handlers
import (
"net/http"
"github.com/shibukawa/popcornweb/pw"
)
func init() {
mux.HandleFunc("GET /{$}", home)
mux.HandleFunc("POST /memos", createMemo) // 追加
}
// homeInput と pw.Parse の呼び出しは2節で消した。このページにクエリ文字列を
// 読むものはもう無い。
// home lists every memo that has been written.
//
// 変更: 2節では空だった HomeParams が、ストアの一覧を運ぶ。
func home(w http.ResponseWriter, r *http.Request) {
pw.WriteHTML(w, r, Home(HomeParams{Memos: memos.list()}))
}
// ここから下は雛形に無い。すべて追加。
// createMemoInput is the submitted form.
type createMemoInput struct {
// Body is the memo text. It is required and capped at 200 characters.
Body string `payload:"body" check:"required,maxlen=200"`
}
// createMemo stores one memo and redirects back to the list.
//
// A rejected submission comes back as the same page with the message beside
// the field, not as a problem document.
func createMemo(w http.ResponseWriter, r *http.Request) {
input, err := pw.Parse[createMemoInput](r)
if err != nil {
pw.WriteProblem(w, r, err)
return
}
memos.add(input.Body)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

GET /{$} は雛形のままです。Go の mux で GET / と書くと、より具体的なパターンが 拾わなかったすべてのパスにマッチしてしまいます。{$} を付けるとルートだけにマッチするので、 URL を打ち間違えたらメモ一覧ではなく 404 が返ります。

payload:"body" はクエリ文字列ではなくリクエストボディを読みます。フォームは application/x-www-form-urlencoded で送りますが、同じ宣言のまま API クライアントからの JSON ボディも受け取れます。分岐は増えません。check の規則は生成時にコンパイルされるので、 requiredmaxlen=200 もリクエスト時のリフレクションを必要としません。 規則の一覧はハンドラにあります。

リダイレクトは 303 See Other で、見た目より重要です。POST にページを返すと、 ブラウザは再送信できるリクエストを抱えたままになります。リロードすればメモは2件になります。 GET / へのリダイレクトを返せば、リロードは一覧を読み直すだけです。

保存してください。pw dev が再生成、リビルド、再起動します。箱に何か書いて Add を 押します。404 は消え、ブラウザはリダイレクトに従って / に戻り、メモは一覧の先頭に 出ます。もう1件足してリロードしても両方残ります。再起動していないプロセスの中に あるからです。適当なファイルを保存して再起動させれば、消えます。3章はまるごとその話です。

テキストエリアと Add ボタン、その下に追加した2件のメモが並ぶメモフォーム

箱を空にしたまま Add を押してください。**何も起きません。**ブラウザが送信を止め、 箱の下に「このフィールドを入力してください」と出します。required を書いたからです。 200文字を超えて貼り付ければ、ブラウザは201文字目を受け付けません。

これで終わり、ではありません。フォームが止めているのはフォームからの送信だけです。 ブラウザを介さずに同じルートを叩いてみてください。

Terminal window
curl -i -X POST http://localhost:8080/memos \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'body='
{
"code": "validation_failed",
"detail": "Validation failed",
"errors": [{ "field": "body", "location": "payload", "message": "required" }],
"status": 400,
"title": "Validation failed",
"type": "about:blank"
}

check:"required" が効いています。RFC 9457 の Problem Details、ステータス 400、 問題のあるフィールドの名前。フォームの required を消してもこの応答は変わりません。 どちらか一方でよかったのではなく、止めている相手が違います。フォームは人の 打ち間違いを、check はリクエストそのものを見ています。

もう一度、今度はブラウザのふりをして同じことをします。

Terminal window
curl -i -X POST http://localhost:8080/memos \
-H 'Accept: text/html' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'body='
HTTP/1.1 400 Bad Request
Content-Type: text/html; charset=utf-8
Vary: Accept

同じ失敗が、今度は HTML で返ってきます。pw.WriteProblemAccept を見て表現を 選びます。ページを欲しがるクライアントには templates/400.pw.html、それ以外には Problem Details。ハンドラには分岐が1つもないことに注意してください。1つのルートが ブラウザと API クライアントの両方に答えるのに、そのために書くコードはありません。

返ってきたページは templates/400.pw.html で、pw init が置いていったものです。 中身を見ると、ステータス・タイトル・詳細・フィールドの一覧をパラメータで受け取って います。誰がそれを埋めるかは環境が決めます。

  • dev: 問題が持っているものを全部。原因を作ったのは目の前の開発者で、これから直すからです
  • それ以外: ステータスとタイトルと request id だけ。同じページが公開先で出るとき、 何が起きたかは言い、なぜ起きたかは言いません

テンプレートは1つで、切り替わるのは渡される中身の方です。APP_ENV=prod で起動し直して 同じ curl を投げれば、詳細が消えたページが返ります。

  • POST するフォーム、それを受けるルート、リロードに耐えるリダイレクト。
  • 2箇所のバリデーション。フォームが人の打ち間違いを止め、check の規則がリクエストを止めます。
  • 1つの失敗に対する2通りの表現。Accept を見てフレームワークが選ぶので、ハンドラに分岐はありません。

一覧は再起動のたびに消えたままです。3章でテーブルを与えます。