Spaces:
Sleeping
Sleeping
| package documents | |
| import ( | |
| "bufio" | |
| "encoding/json" | |
| "errors" | |
| "io" | |
| "log/slog" | |
| "mime" | |
| "mime/multipart" | |
| "net/http" | |
| "strings" | |
| "time" | |
| "github.com/google/uuid" | |
| "project/internal/api" | |
| "project/internal/auth" | |
| ) | |
| type Handler struct { | |
| svc *Service | |
| } | |
| func NewHandler(svc *Service) *Handler { | |
| return &Handler{svc: svc} | |
| } | |
| // ListDocTypes godoc | |
| // | |
| // @Summary List tipe dokumen yang didukung | |
| // @Tags documents | |
| // @Produce json | |
| // @Success 200 {object} api.APIResponse{data=[]DocTypeInfo} | |
| // @Security BearerAuth | |
| // @Router /api/v1/documents/doctypes [get] | |
| func (h *Handler) ListDocTypes(w http.ResponseWriter, r *http.Request) { | |
| writeJSON(w, http.StatusOK, api.APIResponse{ | |
| Status: "success", | |
| Message: "supported document types", | |
| Data: h.svc.ListDocTypes(), | |
| }) | |
| } | |
| // ListByUser godoc | |
| // | |
| // @Summary List dokumen milik user | |
| // @Tags documents | |
| // @Produce json | |
| // @Param user_id path string true "User ID" | |
| // @Success 200 {object} api.APIResponse{data=[]Document} | |
| // @Failure 500 {object} api.APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/documents/{user_id} [get] | |
| func (h *Handler) ListByUser(w http.ResponseWriter, r *http.Request) { | |
| userID := r.PathValue("user_id") | |
| if rejectUserMismatch(w, r, userID) { | |
| return | |
| } | |
| docs, err := h.svc.ListByUser(r.Context(), userID) | |
| if err != nil { | |
| slog.Error("list documents", "user_id", userID, "err", err) | |
| writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: "internal server error"}) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, api.APIResponse{Status: "success", Message: "documents", Data: docs}) | |
| } | |
| // Upload godoc | |
| // | |
| // @Summary Upload dokumen ke private object storage | |
| // @Description Mendukung pdf, docx, txt, csv, xlsx. Maksimum 10 MB. Stream ke private object storage tanpa mengubah kontrak upload frontend. | |
| // @Tags documents | |
| // @Accept multipart/form-data | |
| // @Produce json | |
| // @Param user_id formData string true "User ID" | |
| // @Param file formData file true "File dokumen" | |
| // @Success 201 {object} api.APIResponse{data=Document} | |
| // @Failure 400 {object} api.APIResponse | |
| // @Failure 500 {object} api.APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/document/upload [post] | |
| func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { | |
| reqStart := time.Now() | |
| requestID := r.Header.Get("X-Request-Id") | |
| if requestID == "" { | |
| requestID = uuid.New().String() | |
| } | |
| slog.Info("upload: request received", | |
| "request_id", requestID, | |
| "remote_addr", r.RemoteAddr, | |
| "content_length", r.ContentLength, | |
| "user_agent", r.UserAgent(), | |
| ) | |
| // r.MultipartReader() uses Go's default 4 KB socket read buffer, causing | |
| // ~675 syscalls for a 2.7 MB file. On Windows each syscall hits the TCP | |
| // delayed-ACK timer (~26 ms), adding up to ~17 s. Wrapping r.Body with a | |
| // 1 MB bufio.Reader reduces that to ~3 syscalls. | |
| mediaType, params, parseErr := mime.ParseMediaType(r.Header.Get("Content-Type")) | |
| if parseErr != nil || mediaType != "multipart/form-data" || params["boundary"] == "" { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "multipart/form-data required"}) | |
| return | |
| } | |
| mr := multipart.NewReader(bufio.NewReaderSize(r.Body, 1*1024*1024), params["boundary"]) | |
| var userID string | |
| var filename string | |
| var fileSize int64 | |
| var filePart io.Reader | |
| for { | |
| part, err := mr.NextPart() | |
| if err == io.EOF { | |
| break | |
| } | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "failed to parse form"}) | |
| return | |
| } | |
| switch part.FormName() { | |
| case "user_id": | |
| data, _ := io.ReadAll(io.LimitReader(part, 512)) | |
| userID = strings.TrimSpace(string(data)) | |
| case "file": | |
| filename = part.FileName() | |
| fileSize = r.ContentLength | |
| filePart = part | |
| } | |
| if userID != "" && filePart != nil { | |
| break | |
| } | |
| } | |
| if userID == "" { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "user_id is required"}) | |
| return | |
| } | |
| if rejectUserMismatch(w, r, userID) { | |
| return | |
| } | |
| if filePart == nil { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "file is required"}) | |
| return | |
| } | |
| slog.Info("upload: multipart parsed", | |
| "request_id", requestID, | |
| "user_id", userID, | |
| "filename", filename, | |
| "content_length", fileSize, | |
| "elapsed_ms", time.Since(reqStart).Milliseconds(), | |
| ) | |
| doc, err := h.svc.Upload(r.Context(), userID, filename, filePart, fileSize, requestID) | |
| if err != nil { | |
| elapsed := time.Since(reqStart).Milliseconds() | |
| switch { | |
| case errors.Is(err, ErrUnsupportedType): | |
| slog.Warn("upload: unsupported file type", "request_id", requestID, "filename", filename, "elapsed_ms", elapsed) | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "unsupported file type"}) | |
| case errors.Is(err, ErrFileTooLarge): | |
| slog.Warn("upload: file too large", "request_id", requestID, "filename", filename, "elapsed_ms", elapsed) | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "file too large (max 10 MB)"}) | |
| default: | |
| slog.Error("upload: failed", "request_id", requestID, "err", err, "elapsed_ms", elapsed) | |
| writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: "upload failed"}) | |
| } | |
| return | |
| } | |
| slog.Info("upload: completed", | |
| "request_id", requestID, | |
| "doc_id", doc.ID, | |
| "user_id", userID, | |
| "filename", filename, | |
| "file_size_bytes", doc.FileSize, | |
| "elapsed_ms", time.Since(reqStart).Milliseconds(), | |
| ) | |
| writeJSON(w, http.StatusCreated, api.APIResponse{Status: "success", Message: "file uploaded", Data: doc}) | |
| } | |
| // UploadLocal godoc | |
| // | |
| // @Summary Upload dokumen ke local filesystem (benchmarking) | |
| // @Description Sama seperti /upload tetapi menyimpan file ke folder files/ lokal. Digunakan untuk membandingkan performa vs private object storage. | |
| // @Tags documents | |
| // @Accept multipart/form-data | |
| // @Produce json | |
| // @Param user_id formData string true "User ID" | |
| // @Param file formData file true "File dokumen" | |
| // @Success 201 {object} api.APIResponse | |
| // @Failure 400 {object} api.APIResponse | |
| // @Failure 500 {object} api.APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/document/upload-local [post] | |
| func (h *Handler) UploadLocal(w http.ResponseWriter, r *http.Request) { | |
| mediaType, params, parseErr := mime.ParseMediaType(r.Header.Get("Content-Type")) | |
| if parseErr != nil || mediaType != "multipart/form-data" || params["boundary"] == "" { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "multipart/form-data required"}) | |
| return | |
| } | |
| mr := multipart.NewReader(bufio.NewReaderSize(r.Body, 1*1024*1024), params["boundary"]) | |
| var userID string | |
| var filename string | |
| var fileSize int64 | |
| var filePart io.Reader | |
| for { | |
| part, err := mr.NextPart() | |
| if err == io.EOF { | |
| break | |
| } | |
| if err != nil { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "failed to parse form"}) | |
| return | |
| } | |
| switch part.FormName() { | |
| case "user_id": | |
| data, _ := io.ReadAll(io.LimitReader(part, 512)) | |
| userID = strings.TrimSpace(string(data)) | |
| case "file": | |
| filename = part.FileName() | |
| fileSize = r.ContentLength | |
| filePart = part | |
| } | |
| if userID != "" && filePart != nil { | |
| break | |
| } | |
| } | |
| if userID == "" { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "user_id is required"}) | |
| return | |
| } | |
| if rejectUserMismatch(w, r, userID) { | |
| return | |
| } | |
| if filePart == nil { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "file is required"}) | |
| return | |
| } | |
| destPath, err := h.svc.UploadLocal(r.Context(), userID, filename, filePart, fileSize) | |
| if err != nil { | |
| switch { | |
| case errors.Is(err, ErrUnsupportedType): | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "unsupported file type"}) | |
| case errors.Is(err, ErrFileTooLarge): | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "file too large (max 10 MB)"}) | |
| default: | |
| slog.Error("local upload failed", "err", err) | |
| writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: "local upload failed"}) | |
| } | |
| return | |
| } | |
| writeJSON(w, http.StatusCreated, api.APIResponse{Status: "success", Message: "file saved locally", Data: map[string]string{"path": destPath}}) | |
| } | |
| // Process godoc | |
| // | |
| // @Summary Proses dokumen secara async (extract text / convert ke Parquet) | |
| // @Description Processing berjalan di background. Response 202 dikembalikan segera setelah validasi. Pantau progress via GET /api/v1/documents/{user_id}. | |
| // @Tags documents | |
| // @Accept json | |
| // @Produce json | |
| // @Param body body object{document_id=string,user_id=string} true "Document ID dan User ID" | |
| // @Success 202 {object} api.APIResponse{data=ProcessResult} | |
| // @Failure 400 {object} api.APIResponse | |
| // @Failure 403 {object} api.APIResponse | |
| // @Failure 404 {object} api.APIResponse | |
| // @Failure 500 {object} api.APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/document/process [post] | |
| func (h *Handler) Process(w http.ResponseWriter, r *http.Request) { | |
| var req struct { | |
| DocumentID string `json:"document_id"` | |
| UserID string `json:"user_id"` | |
| } | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "invalid request body"}) | |
| return | |
| } | |
| if req.DocumentID == "" || req.UserID == "" { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "document_id and user_id are required"}) | |
| return | |
| } | |
| if rejectUserMismatch(w, r, req.UserID) { | |
| return | |
| } | |
| result, err := h.svc.Process(r.Context(), req.DocumentID, req.UserID) | |
| if err != nil { | |
| switch { | |
| case errors.Is(err, ErrNotFound): | |
| writeJSON(w, http.StatusNotFound, api.APIResponse{Status: "error", Message: "document not found"}) | |
| case errors.Is(err, ErrForbidden): | |
| writeJSON(w, http.StatusForbidden, api.APIResponse{Status: "error", Message: "access denied"}) | |
| default: | |
| writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: err.Error()}) | |
| } | |
| return | |
| } | |
| writeJSON(w, http.StatusAccepted, api.APIResponse{Status: "success", Message: "document processing started", Data: result}) | |
| } | |
| // Delete godoc | |
| // | |
| // @Summary Hapus dokumen | |
| // @Description Menghapus file dari object storage, embeddings, Parquet, dan record database | |
| // @Tags documents | |
| // @Accept json | |
| // @Produce json | |
| // @Param body body object{document_id=string,user_id=string} true "Document ID dan User ID" | |
| // @Success 200 {object} api.APIResponse | |
| // @Failure 400 {object} api.APIResponse | |
| // @Failure 403 {object} api.APIResponse | |
| // @Failure 404 {object} api.APIResponse | |
| // @Failure 500 {object} api.APIResponse | |
| // @Security BearerAuth | |
| // @Router /api/v1/document/delete [delete] | |
| func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { | |
| var req struct { | |
| DocumentID string `json:"document_id"` | |
| UserID string `json:"user_id"` | |
| } | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "invalid request body"}) | |
| return | |
| } | |
| if req.DocumentID == "" || req.UserID == "" { | |
| writeJSON(w, http.StatusBadRequest, api.APIResponse{Status: "error", Message: "document_id and user_id are required"}) | |
| return | |
| } | |
| if rejectUserMismatch(w, r, req.UserID) { | |
| return | |
| } | |
| if err := h.svc.Delete(r.Context(), req.DocumentID, req.UserID); err != nil { | |
| switch { | |
| case errors.Is(err, ErrNotFound): | |
| writeJSON(w, http.StatusNotFound, api.APIResponse{Status: "error", Message: "document not found"}) | |
| case errors.Is(err, ErrForbidden): | |
| writeJSON(w, http.StatusForbidden, api.APIResponse{Status: "error", Message: "access denied"}) | |
| default: | |
| writeJSON(w, http.StatusInternalServerError, api.APIResponse{Status: "error", Message: "delete failed"}) | |
| } | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, api.APIResponse{Status: "success", Message: "document deleted"}) | |
| } | |
| func rejectUserMismatch(w http.ResponseWriter, r *http.Request, userID string) bool { | |
| if err := auth.MatchContextUserID(r.Context(), userID); err != nil { | |
| writeJSON(w, http.StatusForbidden, api.APIResponse{Status: "error", Message: "user_id does not match authenticated user"}) | |
| return true | |
| } | |
| return false | |
| } | |
| func writeJSON(w http.ResponseWriter, status int, v any) { | |
| w.Header().Set("Content-Type", "application/json") | |
| w.WriteHeader(status) | |
| _ = json.NewEncoder(w).Encode(v) | |
| } | |