diff --git a/.golangci.toml b/.golangci.toml new file mode 100644 index 0000000000000000000000000000000000000000..a92da0a21210e5b128768dbc0e60524d795a6a5d --- /dev/null +++ b/.golangci.toml @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Amolith +# +# 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 diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index ceb7e7377bf275f3a44d606bdb45ba8cddf728c0..9b475e73db23c270c34c9c29725f5edf3387e2c3 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -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) } diff --git a/internal/configvalue/configvalue.go b/internal/configvalue/configvalue.go index 284203ec66735654030c0a1d647a28627e370c9e..4b0c2b4edc0f016488518de6d9fb6e84db5edd15 100644 --- a/internal/configvalue/configvalue.go +++ b/internal/configvalue/configvalue.go @@ -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) { diff --git a/internal/cooked/client.go b/internal/cooked/client.go index b06e697ded7d517c4b1310e4fa899c39498aacc6..c09907ecb6f90cd877c0cf043979a4bbaf55981b 100644 --- a/internal/cooked/client.go +++ b/internal/cooked/client.go @@ -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() diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index f1e1925271b4b0e9c6025cc81e64c4a572e072dc..d6544d55ff358d6e085622f6bca7cd50d09914e5 100644 --- a/internal/cooked/client_test.go +++ b/internal/cooked/client_test.go @@ -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") diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 3c0d14c52ee37ab302fbaf722b805f7b74be17b0..824c81f8e7496d9ec607a395bc70111ca47e13b2 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -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. diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index b4541fe03f2633be8c728a2c38287d960d6fe7c2..51268a79fc2342a4d9a56016a1e574614b3b26a8 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -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 {