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].
This commit is contained in:
Rustem Kamalov
2026-04-24 05:14:18 +03:00
parent 0ccb13c36e
commit 6992ad585d
5 changed files with 101 additions and 22 deletions

View File

@@ -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
}

40
core/errors.go Normal file
View File

@@ -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"}
}

View File

@@ -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")

View File

@@ -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))

View File

@@ -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)
}
})
}
}