From 6992ad585dfa3727e1f8fdc08f048afd548f8c90 Mon Sep 17 00:00:00 2001 From: Rustem Kamalov Date: Fri, 24 Apr 2026 05:14:18 +0300 Subject: [PATCH] feat: typed 400 validation errors with stable reason codes Invalid limit/start/boolean params now return 400 bad_request with a machine-readable reason field (INVALID_LIMIT, INVALID_START, INVALID_PARAM, EMPTY_QUERY) instead of 500. Limit is validated in range [1, 100]. --- core/common.go | 30 +++++++++++++++++++----------- core/errors.go | 40 ++++++++++++++++++++++++++++++++++++++++ core/middleware.go | 10 ++++++++++ core/server.go | 4 ++-- core/server_test.go | 39 ++++++++++++++++++++++++++++++--------- 5 files changed, 101 insertions(+), 22 deletions(-) create mode 100644 core/errors.go diff --git a/core/common.go b/core/common.go index 20b6a0c..67c6a79 100644 --- a/core/common.go +++ b/core/common.go @@ -192,9 +192,12 @@ func (q Query) IsEmpty() bool { return false } +// MaxQueryLimit is the maximum allowed value for the limit parameter. +const MaxQueryLimit = 100 + // InitFromContext populates Query from HTTP query parameters and request -// headers. It validates numeric/boolean inputs and returns an error for empty -// search expressions. +// headers. It validates numeric/boolean inputs and returns an *APIError for +// invalid client input (400) or a plain error for internal failures. func (searchQuery *Query) InitFromContext(reqCtx *fiber.Ctx) error { searchQuery.Text = reqCtx.Query("text") searchQuery.LangCode = reqCtx.Query("lang") @@ -202,38 +205,43 @@ func (searchQuery *Query) InitFromContext(reqCtx *fiber.Ctx) error { searchQuery.Filetype = reqCtx.Query("file") searchQuery.Site = reqCtx.Query("site") - limit, err := strconv.Atoi(reqCtx.Query("limit", "25")) + limitRaw := reqCtx.Query("limit", "25") + limit, err := strconv.Atoi(limitRaw) if err != nil { - return err + return errInvalidLimit("limit must be an integer") + } + if limit < 1 || limit > MaxQueryLimit { + return errInvalidLimit(fmt.Sprintf("limit must be between 1 and %d", MaxQueryLimit)) } searchQuery.Limit = limit - start, err := strconv.Atoi(reqCtx.Query("start", "0")) + startRaw := reqCtx.Query("start", "0") + start, err := strconv.Atoi(startRaw) if err != nil { - return err + return errInvalidStart("start must be a non-negative integer") } if start < 0 { - return errors.New("start must be >= 0") + return errInvalidStart("start must be >= 0") } searchQuery.Start = start searchQuery.Filter, err = strconv.ParseBool(reqCtx.Query("filter", "1")) if err != nil { - return err + return errInvalidParam(fmt.Sprintf("filter: %v", err)) } searchQuery.Answers, err = strconv.ParseBool(reqCtx.Query("answers", "0")) if err != nil { - return err + return errInvalidParam(fmt.Sprintf("answers: %v", err)) } searchQuery.ProxyOverride, err = NormalizeProxyRequestOverride(reqCtx.Get("X-Use-Proxy")) if err != nil { - return err + return errInvalidParam(fmt.Sprintf("X-Use-Proxy: %v", err)) } if searchQuery.IsEmpty() { - return errors.New("Query cannot be empty") + return errEmptyQuery() } return nil } diff --git a/core/errors.go b/core/errors.go new file mode 100644 index 0000000..a958bc4 --- /dev/null +++ b/core/errors.go @@ -0,0 +1,40 @@ +package core + +import "fmt" + +// APIError represents a client-facing error with a stable machine-readable reason code. +type APIError struct { + HTTPStatus int + Reason string + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("%s: %s", e.Reason, e.Message) +} + +// Common validation reason codes. +const ( + ReasonInvalidLimit = "INVALID_LIMIT" + ReasonInvalidStart = "INVALID_START" + ReasonInvalidParam = "INVALID_PARAM" + ReasonEmptyQuery = "EMPTY_QUERY" + ReasonNoEngines = "NO_ENGINES" + ReasonUnknownFormat = "UNKNOWN_FORMAT" +) + +func errInvalidLimit(msg string) *APIError { + return &APIError{HTTPStatus: 400, Reason: ReasonInvalidLimit, Message: msg} +} + +func errInvalidStart(msg string) *APIError { + return &APIError{HTTPStatus: 400, Reason: ReasonInvalidStart, Message: msg} +} + +func errInvalidParam(msg string) *APIError { + return &APIError{HTTPStatus: 400, Reason: ReasonInvalidParam, Message: msg} +} + +func errEmptyQuery() *APIError { + return &APIError{HTTPStatus: 400, Reason: ReasonEmptyQuery, Message: "query cannot be empty: provide text, site, or file parameter"} +} diff --git a/core/middleware.go b/core/middleware.go index 37c8d04..36cf105 100644 --- a/core/middleware.go +++ b/core/middleware.go @@ -14,6 +14,7 @@ type JSONErrorResponse struct { Error string `json:"error"` Code int `json:"code"` Message string `json:"message,omitempty"` + Reason string `json:"reason,omitempty"` } type CORSConfig struct { @@ -100,6 +101,8 @@ func RequestLoggerMiddleware() fiber.Handler { if err != nil { if e, ok := err.(*fiber.Error); ok { status = e.Code + } else if apiErr, ok := err.(*APIError); ok { + status = apiErr.HTTPStatus } else { status = fiber.StatusInternalServerError } @@ -132,14 +135,21 @@ func RequestLoggerMiddleware() fiber.Handler { func JSONErrorMiddleware() fiber.ErrorHandler { return func(c *fiber.Ctx, err error) error { code := fiber.StatusInternalServerError + reason := "" + if e, ok := err.(*fiber.Error); ok { code = e.Code } + if apiErr, ok := err.(*APIError); ok { + code = apiErr.HTTPStatus + reason = apiErr.Reason + } resp := JSONErrorResponse{ Error: statusText(code), Code: code, Message: err.Error(), + Reason: reason, } c.Set("Content-Type", "application/json") diff --git a/core/server.go b/core/server.go index e8767f8..aa7601c 100644 --- a/core/server.go +++ b/core/server.go @@ -183,7 +183,7 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm q := Query{} if err := q.InitFromContext(c); err != nil { - WithRequest(c.UserContext()).WithError(err).Error("Invalid query parameters") + WithRequest(c.UserContext()).WithError(err).Warn("Invalid query parameters") return err } @@ -579,7 +579,7 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex q := Query{} if err := q.InitFromContext(c); err != nil { - WithRequest(c.UserContext()).WithError(err).Error("Invalid query parameters") + WithRequest(c.UserContext()).WithError(err).Warn("Invalid query parameters") return err } requestCtx = WithQueryHash(c.UserContext(), QueryHashFromQuery(q)) diff --git a/core/server_test.go b/core/server_test.go index 847d7cb..086e199 100644 --- a/core/server_test.go +++ b/core/server_test.go @@ -230,57 +230,78 @@ func TestInvalidQueryParametersReturnJSONError(t *testing.T) { name string path string message string + reason string }{ { name: "invalid limit", path: "/google/search?text=golang&limit=abc", - message: "invalid syntax", + message: "limit must be an integer", + reason: ReasonInvalidLimit, }, { name: "negative start", path: "/google/search?text=golang&start=-1", message: "start must be >= 0", + reason: ReasonInvalidStart, }, { name: "invalid filter flag", path: "/google/search?text=golang&filter=notabool", message: "invalid syntax", + reason: ReasonInvalidParam, }, { name: "invalid answers flag on mega endpoint", path: "/mega/search?text=golang&answers=notabool", message: "invalid syntax", + reason: ReasonInvalidParam, }, { name: "empty text query", path: "/google/search?text=", - message: "Query cannot be empty", + message: "query cannot be empty", + reason: ReasonEmptyQuery, + }, + { + name: "limit too high", + path: "/google/search?text=golang&limit=999", + message: "limit must be between 1 and 100", + reason: ReasonInvalidLimit, + }, + { + name: "zero limit", + path: "/google/search?text=golang&limit=0", + message: "limit must be between 1 and 100", + reason: ReasonInvalidLimit, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp := request(t, srv, tt.path) - if resp.StatusCode != http.StatusInternalServerError { - t.Fatalf("expected 500 for invalid query params, got %d", resp.StatusCode) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid query params, got %d", resp.StatusCode) } var payload JSONErrorResponse if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { t.Fatalf("decode error response: %v", err) } - if payload.Code != http.StatusInternalServerError { - t.Fatalf("expected code=500, got %d", payload.Code) + if payload.Code != http.StatusBadRequest { + t.Fatalf("expected code=400, got %d", payload.Code) } - if payload.Error != "server_error" { - t.Fatalf("expected error=server_error, got %q", payload.Error) + if payload.Error != "bad_request" { + t.Fatalf("expected error=bad_request, got %q", payload.Error) } if payload.Message == "" { t.Fatal("expected error message to be present") } - if tt.message != "" && !strings.Contains(payload.Message, tt.message) { + if tt.message != "" && !strings.Contains(strings.ToLower(payload.Message), strings.ToLower(tt.message)) { t.Fatalf("expected message to contain %q, got %q", tt.message, payload.Message) } + if tt.reason != "" && payload.Reason != tt.reason { + t.Fatalf("expected reason=%q, got %q", tt.reason, payload.Reason) + } }) } }