gis/internal/transport/http/dataset_handler.go

380 lines
10 KiB
Go

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}.geojson", h.geojson)
r.Get("/{id}.kato.geojson", h.katoGeoJSON)
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=<status> it
// holds the request until the status changes (or ?wait=<seconds> 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
}
filter := domain.DatasetFilter{CategoryID: categoryID}
if v := strings.TrimSpace(r.URL.Query().Get("file_type")); v != "" {
ft := domain.FileType(v)
if !ft.Valid() {
httputil.WriteError(w, http.StatusBadRequest, "file_type must be one of: vector_with_kato, vector, raster")
return
}
filter.FileType = &ft
}
if v := strings.TrimSpace(r.URL.Query().Get("automated")); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
httputil.WriteError(w, http.StatusBadRequest, "automated must be a boolean")
return
}
filter.Automated = &b
}
if v := strings.TrimSpace(r.URL.Query().Get("status")); v != "" {
if !domain.ValidDatasetStatus(v) {
httputil.WriteError(w, http.StatusBadRequest, "invalid status")
return
}
filter.Status = &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(), filter, 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)
}
// geojson returns the dataset as a GeoJSON FeatureCollection (RFC 7946). For a
// vector_with_kato dataset it serves the dataset's own geometry as a single
// feature when present, otherwise one feature per KATO joined to the districts
// table. Only vector_with_kato datasets are supported.
func (h *DatasetHandler) geojson(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDParam(w, r, "id")
if !ok {
return
}
fc, err := h.svc.GeoJSON(r.Context(), id)
if err != nil {
respondDomainError(w, err)
return
}
w.Header().Set("Content-Type", "application/geo+json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(fc)
}
// katoGeoJSON returns the dataset as a GeoJSON FeatureCollection (RFC 7946),
// ignoring any geometry the dataset carries and instead joining the districts
// table on KATO code: one feature per KATO with the observation values mapped
// onto its district polygon. Only vector_with_kato datasets are supported.
func (h *DatasetHandler) katoGeoJSON(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDParam(w, r, "id")
if !ok {
return
}
fc, err := h.svc.KatoGeoJSON(r.Context(), id)
if err != nil {
respondDomainError(w, err)
return
}
w.Header().Set("Content-Type", "application/geo+json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(fc)
}
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)
}