package http import ( "encoding/json" "io" "net/http" "strconv" "strings" "time" "gis/internal/domain" "gis/internal/service" "gis/pkg/httputil" "github.com/go-chi/chi/v5" "github.com/go-playground/validator/v10" "github.com/google/uuid" ) // maxUploadBytes caps the in-memory portion of a multipart upload (64 MiB). const maxUploadBytes = 64 << 20 // DatasetHandler serves the /datasets routes. type DatasetHandler struct { svc *service.DatasetService validate *validator.Validate } // NewDatasetHandler returns a DatasetHandler. func NewDatasetHandler(svc *service.DatasetService, validate *validator.Validate) *DatasetHandler { return &DatasetHandler{svc: svc, validate: validate} } // Register mounts the dataset routes on r. func (h *DatasetHandler) Register(r chi.Router) { r.Get("/", h.list) r.Post("/", h.upload) r.Get("/{id}", h.get) r.Get("/{id}/status", h.status) r.Get("/{id}/download", h.download) r.Post("/{id}/mapping", h.mapping) r.Get("/{id}/observations", h.observations) r.Delete("/{id}", h.delete) } // status long-polls the dataset's processing status. With ?current= it // holds the request until the status changes (or ?wait= elapses, // default 25, max 60); without it, it returns the current status immediately. func (h *DatasetHandler) status(w http.ResponseWriter, r *http.Request) { id, ok := parseUUIDParam(w, r, "id") if !ok { return } current := r.URL.Query().Get("current") var wait time.Duration if raw := r.URL.Query().Get("wait"); raw != "" { secs, err := strconv.Atoi(raw) if err != nil || secs < 0 { httputil.WriteError(w, http.StatusBadRequest, "invalid wait") return } wait = time.Duration(secs) * time.Second } info, err := h.svc.WaitForStatus(r.Context(), id, current, wait) if err != nil { respondDomainError(w, err) return } httputil.WriteJSON(w, http.StatusOK, info) } type yearColumnInput struct { Column string `json:"column" validate:"required"` Date string `json:"date" validate:"required,datetime=2006-01-02"` } type mappingRequest struct { KatoColumn string `json:"kato_column" validate:"required"` YearColumns []yearColumnInput `json:"year_columns" validate:"required,min=1,dive"` } // mapping saves the KATO column and year-column mapping for a vector_with_kato // dataset, moving it to ready. func (h *DatasetHandler) mapping(w http.ResponseWriter, r *http.Request) { id, ok := parseUUIDParam(w, r, "id") if !ok { return } req, err := httputil.DecodeJSON[mappingRequest](w, r) if err != nil { httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } if err := h.validate.Struct(req); err != nil { httputil.WriteValidationErrors(w, err) return } in := service.MappingInput{KatoColumn: req.KatoColumn} for _, yc := range req.YearColumns { in.YearColumns = append(in.YearColumns, domain.YearColumn{Column: yc.Column, Date: yc.Date}) } dataset, err := h.svc.SaveMapping(r.Context(), id, in) if err != nil { respondDomainError(w, err) return } httputil.WriteJSON(w, http.StatusOK, dataset) } func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(maxUploadBytes); err != nil { httputil.WriteError(w, http.StatusBadRequest, "request must be multipart/form-data") return } categoryRaw := r.FormValue("category_id") categoryID, err := uuid.Parse(categoryRaw) if err != nil { httputil.WriteError(w, http.StatusUnprocessableEntity, "category_id must be a valid UUID") return } fileType := domain.FileType(r.FormValue("file_type")) if !fileType.Valid() { httputil.WriteError(w, http.StatusUnprocessableEntity, "file_type must be one of: vector_with_kato, vector, raster") return } meta, ok := optionalJSONFormValue(w, r, "meta") if !ok { return } automated, err := optionalBoolFormValue(r, "automated") if err != nil { httputil.WriteError(w, http.StatusUnprocessableEntity, "automated must be a boolean") return } file, header, err := r.FormFile("file") if err != nil { httputil.WriteError(w, http.StatusBadRequest, "file is required") return } defer file.Close() contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } dataset, err := h.svc.Upload(r.Context(), service.UploadInput{ CategoryID: categoryID, Name: r.FormValue("name"), Description: optionalFormValue(r, "description"), Unit: optionalFormValue(r, "unit"), Meta: meta, Automated: automated, Filename: header.Filename, FileType: fileType, ContentType: contentType, Size: header.Size, Reader: file, }) if err != nil { respondDomainError(w, err) return } httputil.WriteJSON(w, http.StatusCreated, dataset) } // optionalFormValue returns a pointer to a trimmed form value, or nil when the // field is absent or blank, so nullable columns stay NULL. func optionalFormValue(r *http.Request, name string) *string { v := strings.TrimSpace(r.FormValue(name)) if v == "" { return nil } return &v } // optionalJSONFormValue reads a form field expected to contain JSON. A blank // value yields (nil, true); invalid JSON writes a 422 and yields (nil, false). func optionalJSONFormValue(w http.ResponseWriter, r *http.Request, name string) (json.RawMessage, bool) { v := strings.TrimSpace(r.FormValue(name)) if v == "" { return nil, true } if !json.Valid([]byte(v)) { httputil.WriteError(w, http.StatusUnprocessableEntity, name+" must be valid JSON") return nil, false } return json.RawMessage(v), true } // optionalBoolFormValue parses an optional boolean form field, defaulting to // false when the field is absent or blank. func optionalBoolFormValue(r *http.Request, name string) (bool, error) { v := strings.TrimSpace(r.FormValue(name)) if v == "" { return false, nil } return strconv.ParseBool(v) } func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) { categoryID, ok := parseOptionalUUIDQuery(w, r, "category_id") if !ok { return } var categoryCode *string if v := strings.TrimSpace(r.URL.Query().Get("category_code")); v != "" { categoryCode = &v } page, ok := parsePositiveIntQuery(w, r, "page", 1) if !ok { return } pageSize, ok := parsePositiveIntQuery(w, r, "page_size", service.DefaultPageSize) if !ok { return } res, err := h.svc.ListSummaries(r.Context(), categoryID, categoryCode, page, pageSize) if err != nil { respondDomainError(w, err) return } httputil.WriteJSON(w, http.StatusOK, newPaginated(res.Items, res.Page, res.PageSize, res.Total)) } func (h *DatasetHandler) observations(w http.ResponseWriter, r *http.Request) { id, ok := parseUUIDParam(w, r, "id") if !ok { return } page, ok := parsePositiveIntQuery(w, r, "page", 1) if !ok { return } pageSize, ok := parsePositiveIntQuery(w, r, "page_size", service.DefaultPageSize) if !ok { return } var katoCode *string if v := strings.TrimSpace(r.URL.Query().Get("kato_code")); v != "" { katoCode = &v } res, err := h.svc.ListObservations(r.Context(), id, katoCode, page, pageSize) if err != nil { respondDomainError(w, err) return } httputil.WriteJSON(w, http.StatusOK, newPaginated(res.Items, res.Page, res.PageSize, res.Total)) } func (h *DatasetHandler) get(w http.ResponseWriter, r *http.Request) { id, ok := parseUUIDParam(w, r, "id") if !ok { return } dataset, err := h.svc.Get(r.Context(), id) if err != nil { respondDomainError(w, err) return } httputil.WriteJSON(w, http.StatusOK, dataset) } func (h *DatasetHandler) download(w http.ResponseWriter, r *http.Request) { id, ok := parseUUIDParam(w, r, "id") if !ok { return } dataset, obj, err := h.svc.Download(r.Context(), id) if err != nil { respondDomainError(w, err) return } defer obj.Close() w.Header().Set("Content-Type", dataset.ContentType) w.Header().Set("Content-Disposition", `attachment; filename="`+dataset.Filename+`"`) if dataset.SizeBytes > 0 { w.Header().Set("Content-Length", strconv.FormatInt(dataset.SizeBytes, 10)) } if _, err := io.Copy(w, obj); err != nil { // Headers are already sent; nothing useful to return to the client. return } } func (h *DatasetHandler) delete(w http.ResponseWriter, r *http.Request) { id, ok := parseUUIDParam(w, r, "id") if !ok { return } if err := h.svc.Delete(r.Context(), id); err != nil { respondDomainError(w, err) return } w.WriteHeader(http.StatusNoContent) }