検索とエラーの動作を明確にする

Go 言語Beginner
オンラインで実践に進む

はじめに

落とし物ページには、意味のある検索が必要になりました。青いバッグを探す利用者に、青い物すべてやバッグすべてを返すべきではありません。存在しないアイテム、無効なカテゴリー、該当なしの有効な検索にも、それぞれ異なるレスポンスが必要です。

この独立した作業環境は、前のガイド付きラボの一覧・詳細ハンドラーから始まります。そのラボで扱ったHTTP、JSON、httptestの概念、または同等の経験が必要です。フィルターを組み合わせ、明確なエラーを導入し、実際のページを使用します。読解や通常の間違いへの対応を含めて約55分を見込んでください。これは著者による見積もりです。

名前とカテゴリーのフィルターを組み合わせる

このステップでは、両方の検索条件が同じレコードに適用されるようにします。Workspaceから始めてください。作業ディレクトリを戻し、このターミナルの検索パスに準備済みのGoを追加します。exportは現在のシェルだけに作用し、$PATHは既存の項目を保持します。

cd /home/labex/project/lost-found
export PATH=/usr/local/go/bin:$PATH

まず動作する一覧を使い、そのデータを読んでください。

curl -i 'http://127.0.0.1:8080/api/items'
cat items.json

6件すべてのレコードが返るはずです。Lost and Foundに切り替え、blueを入力し、bagsを選んでSearch collectionを選択してください。現在のハンドラーはこれらの入力を無視するため、6件すべてが表示されたままです。Workspaceに戻り、欠けている判定を追加します。

URLのクエリ文字列は?の後に続きます。category=bags&q=blueは、&で区切られた2つの名前付きパラメーターを渡します。APIの一覧検索ではcategoryとqだけを受け付け、それぞれ最大1回とします。このステップではまず有効な検索を実装し、次に無効な入力を処理します。

契約を正確に定めます。カテゴリーが省略されているか空なら、すべてのカテゴリーを対象にします。それ以外は、綴りと大文字・小文字がitems.jsonに存在するカテゴリーと一致しなければなりません。キーワードqは、大文字・小文字を区別せずに名前の部分文字列と照合し、前後の空白を無視します。省略されたキーワード、空のキーワード、空白だけのキーワードはすべての名前に一致します。両方のフィルターが指定されたら、両方に一致する必要があります(AND)。該当する各IDの全フィールドを重複なく保持してください。結果の順序は自由です。有効な検索で該当がなければ、HTTP 200と[]を返します。

r.URL.Query().Getはデコード済みのパラメーターを読みます。strings.TrimSpaceは前後の空白を除き、strings.ToLowerはUnicodeの小文字変換を適用し、strings.Containsは部分文字列を確認します。これは単純な小文字での比較であり、アクセントの除去や言語に応じた検索エンジンではありません。make([]Item, 0)は空でnilではないスライスを作り、該当なしをJSONで[]にエンコードできるようにします。&&は必須の2条件を結合します。ここで||を使うと、片方の条件しか満たさないレコードも誤って含めてしまいます。

編集前に、items.jsonから3つの結果を予測します。キーワードblueだけ、カテゴリーbagsだけ、そして両方を指定した場合です。両者の共通部分にはLF-106だけが属します。2本の傘から、どちらか片方の条件に一致する結果を返してはならない理由が分かります。

引き継いだ詳細のコードは保ちます。最初のラボのnanoの手順を使ってください。Ctrl+Wで探し、Ctrl+OとEnterで既存のファイル名に保存して、Ctrl+Xで終了します。Goが2つ目のハンドラーをコンパイルしないように、バックアップは.go以外の拡張子にします。

cp handler.go handler.go.backup
LC_ALL=C.UTF-8 nano handler.go

