harryagasi
feat(storage): add Supabase object storage and reduce exposed API surface
e4eafe1
Raw
History Blame Contribute Delete
12.5 kB
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)
}