mirror of
https://github.com/karust/openserp.git
synced 2026-08-15 05:04:16 +08:00
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:
@@ -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
40
core/errors.go
Normal 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"}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user