/api/itemsのコレクション分岐を探し、そのコレクションの選択処理だけを置き換えます。レスポンスヘッダー、詳細のループ、存在しないIDへのレスポンスはそのままにします。items引数を使って、次の判断を実装してください。

  • r.URL.Query().Getでデコードされたcategoryとqを読みます。カテゴリーは渡されたままにします。ループ前にキーワードへstrings.TrimSpace、次にstrings.ToLowerを適用します。
  • make([]Item, 0)で結果のスライスを始めます。一致しないレコードまで残るので、元の全スライスを結果として再利用しないでください。
  • for _, item := range itemsでレコードをたどります。カテゴリーが省略・空、またはitem.Categoryと等しいかを判断し、別に、小文字の名前に正規化したキーワードが含まれるかを判断します。この2つを&&で結びます。appendで一致したitem全体を追加します。名前だけや、他のレコードから取ったIDを追加してはいけません。
  • コレクション分岐で結果のスライスをエンコードしてreturnします。詳細は引き続きIDで選び、コレクションのフィルターを詳細リクエストへ適用しません。

参考として、カテゴリー条件はcategory == "" || item.Category == categoryと書けます。ここでの||は「制限がない、または要求カテゴリーに一致する」を表します。カテゴリーと名前の条件の間のANDを置き換えるものではありません。それぞれの判断が読みやすくなるローカル変数名を選びます。

保存・終了し、実際に保存したファイルを確認します。

cat handler.go

整形して再ビルドします。-wによりgofmtがファイルを更新します。serve.pyはコードをビルドし、成功した場合だけ、記録された作業環境のサーバーを置き換えます。コンパイルエラー時には以前のバージョンが動いたままです。報告された行を修正し、両方のコマンドを再実行してください。

gofmt -w handler.go
python3 serve.py

複合検索を送ってください。引用符により&がシェル演算子として解釈されず、URL内に保たれます。

curl -i 'http://127.0.0.1:8080/api/items?category=bags&q=blue'

Sports hallにあるLF-106、Blue sports bagだけが返るはずです。傘も返るなら、ORを使ったことになります。有効でも一致しないキーワードには、空の配列を返す必要があります。

curl -i 'http://127.0.0.1:8080/api/items?q=not-in-the-collection'

空白、+、非ASCII文字を含むテキストは、curlにエンコードさせましょう。-Gは渡されたデータをGETのクエリ文字列として送信し、--data-urlencodeはそのパラメーターをパーセントエンコードします。エンコードしないと、クエリデータ内のリテラルのプラス記号が空白としてデコードされる場合があります。

curl -G -i --data-urlencode 'q=  CAFÉ +  ' 'http://127.0.0.1:8080/api/items'

Welcome boothにあるLF-104、Café + Mapが返るはずです。大文字のキーワードと前後の空白は正規化され、リテラルのプラス記号は名前の一部として残ります。パスを入力し間違えたらcd /home/labex/project/lost-foundで戻ってください。サービスが利用できない場合は、catで.work/server.logを読み、エラー修正後に再ビルドします。

無効なリクエストと該当なしの検索を区別する

このステップでは、ステータスとJSONの両方でエラーを確認できるようにし、完成したページを使います。同じディレクトリのWorkspaceから始めてください。

コレクションのクエリに未知のカテゴリー、未知のパラメーター、または重複パラメーターが含まれる場合は400を使います。カテゴリーの綴りは完全一致で、Bagsとbagsは異なります。空のcategoryとqは引き続き有効です。未知のアイテムIDや未知のAPIパスには404を使います。/api/itemsと/api/items/<id>に対するGET以外のメソッドには、Allow: GET付きの405を使います。エラー本文は空でないerror文字列を含むオブジェクトで、文言は自由です。有効な検索で該当がなければ200と[]のままにします。詳細リクエストはIDで選択し、コレクションのクエリフィルターは詳細には適用しません。準備済みのホームページ/は引き続き利用可能にしてください。

url.ParseQuery(r.URL.RawQuery)はクエリを解析し、値とエラーの両方を返すため、不正なクエリエンコードを拒否できます。各キーは値のスライスに対応し、その長さからパラメーターの重複が分かります。この例では、固定のカテゴリー一覧ではなく現在のソースレコードに照らしてカテゴリーを確認するため、別の窓口でもハンドラーを再利用できます。

