Detailed changes
@@ -0,0 +1,34 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+version = "2"
+
+[linters]
+enable = ["bodyclose", "errcheck", "errorlint", "exhaustive", "gocognit", "govet", "ineffassign", "modernize", "nilerr", "nilnil", "noctx", "revive", "staticcheck", "unused"]
+
+[linters.settings.gocognit]
+min-complexity = 15
+
+[[linters.settings.revive.rules]]
+name = "file-length-limit"
+arguments = [{ max = 1000, skip-comments = true, skip-blank-lines = true }]
+
+[[linters.settings.revive.rules]]
+name = "max-control-nesting"
+arguments = [5]
+
+[formatters]
+enable = ["goimports", "gofumpt", "gci", "golines"]
+
+[formatters.settings.goimports]
+local-prefixes = ["git.secluded.site/cooked-mcp"]
+
+[formatters.settings.gofumpt]
+module-path = "git.secluded.site/cooked-mcp"
+
+[formatters.settings.gci]
+sections = ["standard", "default", "localmodule"]
+
+[formatters.settings.golines]
+max-len = 120
@@ -63,12 +63,24 @@ func Load(ctx context.Context, path string, resolver *configvalue.Resolver) (Con
return Config{}, err
}
- username, err := requiredConfigValue(ctx, resolver, "COOKED_LOGIN_USERNAME", fileConfig.User.Name, "Cooked username")
+ username, err := requiredConfigValue(
+ ctx,
+ resolver,
+ "COOKED_LOGIN_USERNAME",
+ fileConfig.User.Name,
+ "Cooked username",
+ )
if err != nil {
return Config{}, err
}
- password, err := requiredConfigValue(ctx, resolver, "COOKED_LOGIN_PASSWORD", fileConfig.User.Password, "Cooked password")
+ password, err := requiredConfigValue(
+ ctx,
+ resolver,
+ "COOKED_LOGIN_PASSWORD",
+ fileConfig.User.Password,
+ "Cooked password",
+ )
if err != nil {
return Config{}, err
}
@@ -130,7 +142,11 @@ func loadFile(path string) (FileConfig, error) {
return fileConfig, nil
}
-func requiredConfigValue(ctx context.Context, resolver *configvalue.Resolver, envName, config, description string) (string, error) {
+func requiredConfigValue(
+ ctx context.Context,
+ resolver *configvalue.Resolver,
+ envName, config, description string,
+) (string, error) {
if os.Getenv(envName) != "" || config != "" {
return configvalue.ResolveWithExplicitEnvOverride(ctx, resolver, envName, config, description)
}
@@ -83,12 +83,20 @@ func (r *Resolver) ResolveOrThrow(ctx context.Context, config, description strin
case 1:
return "", fmt.Errorf("failed to resolve %s from environment variable: %s", description, missing[0])
default:
- return "", fmt.Errorf("failed to resolve %s from environment variables: %s", description, strings.Join(missing, ", "))
+ return "", fmt.Errorf(
+ "failed to resolve %s from environment variables: %s",
+ description,
+ strings.Join(missing, ", "),
+ )
}
}
// ResolveWithExplicitEnvOverride resolves an explicit environment override before a config value.
-func ResolveWithExplicitEnvOverride(ctx context.Context, resolver *Resolver, explicitEnvName, configValue, description string) (string, error) {
+func ResolveWithExplicitEnvOverride(
+ ctx context.Context,
+ resolver *Resolver,
+ explicitEnvName, configValue, description string,
+) (string, error) {
if value := os.Getenv(explicitEnvName); value != "" {
return value, nil
}
@@ -151,76 +159,74 @@ func shellCommand(command string) (string, []string) {
func parseTemplate(config string) []templatePart {
var parts []templatePart
- appendLiteral := func(value string) {
- if value == "" {
- return
+ index := 0
+ for index < len(config) {
+ literal, part, hasPart, next := nextTemplatePart(config, index)
+ appendTemplateLiteral(&parts, literal)
+ if hasPart {
+ parts = append(parts, part)
}
+ index = next
+ }
- last := len(parts) - 1
- if last >= 0 && parts[last].kind == partLiteral {
- parts[last].value += value
- return
- }
+ return parts
+}
- parts = append(parts, templatePart{kind: partLiteral, value: value})
+func appendTemplateLiteral(parts *[]templatePart, value string) {
+ if value == "" {
+ return
}
- index := 0
- for index < len(config) {
- relativeDollar := strings.IndexByte(config[index:], '$')
- if relativeDollar < 0 {
- appendLiteral(config[index:])
- break
- }
+ last := len(*parts) - 1
+ if last >= 0 && (*parts)[last].kind == partLiteral {
+ (*parts)[last].value += value
+ return
+ }
- dollar := index + relativeDollar
- appendLiteral(config[index:dollar])
+ *parts = append(*parts, templatePart{kind: partLiteral, value: value})
+}
- if dollar+1 >= len(config) {
- appendLiteral("$")
- index = dollar + 1
- continue
- }
+func nextTemplatePart(config string, index int) (string, templatePart, bool, int) {
+ relativeDollar := strings.IndexByte(config[index:], '$')
+ if relativeDollar < 0 {
+ return config[index:], templatePart{}, false, len(config)
+ }
- next := config[dollar+1]
- if next == '$' || next == '!' {
- appendLiteral(string(next))
- index = dollar + 2
- continue
- }
+ dollar := index + relativeDollar
+ literal := config[index:dollar]
+ if dollar+1 >= len(config) {
+ return literal + "$", templatePart{}, false, dollar + 1
+ }
- if next == '{' {
- end := strings.IndexByte(config[dollar+2:], '}')
- if end < 0 {
- appendLiteral("$")
- index = dollar + 1
- continue
- }
+ next := config[dollar+1]
+ if next == '$' || next == '!' {
+ return literal + string(next), templatePart{}, false, dollar + 2
+ }
+ if next == '{' {
+ return nextBracedTemplatePart(config, dollar, literal)
+ }
- endIndex := dollar + 2 + end
- name := config[dollar+2 : endIndex]
- if envNameRE.MatchString(name) {
- parts = append(parts, templatePart{kind: partEnv, value: name})
- } else {
- appendLiteral(config[dollar : endIndex+1])
- }
+ name := envNamePrefixRE.FindString(config[dollar+1:])
+ if name == "" {
+ return literal + "$", templatePart{}, false, dollar + 1
+ }
- index = endIndex + 1
- continue
- }
+ return literal, templatePart{kind: partEnv, value: name}, true, dollar + 1 + len(name)
+}
- name := envNamePrefixRE.FindString(config[dollar+1:])
- if name != "" {
- parts = append(parts, templatePart{kind: partEnv, value: name})
- index = dollar + 1 + len(name)
- continue
- }
+func nextBracedTemplatePart(config string, dollar int, literal string) (string, templatePart, bool, int) {
+ end := strings.IndexByte(config[dollar+2:], '}')
+ if end < 0 {
+ return literal + "$", templatePart{}, false, dollar + 1
+ }
- appendLiteral("$")
- index = dollar + 1
+ endIndex := dollar + 2 + end
+ name := config[dollar+2 : endIndex]
+ if !envNameRE.MatchString(name) {
+ return literal + config[dollar:endIndex+1], templatePart{}, false, endIndex + 1
}
- return parts
+ return literal, templatePart{kind: partEnv, value: name}, true, endIndex + 1
}
func resolveTemplate(parts []templatePart) (string, bool) {
@@ -101,7 +101,13 @@ func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
decodeShoppingList := func(decoder *json.Decoder) error {
return decoder.Decode(&response)
}
- if err := c.doAuthenticated(ctx, http.MethodGet, "/api/user/{username}/shopping-list", nil, decodeShoppingList); err != nil {
+ if err := c.doAuthenticated(
+ ctx,
+ http.MethodGet,
+ "/api/user/{username}/shopping-list",
+ nil,
+ decodeShoppingList,
+ ); err != nil {
return ShoppingList{}, err
}
@@ -167,7 +173,12 @@ func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, err
return response.Recipes, nil
}
-func (c *Client) doAuthenticated(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
+func (c *Client) doAuthenticated(
+ ctx context.Context,
+ method, path string,
+ body []byte,
+ decodeResponse responseDecoder,
+) error {
c.requestMu.Lock()
defer c.requestMu.Unlock()
@@ -15,61 +15,8 @@ import (
func TestReadShoppingListACIDAuthenticationLogin3StoresCookies(t *testing.T) {
var sawSessionCookie bool
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/public/login":
- if r.Method != http.MethodPost {
- t.Fatalf("login method = %s, want POST", r.Method)
- }
-
- var request loginRequest
- if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
- t.Fatalf("decode login request: %v", err)
- }
- if request.Username != "configured-user" || request.Password != "configured-password" {
- t.Fatalf("login request = %#v, want configured credentials", request)
- }
-
- http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
- writeJSON(t, w, loginResponse{Username: "returned-user"})
- case "/api/user/returned-user/shopping-list":
- cookie, err := r.Cookie("cooked_session")
- if err != nil {
- t.Fatalf("missing session cookie: %v", err)
- }
- if cookie.Value != "session-value" {
- t.Fatalf("session cookie = %q, want session-value", cookie.Value)
- }
- sawSessionCookie = true
- writeJSON(t, w, shoppingListResponse{
- ShoppingList: ShoppingList{
- Aisles: []Aisle{{
- ID: "pantry",
- Name: "Pantry",
- ProductGroups: []ProductGroup{{
- ID: "pasta",
- Name: "Pasta",
- Quantity: "200g",
- }},
- }},
- },
- Recipes: []string{"recipe-id"},
- })
- default:
- t.Fatalf("unexpected path %s", r.URL.Path)
- }
- }))
- defer server.Close()
-
- baseURL, err := url.Parse(server.URL)
- if err != nil {
- t.Fatalf("parse server URL: %v", err)
- }
- client, err := NewClient(baseURL, "configured-user", "configured-password")
- if err != nil {
- t.Fatalf("NewClient() error = %v", err)
- }
+ client, closeServer := newTestClient(t, shoppingListTestHandler(t, &sawSessionCookie))
+ defer closeServer()
shoppingList, err := client.ReadShoppingList(context.Background())
if err != nil {
@@ -85,39 +32,8 @@ func TestReadShoppingListACIDAuthenticationLogin3StoresCookies(t *testing.T) {
}
func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/public/login":
- http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
- writeJSON(t, w, loginResponse{Username: "returned-user"})
- case "/api/user/returned-user/recipes":
- if got := r.URL.Query().Get("page"); got != "2" {
- t.Fatalf("page query = %q, want 2", got)
- }
- if got := r.URL.Query().Get("page-count"); got != "5" {
- t.Fatalf("page-count query = %q, want 5", got)
- }
- writeJSON(t, w, recipeListResponse{
- Recipes: []RecipeCard{{
- ID: "recipe-1",
- Title: "Pasta",
- ThumbnailURL: "https://example.invalid/thumb.jpg",
- }},
- })
- default:
- t.Fatalf("unexpected path %s", r.URL.Path)
- }
- }))
- defer server.Close()
-
- baseURL, err := url.Parse(server.URL)
- if err != nil {
- t.Fatalf("parse server URL: %v", err)
- }
- client, err := NewClient(baseURL, "configured-user", "configured-password")
- if err != nil {
- t.Fatalf("NewClient() error = %v", err)
- }
+ client, closeServer := newTestClient(t, listRecipesTestHandler(t))
+ defer closeServer()
recipes, err := client.ListRecipes(context.Background(), 2, 5)
if err != nil {
@@ -133,42 +49,8 @@ func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) {
}
func TestSearchRecipesACIDRecipesRead2SearchesSavedRecipes(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/public/login":
- http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
- writeJSON(t, w, loginResponse{Username: "returned-user"})
- case "/api/user/returned-user/recipes/search":
- if r.Method != http.MethodGet {
- t.Fatalf("search method = %s, want GET", r.Method)
- }
- if got := r.URL.Query().Get("q"); got != "pasta & tomato" {
- t.Fatalf("q query = %q, want pasta & tomato", got)
- }
- if got := r.URL.Query().Get("page"); got != "3" {
- t.Fatalf("page query = %q, want 3", got)
- }
- writeJSON(t, w, recipeListResponse{
- Recipes: []RecipeCard{{
- ID: "recipe-1",
- Title: "Pasta",
- ThumbnailURL: "https://example.invalid/thumb.jpg",
- }},
- })
- default:
- t.Fatalf("unexpected path %s", r.URL.Path)
- }
- }))
- defer server.Close()
-
- baseURL, err := url.Parse(server.URL)
- if err != nil {
- t.Fatalf("parse server URL: %v", err)
- }
- client, err := NewClient(baseURL, "configured-user", "configured-password")
- if err != nil {
- t.Fatalf("NewClient() error = %v", err)
- }
+ client, closeServer := newTestClient(t, searchRecipesTestHandler(t))
+ defer closeServer()
recipes, err := client.SearchRecipes(context.Background(), "pasta & tomato", 3)
if err != nil {
@@ -184,46 +66,8 @@ func TestSearchRecipesACIDRecipesRead2SearchesSavedRecipes(t *testing.T) {
}
func TestReadRecipeMetadataACIDRecipesRead6ReadsMetadata(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.EscapedPath() {
- case "/api/public/login":
- http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
- writeJSON(t, w, loginResponse{Username: "returned-user"})
- case "/api/recipe/recipe%2Fwith%20space/metadata":
- if r.Method != http.MethodGet {
- t.Fatalf("metadata method = %s, want GET", r.Method)
- }
- cookie, err := r.Cookie("cooked_session")
- if err != nil {
- t.Fatalf("missing session cookie: %v", err)
- }
- if cookie.Value != "session-value" {
- t.Fatalf("session cookie = %q, want session-value", cookie.Value)
- }
-
- w.Header().Set("Content-Type", "application/json")
- if _, err := w.Write([]byte(`{
- "title": "Pasta with Tomato Sauce",
- "image-urls": ["https://example.invalid/pasta.jpg"],
- "owner": "returned-user",
- "edit-permission": true
- }`)); err != nil {
- t.Fatalf("write metadata response: %v", err)
- }
- default:
- t.Fatalf("unexpected path %s", r.URL.EscapedPath())
- }
- }))
- defer server.Close()
-
- baseURL, err := url.Parse(server.URL)
- if err != nil {
- t.Fatalf("parse server URL: %v", err)
- }
- client, err := NewClient(baseURL, "configured-user", "configured-password")
- if err != nil {
- t.Fatalf("NewClient() error = %v", err)
- }
+ client, closeServer := newTestClient(t, recipeMetadataTestHandler(t))
+ defer closeServer()
metadata, err := client.ReadRecipeMetadata(context.Background(), "recipe/with space")
if err != nil {
@@ -242,41 +86,8 @@ func TestReadRecipeMetadataACIDRecipesRead6ReadsMetadata(t *testing.T) {
}
func TestReadRecipeContentACIDRecipesRead7And8ReadsContentAndPortions(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.EscapedPath() {
- case "/api/public/login":
- http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
- writeJSON(t, w, loginResponse{Username: "returned-user"})
- case "/api/recipe/recipe%2Fwith%20space/content":
- if r.Method != http.MethodGet {
- t.Fatalf("content method = %s, want GET", r.Method)
- }
- cookie, err := r.Cookie("cooked_session")
- if err != nil {
- t.Fatalf("missing session cookie: %v", err)
- }
- if cookie.Value != "session-value" {
- t.Fatalf("session cookie = %q, want session-value", cookie.Value)
- }
-
- writeJSON(t, w, RecipeContent{
- Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
- Portions: 2,
- })
- default:
- t.Fatalf("unexpected path %s", r.URL.EscapedPath())
- }
- }))
- defer server.Close()
-
- baseURL, err := url.Parse(server.URL)
- if err != nil {
- t.Fatalf("parse server URL: %v", err)
- }
- client, err := NewClient(baseURL, "configured-user", "configured-password")
- if err != nil {
- t.Fatalf("NewClient() error = %v", err)
- }
+ client, closeServer := newTestClient(t, recipeContentTestHandler(t))
+ defer closeServer()
content, err := client.ReadRecipeContent(context.Background(), "recipe/with space")
if err != nil {
@@ -307,7 +118,12 @@ func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
}
client := &Client{baseURL: baseURL}
- request, err := client.newRequest(context.Background(), http.MethodGet, "/api/user/user%2Fname/recipes?page=2&page-count=5", nil)
+ request, err := client.newRequest(
+ context.Background(),
+ http.MethodGet,
+ "/api/user/user%2Fname/recipes?page=2&page-count=5",
+ nil,
+ )
if err != nil {
t.Fatalf("newRequest() error = %v", err)
}
@@ -317,7 +133,209 @@ func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
}
}
-func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent](t *testing.T, w http.ResponseWriter, value T) {
+func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
+ t.Helper()
+
+ server := httptest.NewServer(handler)
+ baseURL, err := url.Parse(server.URL)
+ if err != nil {
+ server.Close()
+ t.Fatalf("parse server URL: %v", err)
+ }
+ client, err := NewClient(baseURL, "configured-user", "configured-password")
+ if err != nil {
+ server.Close()
+ t.Fatalf("NewClient() error = %v", err)
+ }
+
+ return client, server.Close
+}
+
+func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
+ t.Helper()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/public/login":
+ requireLoginRequest(t, r)
+ writeLoginResponse(t, w)
+ case "/api/user/returned-user/shopping-list":
+ requireSessionCookie(t, r)
+ *sawSessionCookie = true
+ writeShoppingListResponse(t, w)
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }
+}
+
+func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
+ t.Helper()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/public/login":
+ writeLoginResponse(t, w)
+ case "/api/user/returned-user/recipes":
+ requireQueryValue(t, r, "page", "2")
+ requireQueryValue(t, r, "page-count", "5")
+ writeRecipeListResponse(t, w)
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }
+}
+
+func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
+ t.Helper()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/public/login":
+ writeLoginResponse(t, w)
+ case "/api/user/returned-user/recipes/search":
+ requireMethod(t, r, http.MethodGet, "search")
+ requireQueryValue(t, r, "q", "pasta & tomato")
+ requireQueryValue(t, r, "page", "3")
+ writeRecipeListResponse(t, w)
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }
+}
+
+func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
+ t.Helper()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/api/public/login":
+ writeLoginResponse(t, w)
+ case "/api/recipe/recipe%2Fwith%20space/metadata":
+ requireMethod(t, r, http.MethodGet, "metadata")
+ requireSessionCookie(t, r)
+ writeRecipeMetadataResponse(t, w)
+ default:
+ t.Fatalf("unexpected path %s", r.URL.EscapedPath())
+ }
+ }
+}
+
+func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
+ t.Helper()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/api/public/login":
+ writeLoginResponse(t, w)
+ case "/api/recipe/recipe%2Fwith%20space/content":
+ requireMethod(t, r, http.MethodGet, "content")
+ requireSessionCookie(t, r)
+ writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
+ default:
+ t.Fatalf("unexpected path %s", r.URL.EscapedPath())
+ }
+ }
+}
+
+func requireLoginRequest(t *testing.T, r *http.Request) {
+ t.Helper()
+ requireMethod(t, r, http.MethodPost, "login")
+
+ var request loginRequest
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ t.Fatalf("decode login request: %v", err)
+ }
+ if request.Username != "configured-user" || request.Password != "configured-password" {
+ t.Fatalf("login request = %#v, want configured credentials", request)
+ }
+}
+
+func requireMethod(t *testing.T, r *http.Request, want string, label string) {
+ t.Helper()
+
+ if r.Method != want {
+ t.Fatalf("%s method = %s, want %s", label, r.Method, want)
+ }
+}
+
+func requireSessionCookie(t *testing.T, r *http.Request) {
+ t.Helper()
+
+ cookie, err := r.Cookie("cooked_session")
+ if err != nil {
+ t.Fatalf("missing session cookie: %v", err)
+ }
+ if cookie.Value != "session-value" {
+ t.Fatalf("session cookie = %q, want session-value", cookie.Value)
+ }
+}
+
+func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
+ t.Helper()
+
+ if got := r.URL.Query().Get(name); got != want {
+ t.Fatalf("%s query = %q, want %q", name, got, want)
+ }
+}
+
+func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
+ t.Helper()
+
+ http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
+ writeJSON(t, w, loginResponse{Username: "returned-user"})
+}
+
+func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
+ t.Helper()
+
+ writeJSON(t, w, shoppingListResponse{
+ ShoppingList: ShoppingList{
+ Aisles: []Aisle{{
+ ID: "pantry",
+ Name: "Pantry",
+ ProductGroups: []ProductGroup{{
+ ID: "pasta",
+ Name: "Pasta",
+ Quantity: "200g",
+ }},
+ }},
+ },
+ Recipes: []string{"recipe-id"},
+ })
+}
+
+func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
+ t.Helper()
+
+ writeJSON(t, w, recipeListResponse{
+ Recipes: []RecipeCard{{
+ ID: "recipe-1",
+ Title: "Pasta",
+ ThumbnailURL: "https://example.invalid/thumb.jpg",
+ }},
+ })
+}
+
+func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
+ t.Helper()
+
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write([]byte(`{
+ "title": "Pasta with Tomato Sauce",
+ "image-urls": ["https://example.invalid/pasta.jpg"],
+ "owner": "returned-user",
+ "edit-permission": true
+ }`)); err != nil {
+ t.Fatalf("write metadata response: %v", err)
+ }
+}
+
+func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent](
+ t *testing.T,
+ w http.ResponseWriter,
+ value T,
+) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
@@ -59,72 +59,96 @@ func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (Rea
return output, nil
}
-func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, arguments ReadArguments) (*sdk.CallToolResult, ReadOutput, error) {
+func (s *Server) callReadTool(
+ ctx context.Context,
+ _ *sdk.CallToolRequest,
+ arguments ReadArguments,
+) (*sdk.CallToolResult, ReadOutput, error) {
switch arguments.Target {
case "shopping_list":
- shoppingList, err := s.backend.ReadShoppingList(ctx)
- if err != nil {
- return nil, ReadOutput{}, err
- }
+ return s.callShoppingListReadTool(ctx)
+ case "recipe":
+ return s.callRecipeReadTool(ctx, arguments.RecipeID)
+ case "recipes":
+ return s.callRecipesReadTool(ctx, arguments)
+ default:
+ return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
+ }
+}
- output := ReadOutput{
- Aisles: shoppingList.Aisles,
- ShoppingListRecipes: shoppingList.Recipes,
- }
+func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
+ shoppingList, err := s.backend.ReadShoppingList(ctx)
+ if err != nil {
+ return nil, ReadOutput{}, err
+ }
- return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
- case "recipe":
- recipeID := strings.TrimSpace(arguments.RecipeID)
- if recipeID == "" {
- return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
- }
+ output := ReadOutput{
+ Aisles: shoppingList.Aisles,
+ ShoppingListRecipes: shoppingList.Recipes,
+ }
- metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
- if err != nil {
- return nil, ReadOutput{}, err
- }
- content, err := s.backend.ReadRecipeContent(ctx, recipeID)
- if err != nil {
- return nil, ReadOutput{}, err
- }
+ return &sdk.CallToolResult{
+ Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
+ }, output, nil
+}
- recipe := RecipeDetail{
- ID: recipeID,
- Title: metadata.Title,
- Owner: metadata.Owner,
- EditPermission: metadata.EditPermission,
- Content: content.Content,
- Portions: content.Portions,
- }
- output := ReadOutput{Recipe: &recipe}
+func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
+ recipeID := strings.TrimSpace(rawRecipeID)
+ if recipeID == "" {
+ return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
+ }
- return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
- case "recipes":
- page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
- query := strings.TrimSpace(arguments.Query)
-
- var (
- recipes []cooked.RecipeCard
- err error
- )
- if query == "" {
- recipes, err = s.backend.ListRecipes(ctx, page, limit)
- } else {
- recipes, err = s.backend.SearchRecipes(ctx, query, page)
- if len(recipes) > limit {
- recipes = recipes[:limit]
- }
- }
- if err != nil {
- return nil, ReadOutput{}, err
- }
+ metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
+ if err != nil {
+ return nil, ReadOutput{}, err
+ }
+ content, err := s.backend.ReadRecipeContent(ctx, recipeID)
+ if err != nil {
+ return nil, ReadOutput{}, err
+ }
- output := ReadOutput{Recipes: recipeSummaries(recipes)}
+ recipe := RecipeDetail{
+ ID: recipeID,
+ Title: metadata.Title,
+ Owner: metadata.Owner,
+ EditPermission: metadata.EditPermission,
+ Content: content.Content,
+ Portions: content.Portions,
+ }
+ output := ReadOutput{Recipe: &recipe}
- return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
- default:
- return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
+ return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
+}
+
+func (s *Server) callRecipesReadTool(
+ ctx context.Context,
+ arguments ReadArguments,
+) (*sdk.CallToolResult, ReadOutput, error) {
+ page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
+ recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
+ if err != nil {
+ return nil, ReadOutput{}, err
}
+
+ output := ReadOutput{Recipes: recipeSummaries(recipes)}
+
+ return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
+}
+
+func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
+ if query == "" {
+ return s.backend.ListRecipes(ctx, page, limit)
+ }
+
+ recipes, err := s.backend.SearchRecipes(ctx, query, page)
+ if err != nil {
+ return nil, err
+ }
+ if len(recipes) > limit {
+ recipes = recipes[:limit]
+ }
+
+ return recipes, nil
}
func readTool() *sdk.Tool {
@@ -154,6 +178,7 @@ func normalizeRecipePage(page, limit int) (int, int) {
return page, limit
}
+
func formatShoppingList(shoppingList cooked.ShoppingList) string {
if len(shoppingList.Aisles) == 0 {
return "Shopping list is empty."
@@ -251,11 +276,11 @@ func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
// ReadArguments contains read tool arguments.
type ReadArguments struct {
- Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
+ Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
- Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
- Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
- Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
+ Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
+ Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
+ Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
}
// ReadOutput is the structured output for the current read tool slice.
@@ -43,7 +43,11 @@ func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
}
func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
- backend := &fakeBackend{recipes: []cooked.RecipeCard{{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"}}}
+ backend := &fakeBackend{
+ recipes: []cooked.RecipeCard{
+ {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
+ },
+ }
server := NewServer(backend, "test")
output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5})
@@ -128,17 +132,38 @@ func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
}
server := NewServer(backend, "test")
- result, output, err := server.callReadTool(context.Background(), nil, ReadArguments{Target: "recipe", RecipeID: "recipe-1"})
+ result, output, err := server.callReadTool(
+ context.Background(),
+ nil,
+ ReadArguments{Target: "recipe", RecipeID: "recipe-1"},
+ )
if err != nil {
t.Fatalf("callReadTool() error = %v", err)
}
+ requireSingleRecipeBackendCalls(t, backend)
+ requireSingleRecipeOutput(t, output)
+ requireNoImageLeak(t, result, output)
+}
+
+func requireSingleRecipeBackendCalls(t *testing.T, backend *fakeBackend) {
+ t.Helper()
+
if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
- t.Fatalf("backend recipe IDs = metadata %q content %q, want recipe-1", backend.metadataRecipeID, backend.contentRecipeID)
+ t.Fatalf(
+ "backend recipe IDs = metadata %q content %q, want recipe-1",
+ backend.metadataRecipeID,
+ backend.contentRecipeID,
+ )
}
if backend.metadataCalls != 1 || backend.contentCalls != 1 {
t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
}
+}
+
+func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
+ t.Helper()
+
if output.Recipe == nil {
t.Fatal("structured recipe is nil")
}
@@ -146,17 +171,30 @@ func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
}
if output.Recipe.Owner != "returned-user" || !output.Recipe.EditPermission {
- t.Fatalf("structured recipe owner/edit = %q/%v, want returned-user/true", output.Recipe.Owner, output.Recipe.EditPermission)
+ t.Fatalf(
+ "structured recipe owner/edit = %q/%v, want returned-user/true",
+ output.Recipe.Owner,
+ output.Recipe.EditPermission,
+ )
}
if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
- t.Fatalf("structured recipe content/portions = %q/%d, want markdown/2", output.Recipe.Content, output.Recipe.Portions)
+ t.Fatalf(
+ "structured recipe content/portions = %q/%d, want markdown/2",
+ output.Recipe.Content,
+ output.Recipe.Portions,
+ )
}
+}
+
+func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOutput) {
+ t.Helper()
encodedOutput, err := json.Marshal(output)
if err != nil {
t.Fatalf("marshal output: %v", err)
}
- if strings.Contains(string(encodedOutput), "image") || strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
+ if strings.Contains(string(encodedOutput), "image") ||
+ strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
t.Fatalf("structured output leaked image data: %s", encodedOutput)
}
if len(result.Content) != 1 {