401 lines
11 KiB
Go
401 lines
11 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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}.cog", h.cog)
|
|
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's pre-assembled GeoJSON FeatureCollection (RFC
|
|
// 7946), generated and stored at processing time. A vector_with_kato dataset's
|
|
// collection joins the districts table on KATO code (one feature per KATO with
|
|
// the observation values mapped onto its district polygon); a plain vector
|
|
// dataset's collection wraps its own geometry as a single feature.
|
|
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)
|
|
_, _ = w.Write(fc)
|
|
}
|
|
|
|
// cog streams the raster dataset's Cloud-Optimized GeoTIFF. It responds 422 for
|
|
// a non-raster dataset and 409 when the raster's COG has not been produced yet.
|
|
func (h *DatasetHandler) cog(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := parseUUIDParam(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
dataset, obj, err := h.svc.COG(r.Context(), id)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrNotRaster):
|
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "dataset is not a raster")
|
|
case errors.Is(err, service.ErrCOGNotReady):
|
|
httputil.WriteError(w, http.StatusConflict, "cog is not ready")
|
|
default:
|
|
respondDomainError(w, err)
|
|
}
|
|
return
|
|
}
|
|
defer obj.Close()
|
|
|
|
w.Header().Set("Content-Type", "image/tiff")
|
|
w.Header().Set("Content-Disposition", `inline; filename="`+cogFilename(dataset.Filename)+`"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := io.Copy(w, obj); err != nil {
|
|
// Headers are already sent; nothing useful to return to the client.
|
|
return
|
|
}
|
|
}
|
|
|
|
// cogFilename derives a .cog.tif download name from the source filename.
|
|
func cogFilename(filename string) string {
|
|
if i := strings.LastIndex(filename, "."); i > 0 {
|
|
filename = filename[:i]
|
|
}
|
|
return filename + ".cog.tif"
|
|
}
|
|
|
|
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)
|
|
}
|