diff --git a/pkg/simplecontent/api/content_handler.go b/pkg/simplecontent/api/content_handler.go index 14dd9a6..a3d3e1b 100644 --- a/pkg/simplecontent/api/content_handler.go +++ b/pkg/simplecontent/api/content_handler.go @@ -139,7 +139,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) { content, err := h.service.CreateContent(r.Context(), createReq) if err != nil { slog.Error("Failed to create content", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -153,7 +153,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) { } if err := h.service.UpdateContentStatus(r.Context(), content.ID, statusEnum); err != nil { slog.Error("Failed to update content status", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } // Update the content object with the new status @@ -169,7 +169,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) { CreatedBy: ownerID.String(), }); err != nil { slog.Error("Failed to set content metadata", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -215,7 +215,7 @@ func (h *ContentHandler) GetContent(w http.ResponseWriter, r *http.Request) { content, err := h.service.GetContent(r.Context(), id) if err != nil { slog.Error("Failed to get content", "content_id", idStr, "error", err) - http.Error(w, err.Error(), http.StatusNotFound) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusNotFound) return } @@ -315,7 +315,7 @@ func (h *ContentHandler) DeleteContent(w http.ResponseWriter, r *http.Request) { if err := h.service.DeleteContent(r.Context(), id); err != nil { slog.Error("Failed to delete content", "content_id", idStr, "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -337,7 +337,7 @@ func (h *ContentHandler) GetContentDetails(w http.ResponseWriter, r *http.Reques if err != nil { slog.Error("Failed to get content details", "content_id", idStr, "error", err) - http.Error(w, err.Error(), http.StatusNotFound) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusNotFound) return } @@ -376,7 +376,7 @@ func (h *ContentHandler) CreateObject(w http.ResponseWriter, r *http.Request) { }) if err != nil { slog.Error("Fail to create object", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -394,7 +394,7 @@ func (h *ContentHandler) CreateObject(w http.ResponseWriter, r *http.Request) { uploadURL, err := h.storage.GetUploadURL(r.Context(), object.ID) if err != nil { slog.Error("Failed to get upload URL", "err", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -430,7 +430,7 @@ func (h *ContentHandler) ListObjects(w http.ResponseWriter, r *http.Request) { objects, err := h.service.GetObjectsByContentID(r.Context(), contentID) if err != nil { slog.Error("Fail to get objects by content ID", "content_id", contentIDStr, "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } if len(objects) == 0 { @@ -568,7 +568,7 @@ func (h *ContentHandler) CreateDerivedContent(w http.ResponseWriter, r *http.Req }) if err != nil { slog.Error("Failed to create derived content", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -675,7 +675,7 @@ func (h *ContentHandler) SetContentMetadata(w http.ResponseWriter, r *http.Reque // Set content metadata if err := h.service.SetContentMetadata(r.Context(), req); err != nil { slog.Error("Failed to set content metadata", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -696,7 +696,7 @@ func (h *ContentHandler) GetContentMetadataHandler(w http.ResponseWriter, r *htt metadata, err := h.service.GetContentMetadata(r.Context(), id) if err != nil { slog.Error("Failed to get content metadata", "content_id", idStr, "error", err) - http.Error(w, err.Error(), http.StatusNotFound) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusNotFound) return } @@ -748,7 +748,7 @@ func (h *ContentHandler) GetDerivedContent(w http.ResponseWriter, r *http.Reques derivedList, err := h.service.ListDerivedContent(r.Context(), simplecontent.WithParentID(parentID)) if err != nil { slog.Error("Failed to get derived content", "parent_id", parentIDStr, "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -799,7 +799,7 @@ func (h *ContentHandler) GetDerivedContentTree(w http.ResponseWriter, r *http.Re rootContent, err := h.service.GetContent(r.Context(), rootID) if err != nil { slog.Error("Failed to get root content", "root_id", rootIDStr, "error", err) - http.Error(w, err.Error(), http.StatusNotFound) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusNotFound) return } diff --git a/pkg/simplecontent/api/files_handler.go b/pkg/simplecontent/api/files_handler.go index 1eee6e7..5b89c59 100644 --- a/pkg/simplecontent/api/files_handler.go +++ b/pkg/simplecontent/api/files_handler.go @@ -129,7 +129,7 @@ func (h *FilesHandler) CreateFile(w http.ResponseWriter, r *http.Request) { }) if err != nil { slog.Error("Failed to create content", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -142,7 +142,7 @@ func (h *FilesHandler) CreateFile(w http.ResponseWriter, r *http.Request) { } if err := h.service.SetContentMetadata(r.Context(), metadataParams); err != nil { slog.Error("Failed to set content metadata", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -158,7 +158,7 @@ func (h *FilesHandler) CreateFile(w http.ResponseWriter, r *http.Request) { }) if err != nil { slog.Error("Failed to create object", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -169,7 +169,7 @@ func (h *FilesHandler) CreateFile(w http.ResponseWriter, r *http.Request) { "file_name": req.FileName, }); err != nil { slog.Error("Failed to set object metadata", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -177,7 +177,7 @@ func (h *FilesHandler) CreateFile(w http.ResponseWriter, r *http.Request) { uploadURL, err := h.storageService.GetUploadURL(r.Context(), object.ID) if err != nil { slog.Error("Failed to generate upload URL", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } @@ -206,7 +206,7 @@ func (h *FilesHandler) CompleteUpload(w http.ResponseWriter, r *http.Request) { // Complete the upload using the unified API if err := h.service.UpdateContentStatus(r.Context(), contentID, simplecontent.ContentStatusUploaded); err != nil { slog.Error("Failed to complete upload", "content_id", contentID.String(), "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, simplecontent.ToErrorMessage(err), http.StatusInternalServerError) return } diff --git a/pkg/simplecontent/api/files_handler_test.go b/pkg/simplecontent/api/files_handler_test.go index cfb7bb2..6981bcf 100644 --- a/pkg/simplecontent/api/files_handler_test.go +++ b/pkg/simplecontent/api/files_handler_test.go @@ -72,6 +72,7 @@ func TestFilesHandler_CreateFile_Success(t *testing.T) { // Memory backend doesn't support upload URLs, so we expect an error // But we can verify the request was processed assert.Equal(t, http.StatusInternalServerError, w.Code) + t.Logf("CreateFile_Success body: %s", w.Body.String()) } func TestFilesHandler_CreateFile_InvalidOwnerID(t *testing.T) { @@ -97,6 +98,7 @@ func TestFilesHandler_CreateFile_InvalidOwnerID(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) + t.Logf("CreateFile_InvalidOwnerID body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Invalid owner ID") } @@ -123,6 +125,7 @@ func TestFilesHandler_CreateFile_MissingOwnerType(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) + t.Logf("CreateFile_MissingOwnerType body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Owner type is required") } @@ -149,6 +152,7 @@ func TestFilesHandler_CreateFile_MissingDocumentType(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) + t.Logf("CreateFile_MissingDocumentType body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Document type is required") } @@ -197,6 +201,7 @@ func TestFilesHandler_CompleteUpload_InvalidContentID(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) + t.Logf("CompleteUpload_InvalidContentID body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Invalid content ID") } @@ -257,6 +262,7 @@ func TestFilesHandler_GetFileInfo_NotFound(t *testing.T) { w := httptest.NewRecorder() router.ServeHTTP(w, req) + t.Logf("GetFileInfo_NotFound body: %s", w.Body.String()) assert.Equal(t, http.StatusNotFound, w.Code) } @@ -328,6 +334,7 @@ func TestFilesHandler_GetFilesByContentIDs_MissingIDParameter(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) + t.Logf("GetFilesByContentIDs_MissingIDParameter body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Missing required 'id' parameter") } @@ -351,5 +358,26 @@ func TestFilesHandler_GetFilesByContentIDs_TooManyIDs(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) + t.Logf("GetFilesByContentIDs_TooManyIDs body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Too many IDs requested") } + +// New test to trigger JSON decode error and print the error message +func TestFilesHandler_CreateFile_InvalidJSON(t *testing.T) { + handler, _, _ := setupFilesHandlerTest(t) + router := chi.NewRouter() + router.Post("/", handler.CreateFile) + + // Invalid JSON body + body := []byte("{ invalid json") + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + // Expect 400 with decode error message + assert.Equal(t, http.StatusBadRequest, w.Code) + t.Logf("CreateFile_InvalidJSON body: %s", w.Body.String()) +} diff --git a/pkg/simplecontent/errors.go b/pkg/simplecontent/errors.go index 2ae49ae..eee5408 100644 --- a/pkg/simplecontent/errors.go +++ b/pkg/simplecontent/errors.go @@ -3,6 +3,7 @@ package simplecontent import ( "errors" "fmt" + "net/http" "github.com/google/uuid" ) @@ -11,22 +12,22 @@ import ( var ( // ErrContentNotFound indicates a content was not found ErrContentNotFound = errors.New("content not found") - + // ErrObjectNotFound indicates an object was not found ErrObjectNotFound = errors.New("object not found") - + // ErrStorageBackendNotFound indicates a storage backend was not found ErrStorageBackendNotFound = errors.New("storage backend not found") - + // ErrInvalidContentStatus indicates an invalid content status ErrInvalidContentStatus = errors.New("invalid content status") - + // ErrInvalidObjectStatus indicates an invalid object status ErrInvalidObjectStatus = errors.New("invalid object status") - + // ErrUploadFailed indicates an upload operation failed ErrUploadFailed = errors.New("upload failed") - + // ErrDownloadFailed indicates a download operation failed ErrDownloadFailed = errors.New("download failed") @@ -44,6 +45,18 @@ var ( // ErrContentBeingProcessed indicates operation cannot proceed while content is being processed ErrContentBeingProcessed = errors.New("content is being processed") + + // ErrMaxDerivationDepth indicates maximum derivation depth has been exceeded + ErrMaxDerivationDepth = errors.New("maximum derivation depth exceeded") + + // ErrNoStorageBackend indicates no storage backend is available + ErrNoStorageBackend = errors.New("no storage backend available") + + // ErrNoObjectsFound indicates no objects were found for the content + ErrNoObjectsFound = errors.New("no objects found for content") + + // ErrNoUploadedObjects indicates no uploaded objects were found + ErrNoUploadedObjects = errors.New("no uploaded objects found") ) // ContentError represents an error related to content operations @@ -61,6 +74,40 @@ func (e *ContentError) Unwrap() error { return e.Err } +// ErrorMessage returns a caller-friendly error message with technical details +func (e *ContentError) ErrorMessage() string { + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Op, e.Err) + } + return e.Op +} + +// HTTPStatus returns the appropriate HTTP status code for this error +func (e *ContentError) HTTPStatus() int { + switch { + case errors.Is(e.Err, ErrContentNotFound): + return http.StatusNotFound + case errors.Is(e.Err, ErrInvalidContentStatus): + return http.StatusBadRequest + case errors.Is(e.Err, ErrContentNotReady): + return http.StatusConflict + case errors.Is(e.Err, ErrParentNotReady): + return http.StatusConflict + case errors.Is(e.Err, ErrContentBeingProcessed): + return http.StatusConflict + case errors.Is(e.Err, ErrInvalidUploadState): + return http.StatusConflict + case errors.Is(e.Err, ErrMaxDerivationDepth): + return http.StatusBadRequest + case errors.Is(e.Err, ErrNoObjectsFound): + return http.StatusNotFound + case errors.Is(e.Err, ErrNoUploadedObjects): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} + // ObjectError represents an error related to object operations type ObjectError struct { ObjectID uuid.UUID @@ -76,6 +123,32 @@ func (e *ObjectError) Unwrap() error { return e.Err } +// ErrorMessage returns a caller-friendly error message with technical details +func (e *ObjectError) ErrorMessage() string { + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Op, e.Err) + } + return e.Op +} + +// HTTPStatus returns the appropriate HTTP status code for this error +func (e *ObjectError) HTTPStatus() int { + switch { + case errors.Is(e.Err, ErrObjectNotFound): + return http.StatusNotFound + case errors.Is(e.Err, ErrInvalidObjectStatus): + return http.StatusBadRequest + case errors.Is(e.Err, ErrObjectNotReady): + return http.StatusConflict + case errors.Is(e.Err, ErrUploadFailed): + return http.StatusInternalServerError + case errors.Is(e.Err, ErrDownloadFailed): + return http.StatusInternalServerError + default: + return http.StatusInternalServerError + } +} + // StorageError represents an error related to storage operations type StorageError struct { Backend string @@ -90,4 +163,45 @@ func (e *StorageError) Error() string { func (e *StorageError) Unwrap() error { return e.Err -} \ No newline at end of file +} + +// ErrorMessage returns a caller-friendly error message with technical details +func (e *StorageError) ErrorMessage() string { + if e.Err != nil { + return fmt.Sprintf("%s on backend %s (key: %s): %v", e.Op, e.Backend, e.Key, e.Err) + } + return fmt.Sprintf("%s on backend %s (key: %s)", e.Op, e.Backend, e.Key) +} + +// HTTPStatus returns the appropriate HTTP status code for this error +func (e *StorageError) HTTPStatus() int { + switch { + case errors.Is(e.Err, ErrStorageBackendNotFound): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} + +// ToErrorMessage converts an error to a caller-friendly message with technical details +func ToErrorMessage(err error) string { + if err == nil { + return "" + } + + // Check if it's one of our custom error types + var contentErr *ContentError + var objectErr *ObjectError + var storageErr *StorageError + + switch { + case errors.As(err, &contentErr): + return contentErr.ErrorMessage() + case errors.As(err, &objectErr): + return objectErr.ErrorMessage() + case errors.As(err, &storageErr): + return storageErr.ErrorMessage() + default: + return err.Error() + } +} diff --git a/pkg/simplecontent/service_impl.go b/pkg/simplecontent/service_impl.go index 6dacbc5..fc96278 100644 --- a/pkg/simplecontent/service_impl.go +++ b/pkg/simplecontent/service_impl.go @@ -168,7 +168,11 @@ func (s *service) CreateDerivedContent(ctx context.Context, req CreateDerivedCon // Verify parent content exists and validate status parentContent, err := s.repository.GetContent(ctx, req.ParentID) if err != nil { - return nil, fmt.Errorf("parent content not found: %w", err) + return nil, &ContentError{ + ContentID: req.ParentID, + Op: "create_derived", + Err: ErrContentNotFound, + } } // Validate parent content status for creating derived content @@ -187,7 +191,7 @@ func (s *service) CreateDerivedContent(ctx context.Context, req CreateDerivedCon return nil, &ContentError{ ContentID: req.ParentID, Op: "create_derived", - Err: fmt.Errorf("maximum derivation depth (%d) exceeded", maxDerivationDepth), + Err: ErrMaxDerivationDepth, } } @@ -242,7 +246,11 @@ func (s *service) CreateDerivedContent(ctx context.Context, req CreateDerivedCon FileName: req.FileName, } if err := s.repository.SetContentMetadata(ctx, metadata); err != nil { - return nil, fmt.Errorf("failed to create derived content metadata: %w", err) + return nil, &ContentError{ + ContentID: content.ID, + Op: "create_derived", + Err: err, + } } } @@ -262,7 +270,11 @@ func (s *service) CreateDerivedContent(ctx context.Context, req CreateDerivedCon ProcessingMetadata: nil, }) if err != nil { - return nil, fmt.Errorf("failed to create derived content relationship: %w", err) + return nil, &ContentError{ + ContentID: content.ID, + Op: "create_derived", + Err: err, + } } // Fire event @@ -638,7 +650,11 @@ func (s *service) UploadDerivedContent(ctx context.Context, req UploadDerivedCon // Step 1: Verify parent content exists and validate status parentContent, err := s.repository.GetContent(ctx, req.ParentID) if err != nil { - return nil, fmt.Errorf("parent content not found: %w", err) + return nil, &ContentError{ + ContentID: req.ParentID, + Op: "upload_derived", + Err: ErrContentNotFound, + } } // Validate parent content status for creating derived content @@ -687,7 +703,11 @@ func (s *service) UploadDerivedContent(ctx context.Context, req UploadDerivedCon ProcessingMetadata: nil, }) if err != nil { - return nil, fmt.Errorf("failed to create derived content relationship: %w", err) + return nil, &ContentError{ + ContentID: content.ID, + Op: "upload_derived_create", + Err: err, + } } // Step 5: Determine storage backend @@ -700,7 +720,11 @@ func (s *service) UploadDerivedContent(ctx context.Context, req UploadDerivedCon } } if storageBackend == "" { - return nil, fmt.Errorf("no storage backend available") + return nil, &ContentError{ + ContentID: content.ID, + Op: "upload_derived_create", + Err: ErrNoStorageBackend, + } } // Step 6: Create the object @@ -811,7 +835,11 @@ func (s *service) UploadObjectForContent(ctx context.Context, req UploadObjectFo } } if storageBackend == "" { - return nil, fmt.Errorf("no storage backend available") + return nil, &ContentError{ + ContentID: req.ContentID, + Op: "upload_object_get_content", + Err: ErrNoStorageBackend, + } } // Get content metadata for filename @@ -979,7 +1007,7 @@ func (s *service) DownloadContent(ctx context.Context, contentID uuid.UUID) (io. return nil, &ContentError{ ContentID: contentID, Op: "download", - Err: fmt.Errorf("no objects found for content"), + Err: ErrNoObjectsFound, } } @@ -996,7 +1024,7 @@ func (s *service) DownloadContent(ctx context.Context, contentID uuid.UUID) (io. return nil, &ContentError{ ContentID: contentID, Op: "download", - Err: fmt.Errorf("no uploaded objects found for content"), + Err: ErrNoUploadedObjects, } } @@ -1015,7 +1043,11 @@ func (s *service) SetContentMetadata(ctx context.Context, req SetContentMetadata // Verify content exists _, err := s.repository.GetContent(ctx, req.ContentID) if err != nil { - return fmt.Errorf("content not found: %w", err) + return &ContentError{ + ContentID: req.ContentID, + Op: "set_metadata", + Err: ErrContentNotFound, + } } now := time.Now().UTC() @@ -1834,7 +1866,7 @@ func (s *service) GetContentDetailsBatch(ctx context.Context, contentIDs []uuid. // Batch query 1: Get all contents contents, err := s.repository.GetContentsByIDs(ctx, contentIDs) if err != nil { - return nil, fmt.Errorf("failed to get contents: %w", err) + return nil, err } // Build content map for quick lookup @@ -1874,7 +1906,7 @@ func (s *service) GetContentDetailsBatch(ctx context.Context, contentIDs []uuid. // Batch query 3: Get all objects objectsMap, err := s.repository.GetObjectsByContentIDs(ctx, contentIDs) if err != nil { - return nil, fmt.Errorf("failed to get objects: %w", err) + return nil, err } // Collect all object IDs for batch metadata query @@ -2008,7 +2040,6 @@ func (s *service) GetContentDetailsBatch(ctx context.Context, contentIDs []uuid. return result, nil } - // computeDerivationDepth computes the derivation depth by recursively traversing the parent chain // Maximum depth is capped at 100 to prevent infinite loops func (s *service) computeDerivationDepth(ctx context.Context, contentID uuid.UUID) int {