本文を書く前にヘッダーを設定します。早期のreturnは、エラーの後に通常の成功結果へ処理が進むのを防ぎます。 編集前に、前のステップの有効だが該当がない検索と、下の拒否されるリクエストを比べます。ステータスが異なる理由を予測してください。存在しないアイテムIDは、不正なコレクションクエリとは違う失敗です。前のステップの検索判断は保ち、その周囲にガード条件を加えます。

LC_ALL=C.UTF-8 nano handler.go

Ctrl+Wで、返されるハンドラー関数を探します。url.ParseQueryのためにnet/urlをインポートへ追加します。ファイル全体を置き換えず、次のガードを実装してください。

まずパスを確認します。コレクションパスは厳密に/api/itemsで、詳細は/api/items/から始まります。それ以外のAPIパスは通常のページや一覧へ進まず、404を返します。次に、認識したルートのメソッドを確認します。GET以外ならAllowをGETに設定し、405のJSONエラーを返します。

必要なら、エラーを書くローカル関数を1つ作って再利用します。整数のステータスと文字列のメッセージを受け取り、w.WriteHeader(status)を呼び、map[string]string{"error": message}をエンコードできます。Content-Typeヘッダーは引き続き最初に設定します。メッセージは空でなければよく、文言は採点されません。

その後、クエリフィルターがID検索を変えないように、詳細の選択をコレクションクエリの解析より前に置きます。存在しない詳細は、引き続き404とJSONエラーを返します。

続いてコレクション分岐で、url.ParseQueryを使ってr.URL.RawQueryを解析します。値を使う前に返されたエラーを確認し、不正なエンコードには400を返します。パーセントエスケープには16進数字が2桁必要なので、/api/items?q=%ZZはテストで試せる具体的な不正入力です。各キーと値のスライスをたどり、categoryとq以外のキーや、長さが1でないスライスを拒否します。拒否するたびに直ちにreturnします。

次に、受け入れたパラメーターを解析済みの値から読みます。空でないカテゴリーは、渡されたレコードをたどり、その綴りのカテゴリーが存在するか確認します。存在しなければ400で拒否します。items.jsonの固定カテゴリー一覧をプログラムにコピーしないでください。別の窓口には全く新しいカテゴリーがあるかもしれません。

最後に、それらのガードを通ったら既存のAND検索を実行し、結果の配列をエンコードします。空のカテゴリーとキーワードも有効なままです。該当なしの[]をエラーに変えないでください。

Ctrl+OとEnterで保存し、Ctrl+Xで終了します。エディタが意図しないファイル名を示したらCtrl+Cで確認を取り消し、パスを調べます。コンパイルエラーを直す間も、動作する検索と詳細のコードを保ってください。

gofmt -w handler.go
python3 serve.py

次は意図的に拒否されるリクエストです。順に400、400、404、405になるはずです。-X POSTはPOSTメソッドを選びます。これは読み取り専用サービスなので、何も作成してはいけません。

curl -i 'http://127.0.0.1:8080/api/items?category=unknown'
curl -i 'http://127.0.0.1:8080/api/items?q=blue&q=bag'
curl -i 'http://127.0.0.1:8080/api/not-a-route'
curl -X POST -i 'http://127.0.0.1:8080/api/items'

各レスポンスにはJSONのerrorがあり、POSTにはさらにAllow: GETが含まれます。curlがこれらのレスポンスを受信することは、HTTPステータスもJSONレスポンスも届かない接続失敗とは異なります。想定したHTTPエラーを理由に作業環境をリセットしないでください。

テストケースのテーブルを使い、関連する期待値をまとめます。前のラボのレコーダーとリクエストの手法を繰り返します。url.Values.Encode()はUnicodeとプラス記号を含むクエリをGoでエンコードします。一覧レスポンスは[]Itemへデコードし、内容を見る前にjson.Unmarshalが返すエラーを確認します。空の結果では、長さゼロでnilではないスライスは[]を意味し、nilのスライスならnullを意味します。すでに実行した実サービスの検索では、実際のアイテムの内容も確認しています。

