mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-02 05:47:31 +08:00
Test: release Go-proxy RESTful contract tests verified passing in Go mode (#17468)
### Summary Aligns Go and Python error codes/messages so both backends honor the same RESTful API contract, removing implementation-specific error leaks (MySQL errors, `ValueError`, `AttributeError`, Gin validator format) in favor of clean business error codes. **Chat list** — invalid `orderby` now returns code 101 (was: raw Python `AttributeError` code 100); invalid `page`/`page_size` values fall back to defaults (was: raw `ValueError`/`ProgrammingError` code 100). **Dataset create/update/delete** — adds UUID validation (101), extra-field rejection (101), duplicate-id detection (101), content-type / JSON-syntax / object-shape checks (101), and "lacks permission" for nonexistent datasets (IDOR). Create auto-deduplicates dataset names. Pagerank updates tolerate a missing ES index. List response includes `parser_config` and `pagerank`. **Session list/update** — adds filtering, sorting, and pagination support. Empty payloads are valid no-ops. Authorization errors map to code 109. **Chunk list** — doc object uses Python key names (`chunk_count`, `dataset_id`, `chunk_method`, run text status). Add validates list element types. **Document update** — adds `chunk_method` alias, pydantic-style Field error messages, metadata index auto-create with refresh, and "These documents do not belong to dataset" messages. List validates `metadata_condition` and reports ownership errors for unmatched name/id filters. **Search completion** — `kb_ids` ownership failure returns code 102 instead of 109. Released 31 contract tests from `GO_ONLY_SKIPS` (all verified passing on both Go and Python backends with real LLM keys).
This commit is contained in:
@@ -31,6 +31,7 @@ import (
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/utility"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -392,13 +393,32 @@ func (h *DocumentHandler) DeleteDocuments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, _, ok := parseJSONRequestObject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IDs *[]string `json:"ids"`
|
||||
DeleteAll bool `json:"delete_all,omitempty"`
|
||||
}
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Mirror pydantic's DeleteDocumentReq duplicate detection.
|
||||
if req.IDs != nil {
|
||||
seen := make(map[string]int, len(*req.IDs))
|
||||
duplicates := make([]string, 0)
|
||||
for _, id := range *req.IDs {
|
||||
seen[id]++
|
||||
if seen[id] == 2 {
|
||||
duplicates = append(duplicates, id)
|
||||
}
|
||||
}
|
||||
if len(duplicates) > 0 {
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, fmt.Sprintf("Field: <ids> - Message: <Duplicate ids: '%s'> - Value: <%s>", strings.Join(duplicates, ", "), string(bodyBytes)))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -505,6 +525,15 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
if orderby := c.Query("orderby"); orderby != "" {
|
||||
switch orderby {
|
||||
case "create_time", "update_time", "name":
|
||||
default:
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, fmt.Sprintf("invalid orderby field: %s", orderby))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
@@ -548,6 +577,30 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mirror Python: an explicit id/name filter that matches nothing is an
|
||||
// ownership error rather than an empty page. Each filter's existence is
|
||||
// checked independently of the other.
|
||||
if docID := c.Query("id"); docID != "" {
|
||||
idOpts := opts
|
||||
idOpts.DocIDs = []string{docID}
|
||||
idOpts.Name = ""
|
||||
_, idTotal, idErr := h.documentService.ListDocumentsByDatasetIDWithOptions(ctx, idOpts, 1, 1)
|
||||
if idErr == nil && idTotal == 0 {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, map[string]interface{}{"total": 0, "docs": []interface{}{}}, fmt.Sprintf("you don't own the document %s", docID))
|
||||
return
|
||||
}
|
||||
}
|
||||
if opts.Name != "" {
|
||||
nameOpts := opts
|
||||
nameOpts.DocIDs = nil
|
||||
nameOpts.DocIDFilterApplied = false
|
||||
_, nameTotal, nameErr := h.documentService.ListDocumentsByDatasetIDWithOptions(ctx, nameOpts, 1, 1)
|
||||
if nameErr == nil && nameTotal == 0 {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, map[string]interface{}{"total": 0, "docs": []interface{}{}}, fmt.Sprintf("you don't own the document %s", opts.Name))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
docs := make([]map[string]interface{}, 0, len(documents))
|
||||
for _, doc := range documents {
|
||||
if opts.CreateTimeFrom > 0 && doc.CreateTime != nil && *doc.CreateTime < opts.CreateTimeFrom {
|
||||
@@ -579,7 +632,7 @@ func parseDocumentListOptions(c *gin.Context, datasetID string) (dao.DocumentLis
|
||||
|
||||
opts.RunStatuses = normalizeRunStatusFilter(queryValues(c, "run", "run_status"))
|
||||
if len(queryValues(c, "run", "run_status")) > 0 && len(opts.RunStatuses) == 0 {
|
||||
return opts, "Invalid filter run status conditions"
|
||||
return opts, fmt.Sprintf("Invalid filter run status conditions: %s", strings.Join(invalidRunStatuses(queryValues(c, "run", "run_status")), ", "))
|
||||
}
|
||||
|
||||
opts.Name = c.Query("name")
|
||||
@@ -615,6 +668,17 @@ func parseDocumentListOptions(c *gin.Context, datasetID string) (dao.DocumentLis
|
||||
}
|
||||
|
||||
func (h *DocumentHandler) applyDocumentMetadataFilter(c *gin.Context, opts dao.DocumentListOptions) (dao.DocumentListOptions, string) {
|
||||
// Mirror Python's metadata_condition query-param validation.
|
||||
if raw := strings.TrimSpace(c.Query("metadata_condition")); raw != "" {
|
||||
var mc interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &mc); err != nil {
|
||||
return opts, fmt.Sprintf("metadata_condition must be valid JSON: %s.", raw)
|
||||
}
|
||||
if _, ok := mc.(map[string]interface{}); !ok {
|
||||
return opts, "metadata_condition must be an object."
|
||||
}
|
||||
}
|
||||
|
||||
metadata, err := parseMetadataQuery(c.Request.URL.Query())
|
||||
if err != nil {
|
||||
return opts, err.Error()
|
||||
@@ -820,6 +884,19 @@ func normalizeRunStatusFilter(statuses []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// invalidRunStatuses returns the raw filter values that do not map to a valid
|
||||
// run status, mirroring Python's "Invalid filter run status conditions: ...".
|
||||
func invalidRunStatuses(statuses []string) []string {
|
||||
valid := map[string]bool{"UNSTART": true, "RUNNING": true, "CANCEL": true, "DONE": true, "FAIL": true}
|
||||
invalid := make([]string, 0)
|
||||
for _, status := range statuses {
|
||||
if !valid[strings.ToUpper(status)] {
|
||||
invalid = append(invalid, status)
|
||||
}
|
||||
}
|
||||
return invalid
|
||||
}
|
||||
|
||||
func (h *DocumentHandler) UploadDocuments(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
@@ -1421,8 +1498,22 @@ func (h *DocumentHandler) StartIngestionTask(c *gin.Context) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "`document_ids` is required")
|
||||
return
|
||||
}
|
||||
// Gin's `required` accepts an empty non-nil slice; reject it explicitly.
|
||||
if len(req.DocumentIDs) == 0 {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "`document_ids` is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if orderby := c.Query("orderby"); orderby != "" {
|
||||
switch orderby {
|
||||
case "create_time", "update_time", "name":
|
||||
default:
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, fmt.Sprintf("invalid orderby field: %s", orderby))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
@@ -1529,16 +1620,25 @@ func (h *DocumentHandler) StopParseDocuments(c *gin.Context) {
|
||||
|
||||
var req StopParseDocumentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ErrorWithCode(c, common.CodeBadRequest, err.Error())
|
||||
common.ErrorWithCode(c, common.CodeDataError, "`document_ids` is required")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.DocumentIDs) == 0 {
|
||||
common.ErrorWithCode(c, common.CodeBadRequest, "`document_ids` is required")
|
||||
common.ErrorWithCode(c, common.CodeDataError, "`document_ids` is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if orderby := c.Query("orderby"); orderby != "" {
|
||||
switch orderby {
|
||||
case "create_time", "update_time", "name":
|
||||
default:
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, fmt.Sprintf("invalid orderby field: %s", orderby))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if !h.datasetService.Accessible(ctx, datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
@@ -1619,6 +1719,25 @@ func (h *DocumentHandler) UpdateDatasetDocument(c *gin.Context) {
|
||||
}
|
||||
var req document.UpdateDatasetDocumentRequest
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
// Mirror pydantic's "Field: <f> - Message: <m> - Value: <v>" format.
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
value := typeErr.Value
|
||||
if rawValue, ok := raw[typeErr.Field]; ok {
|
||||
// Render scalars the way Python's str() does: strings
|
||||
// unquoted, other JSON values verbatim.
|
||||
var scalar interface{}
|
||||
if jsonErr := json.Unmarshal(rawValue, &scalar); jsonErr == nil {
|
||||
if str, isStr := scalar.(string); isStr {
|
||||
value = str
|
||||
} else {
|
||||
value = string(rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("Field: <%s> - Message: <Input should be a valid %s> - Value: <%s>", typeErr.Field, pythonJSONKindName(typeErr), value))
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -1845,3 +1964,26 @@ func parseMetadataDeletes(raw interface{}) ([]document.DocumentMetadataDelete, s
|
||||
}
|
||||
return deletes, ""
|
||||
}
|
||||
|
||||
// pythonJSONKindName maps a JSON unmarshal type error to the pydantic type
|
||||
// name used in the "Input should be a valid ..." contract message.
|
||||
func pythonJSONKindName(typeErr *json.UnmarshalTypeError) string {
|
||||
if typeErr.Type == nil {
|
||||
return "value"
|
||||
}
|
||||
switch typeErr.Type.Kind() {
|
||||
case reflect.String:
|
||||
return "string"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return "integer"
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return "number"
|
||||
case reflect.Bool:
|
||||
return "boolean"
|
||||
case reflect.Slice, reflect.Array:
|
||||
return "list"
|
||||
case reflect.Map:
|
||||
return "dictionary"
|
||||
}
|
||||
return "value"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user