Introduction
The lost property page now needs meaningful searches. A visitor asking for a blue bag should not receive every blue object or every bag. A missing item, an invalid category, and a valid search with no matches also need different responses.
This independent workspace begins with the list and detail handler from the previous guided lab. You need that lab's HTTP, JSON, and httptest concepts or equivalent experience. You will combine filters, introduce explicit errors, and use the real page. Allow about 55 minutes including reading and ordinary mistakes; this is an author estimate.
Combine Name and Category Filters
In this step, make both search conditions apply to the same record. Start in Workspace. Restore the working directory and add the prepared Go installation to this terminal's search path. export affects only the current shell; $PATH keeps its existing entries.
cd /home/labex/project/lost-found
export PATH=/usr/local/go/bin:$PATH
First use the working list and read its data:
curl -i 'http://127.0.0.1:8080/api/items'
cat items.json
Expect all six records. Switch to Lost and Found, enter blue, choose bags, and select Search collection. The current handler ignores those inputs and still shows all six items. Return to Workspace to supply the missing decision.
A URL's query string follows ?. category=bags&q=blue supplies two named parameters separated by &. An API list search will accept only category and q, each at most once. This step first implements valid searches; the next handles invalid input.
The contract is precise: an absent or empty category means all categories. Otherwise its spelling and case must match a category present in items.json. The keyword q matches a substring of the name, case-insensitively; surrounding whitespace is ignored. An absent, empty, or whitespace-only keyword matches every name. When both filters are supplied, both must match (AND). Keep the complete fields for each matching ID, with no duplicates; any result order is allowed. A valid search with no matches returns HTTP 200 and [].
r.URL.Query().Get reads a decoded parameter. strings.TrimSpace removes surrounding whitespace, strings.ToLower applies Unicode lowercase conversion, and strings.Contains tests a substring. This is a simple lowercase comparison, not accent removal or a language-aware search engine. make([]Item, 0) creates an empty non-nil slice so JSON encodes no matches as []. && joins both required conditions; using || here would incorrectly include records satisfying only one.
Before editing, predict three results from items.json: keyword blue alone, category bags alone, and both together. Only LF-106 belongs to their intersection. The two umbrellas show why returning either condition's matches would be wrong.
Keep the inherited detail code. Use the nano workflow from the first lab: Ctrl+W locates text, Ctrl+O then Enter saves the existing filename, and Ctrl+X exits. A backup must have a non-.go extension so Go does not compile a second handler.
cp handler.go handler.go.backup
LC_ALL=C.UTF-8 nano handler.go
Find the /api/items collection branch and replace only its collection-selection logic. Leave its response header, the detail loop and the missing-ID response intact. Implement these decisions using the items argument:
- Read the decoded
categoryandqparameters withr.URL.Query().Get. Keep the category exactly as provided. Applystrings.TrimSpaceand thenstrings.ToLowerto the keyword before the loop. - Begin a result slice with
make([]Item, 0). Do not reuse the original full slice as a result, because that would retain nonmatching records. - Traverse the records with
for _, item := range items. Decide whether the category is absent/empty or equalsitem.Category, and separately whether the lowercased name contains the normalized keyword. Join those two decisions with&&. Append the entire matchingitemusingappend, not just its name or an ID taken from another record. - Encode that result slice in the collection branch and return. Keep details selected by ID; do not apply collection filters to a detail request.
For comparison, a category condition can be expressed as category == "" || item.Category == category: the || here represents either no restriction or the requested category. It does not replace the AND between category and name conditions. Choose local variable names that make those separate decisions readable.
Save, exit, and inspect the file you actually saved:
cat handler.go
Format and rebuild. -w lets gofmt update the file. serve.py builds your code and replaces only the recorded workspace server after a successful build. Compilation errors leave the old running version; correct the reported line and rerun both commands.
gofmt -w handler.go
python3 serve.py
Now send a combined search. The quotes keep & inside the URL rather than treating it as a shell operator:
curl -i 'http://127.0.0.1:8080/api/items?category=bags&q=blue'
Expect only LF-106, Blue sports bag, at Sports hall. Returning the umbrellas too would mean OR was used. A valid but unmatched keyword must produce an empty array:
curl -i 'http://127.0.0.1:8080/api/items?q=not-in-the-collection'
For text containing spaces, +, or non-ASCII characters, let curl encode it. -G sends the supplied data in a GET query string, and --data-urlencode percent-encodes that parameter. Without encoding, a literal plus in query data can be decoded as a space.
curl -G -i --data-urlencode 'q= CAFÉ + ' 'http://127.0.0.1:8080/api/items'
Expect LF-104, Café + Map, at Welcome booth. The uppercase keyword and surrounding spaces are normalized, while the literal plus still belongs to the name. If a path was mistyped, restore cd /home/labex/project/lost-found; if the service is unavailable, read .work/server.log with cat and rebuild after correcting the error.
Distinguish Invalid Requests from Empty Searches
In this step, make errors observable through both status and JSON, then use the completed page. Start in Workspace in the same directory.
Use 400 for a collection query containing an unknown category, an unknown parameter, or a repeated parameter. Category spelling is exact; Bags is different from bags. Empty category and q remain valid. Use 404 for an unknown item ID or unknown API path. Use 405 for non-GET methods on /api/items and /api/items/<id>, with Allow: GET. Error bodies are objects containing a nonempty error string; wording is flexible. A valid no-match search stays 200 with []. Detail requests select by ID; collection query filters do not apply to details. The prepared homepage / stays available.
url.ParseQuery(r.URL.RawQuery) parses the query and returns both values and an error, so malformed query encoding can be rejected. Each key maps to a slice of values: its length reveals repeated parameters. This example checks a category against the current source records rather than a fixed category list, so another desk can reuse the handler.
Set headers before writing the body. Early returns prevent an error from falling through into a normal successful result. Before editing, compare the previous step's valid-but-empty search with the rejected requests below. Predict why their statuses differ. A missing item ID is a different failure from a malformed collection query. Keep the search decisions from the previous step; this edit adds guards around them.
LC_ALL=C.UTF-8 nano handler.go
Use Ctrl+W to find the returned handler function. Add net/url to your imports for url.ParseQuery. Implement these guards without replacing the whole file:
First, check the path first. The collection path is exactly /api/items; a detail begins with /api/items/. Any other API path must return 404, not fall through to the normal page or list. Then check the method on those recognized routes: if it is not GET, set Allow to GET and return a 405 JSON error.
Next, reuse one local error-writing function if helpful. It can accept a status integer and a message string, call w.WriteHeader(status), and encode map[string]string{"error": message}. The content-type header is still set first. Choose nonempty messages; wording is not scored.
Then, leave detail selection before collection-query parsing so query filters do not alter an ID lookup. A missing detail still returns 404 with a JSON error.
After that, in the collection branch, parse r.URL.RawQuery with url.ParseQuery. Check its returned error before using values; malformed encoding must return 400. A percent escape requires two hexadecimal digits, so /api/items?q=%ZZ is a concrete malformed input to try in a test. Traverse each key and its value slice. Reject keys other than category or q, and reject a slice whose length is not one. Return immediately after each rejection.
Next, read the accepted parameters from the parsed values. For a nonempty category, traverse the supplied records to determine whether that exact category exists; reject it with 400 if not. Do not copy a fixed category list from items.json into the program. Another desk may have entirely new categories.
Finally, after these guards succeed, run your existing AND search and encode the result array. Empty category and keyword values stay valid. Keep the no-match result [] instead of changing it to an error.
Save with Ctrl+O and Enter, then exit with Ctrl+X. If the editor reports a filename you did not intend, cancel the prompt with Ctrl+C and check the path. Preserve the already working search and detail code while fixing compilation errors.
gofmt -w handler.go
python3 serve.py
The following are deliberate rejected requests. Expect 400, 400, 404, and 405 respectively. -X POST chooses a POST method; this is a read-only service and must not create anything.
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'
Each response has a JSON error; the POST also includes Allow: GET. curl receiving these responses is different from a connection failure, where no HTTP status or JSON response arrives. Do not turn expected HTTP errors into a reason to reset the workspace.
Use a table of test cases to keep related expectations together. This repeats the previous lab's recorder/request technique. url.Values.Encode() encodes the Unicode/plus query in Go. Decode a list response into []Item and check the error returned by json.Unmarshal before inspecting its contents. For an empty result, a non-nil slice with length zero means []; a nil slice would mean null. The live search you already ran also checks the actual item contents.
Create your own table-driven test, using independent records instead of the live collection. Choose a small fixture with the same name in two categories and a third nonmatching item. First predict the exact ID set for a combined search; this catches an OR implementation that a status-only assertion would miss.
LC_ALL=C.UTF-8 nano handler_test.go
As in the first lab, start with package main, import the packages you use, and put the test in a Test function accepting *testing.T. Use net/http/httptest for requests/recorders, encoding/json for decoding, testing for assertions and net/url if you encode a query. Build a handler from your fixture once inside the test.
Represent related requests with a slice of anonymous structures. For example, fields name string, path string and status int give each case a label, a request path and an expected status. Supply your own rows: include a successful combined search, an empty search, an invalid category, a repeated key, malformed encoding and a missing ID. A for _, tc := range cases loop applies each case's request to a fresh recorder. Use tc.path in httptest.NewRequest("GET", tc.path, nil); compare recorder.Code to tc.status and include the case name in failure output. Reusing a recorder would mix response bodies, so create a new one for every case.
Go's t.Run(tc.name, func(t *testing.T) { ... }) optionally gives each row a named subtest. Replace the dots with your recorder, request and assertion statements. A failed t.Fatalf should report both the expected status and the observed status.
Status alone cannot prove the search. For the successful combined case, decode into []Item, check the decoding error, and compare the returned IDs and fields to the matching fixture records. For the empty case, declare var got []Item, check the decoding error, then require got != nil and len(got) == 0. This distinguishes an empty array from null without converting between types. For rejected cases, decode into map[string]string and require a nonempty error. Add a separate POST request for a recognized route and check both 405 and recorder.Header().Get("Allow") == "GET". These use the same recorder technique with different expected results.
You decide the fixture, case names and exact assertions. Make at least one assertion that would fail if the result contained an extra same-name item. Save with Ctrl+O and Enter, then Ctrl+X.
gofmt -w handler_test.go
go test -v .
Expect PASS. If a test fails, compare the displayed path and status with the contract above. Correct the handler, rerun the tests, and rebuild with python3 serve.py so the browser uses your current code. Keep the supporting files main.go, go.mod, items.json, serve.py, and web/ unchanged. You may edit handler.go and add or edit your own Go source and tests without replacing the source collection or webpage.
Switch to Lost and Found and reload. Search blue in category bags; expect one card, reference LF-106. Open View item and read Sports hall, then close with Back to collection. Search for not-in-the-collection; expect 0 items found and No matching items. Clear the keyword, choose All categories, and search again; all six records must return. Use keyboard Tab to reach fields and buttons; Enter submits the form and Escape closes an open detail dialog.

Look at the selected category, the one-result count, and the matching reference. The page uses your API for each search and detail. Its unavailable message represents an actual request problem; it never substitutes a built-in collection.
Summary
You combined normalized name and exact category filters, preserved item identities, returned [] for valid empty searches, and separated 400, 404, and 405 errors. Your table-driven test and real webpage exercise the handler. The challenge transfers this same contract to a different desk's data.