実コレクションとは独立したレコードを使い、自分で表駆動テストを作ります。同じ名前を2つのカテゴリーに置き、3件目は一致しないアイテムにした小さなfixtureを選びます。まず複合検索の正確なID集合を予測します。これにより、ステータスだけの検証では見逃すOR実装を発見できます。

LC_ALL=C.UTF-8 nano handler_test.go

最初のラボと同様に、package mainから始め、使うパッケージをインポートし、*testing.Tを受け取るTest関数にテストを書きます。リクエストとレコーダーにはnet/http/httptest、デコードにはencoding/json、検証にはtestingを使い、クエリをエンコードする場合はnet/urlも使います。テスト内でfixtureからハンドラーを1回作ります。

関連するリクエストは無名構造体のスライスで表します。たとえばフィールドname string、path string、status intで、各ケースのラベル、リクエストパス、期待ステータスを持たせられます。自分で行を用意し、成功する複合検索、該当なしの検索、無効なカテゴリー、重複キー、不正エンコード、存在しないIDを含めます。for _, tc := range casesで各ケースのリクエストを新しいレコーダーに適用します。httptest.NewRequest("GET", tc.path, nil)にtc.pathを使い、recorder.Codeをtc.statusと比較して、失敗出力にケース名を含めます。レコーダーを使い回すと本文が混ざるため、各ケースで新しく作ります。

Goのt.Run(tc.name, func(t *testing.T) { ... })を使うと、任意で各行に名前付きサブテストを付けられます。省略部分をレコーダー、リクエスト、検証の文に置き換えます。失敗時のt.Fatalfには期待ステータスと観測したステータスの両方を出してください。

ステータスだけでは検索を証明できません。成功する複合ケースでは[]Itemへデコードしてエラーを確認し、返されたIDとフィールドを一致するfixtureレコードと比較します。該当なしのケースではvar got []Itemを宣言し、デコードエラーを確認してから、got != nilかつlen(got) == 0を要求します。型変換せずに空配列とnullを区別できます。拒否ケースではmap[string]stringにデコードし、空でないerrorを要求します。認識されるルートへのPOSTリクエストを別に追加し、405とrecorder.Header().Get("Allow") == "GET"の両方を確認します。同じレコーダー手法で、異なる結果を期待しています。

fixture、ケース名、正確な検証は自分で決めます。同名の余分なアイテムが結果に入ると失敗する検証を、少なくとも1つ作ってください。Ctrl+OとEnterで保存し、Ctrl+Xで終了します。

gofmt -w handler_test.go
go test -v .

PASSを期待します。失敗したら、表示されたパスとステータスを上の契約と比較します。ハンドラーを修正してテストを再実行し、python3 serve.pyで再ビルドしてブラウザーにも現在のコードを使わせてください。補助ファイルのmain.go、go.mod、items.json、serve.py、web/は変更しません。ソースコレクションやWebページを置き換えず、handler.goの編集や、自分のGoソースとテストの追加・編集はできます。

Lost and Foundに切り替えて再読み込みします。カテゴリーbagsでblueを検索し、参照番号LF-106のカードが1枚になることを確認します。View itemを開いてSports hallを読み、Back to collectionで閉じます。not-in-the-collectionを検索すると、0 items foundとNo matching itemsが表示されるはずです。キーワードを空にし、All categoriesを選んで再検索すると、6件すべてが戻る必要があります。Tabキーで入力欄とボタンに移動します。Enterでフォームを送信でき、Escapeで開いている詳細ダイアログを閉じられます。

複合検索で一致する青いバッグだけを表示

選択されたカテゴリー、結果が1件であることを示す件数、該当する参照番号を確認してください。ページは検索と詳細表示のたびにAPIを使います。利用不可のメッセージは実際のリクエストの問題を表し、組み込みのコレクションを代わりに表示することはありません。

まとめ

正規化した名前フィルターと完全一致のカテゴリーフィルターを組み合わせ、アイテムの識別情報を保持し、有効な検索で該当がなければ[]を返し、400、404、405のエラーを区別しました。テーブル駆動テストと実際のWebページでハンドラーを動かしました。チャレンジでは、同じ契約を別の窓口のデータに適用します。