Second checkpoint

This commit is contained in:
2026-06-25 01:17:42 +05:00
parent 854ef138d0
commit 9cf791b789
90 changed files with 5504 additions and 988 deletions

View File

@@ -0,0 +1,173 @@
package http
import (
"net/http"
"strconv"
"gis/internal/service"
"gis/pkg/httputil"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
)
// CategoryHandler serves the /categories routes.
type CategoryHandler struct {
svc *service.CategoryService
validate *validator.Validate
}
// NewCategoryHandler returns a CategoryHandler.
func NewCategoryHandler(svc *service.CategoryService, validate *validator.Validate) *CategoryHandler {
return &CategoryHandler{svc: svc, validate: validate}
}
// Register mounts the category routes on r.
func (h *CategoryHandler) Register(r chi.Router) {
r.Get("/", h.list)
r.Post("/", h.create)
r.Get("/{id}", h.get)
r.Put("/{id}", h.update)
r.Delete("/{id}", h.delete)
}
type categoryRequest struct {
ParentID *string `json:"parent_id" validate:"omitempty,uuid"`
Name string `json:"name" validate:"required,max=255"`
Description string `json:"description" validate:"max=2000"`
}
func (r categoryRequest) toInput() (service.CategoryInput, error) {
in := service.CategoryInput{Name: r.Name, Description: r.Description}
if r.ParentID != nil {
id, err := uuid.Parse(*r.ParentID)
if err != nil {
return in, err
}
in.ParentID = &id
}
return in, nil
}
func (h *CategoryHandler) create(w http.ResponseWriter, r *http.Request) {
req, err := httputil.DecodeJSON[categoryRequest](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, _ := req.toInput()
category, err := h.svc.Create(r.Context(), in)
if err != nil {
respondDomainError(w, err)
return
}
httputil.WriteJSON(w, http.StatusCreated, category)
}
func (h *CategoryHandler) list(w http.ResponseWriter, r *http.Request) {
parentID, ok := parseOptionalUUIDQuery(w, r, "parent_id")
if !ok {
return
}
categories, err := h.svc.List(r.Context(), parentID)
if err != nil {
respondDomainError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, categories)
}
func (h *CategoryHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDParam(w, r, "id")
if !ok {
return
}
category, err := h.svc.Get(r.Context(), id)
if err != nil {
respondDomainError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, category)
}
func (h *CategoryHandler) update(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDParam(w, r, "id")
if !ok {
return
}
req, err := httputil.DecodeJSON[categoryRequest](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, _ := req.toInput()
category, err := h.svc.Update(r.Context(), id, in)
if err != nil {
respondDomainError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, category)
}
func (h *CategoryHandler) 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)
}
// parseUUIDParam reads a UUID path parameter, writing a 400 if it is invalid.
func parseUUIDParam(w http.ResponseWriter, r *http.Request, name string) (uuid.UUID, bool) {
id, err := uuid.Parse(chi.URLParam(r, name))
if err != nil {
httputil.WriteError(w, http.StatusBadRequest, "invalid "+name)
return uuid.Nil, false
}
return id, true
}
// parsePositiveIntQuery reads an optional positive integer query parameter,
// returning def when absent. A present but invalid value writes a 400.
func parsePositiveIntQuery(w http.ResponseWriter, r *http.Request, name string, def int) (int, bool) {
raw := r.URL.Query().Get(name)
if raw == "" {
return def, true
}
v, err := strconv.Atoi(raw)
if err != nil || v < 1 {
httputil.WriteError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return v, true
}
// parseOptionalUUIDQuery reads an optional UUID query parameter. A missing value
// yields (nil, true); an invalid value writes a 400 and yields (nil, false).
func parseOptionalUUIDQuery(w http.ResponseWriter, r *http.Request, name string) (*uuid.UUID, bool) {
raw := r.URL.Query().Get(name)
if raw == "" {
return nil, true
}
id, err := uuid.Parse(raw)
if err != nil {
httputil.WriteError(w, http.StatusBadRequest, "invalid "+name)
return nil, false
}
return &id, true
}

View File

@@ -0,0 +1,313 @@
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=<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
}
code := r.FormValue("code")
if code == "" {
httputil.WriteError(w, http.StatusUnprocessableEntity, "code is required")
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,
Code: code,
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
}
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, 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)
}

View File

@@ -0,0 +1,24 @@
package http
import (
"errors"
"net/http"
"gis/internal/domain"
"gis/pkg/httputil"
)
// respondDomainError maps a domain error to an HTTP status and writes a JSON
// error envelope.
func respondDomainError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrNotFound):
httputil.WriteError(w, http.StatusNotFound, "not found")
case errors.Is(err, domain.ErrValidation):
httputil.WriteError(w, http.StatusUnprocessableEntity, err.Error())
case errors.Is(err, domain.ErrConflict):
httputil.WriteError(w, http.StatusConflict, "operation conflicts with existing data")
default:
httputil.WriteError(w, http.StatusInternalServerError, "internal server error")
}
}

View File

@@ -0,0 +1,50 @@
package http
import (
"context"
"net/http"
"gis/pkg/httputil"
)
// ReadinessCheck reports whether a dependency is reachable.
type ReadinessCheck func(ctx context.Context) error
// HealthHandler serves liveness and readiness probes.
type HealthHandler struct {
checks map[string]ReadinessCheck
}
// NewHealthHandler builds a HealthHandler with the given named readiness checks.
func NewHealthHandler(checks map[string]ReadinessCheck) *HealthHandler {
return &HealthHandler{checks: checks}
}
// Live reports that the process is up.
func (h *HealthHandler) Live(w http.ResponseWriter, r *http.Request) {
httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// Ready runs all readiness checks and reports per-dependency status. It returns
// 503 if any check fails.
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
results := make(map[string]string, len(h.checks))
ready := true
for name, check := range h.checks {
if err := check(r.Context()); err != nil {
results[name] = "error: " + err.Error()
ready = false
continue
}
results[name] = "ok"
}
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
httputil.WriteJSON(w, status, map[string]any{
"ready": ready,
"components": results,
})
}

View File

@@ -0,0 +1,33 @@
package http
import (
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
)
// requestLogger logs each request once it completes, including method, path,
// status, byte count, duration, and the chi request id.
func requestLogger(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
defer func() {
log.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
}()
next.ServeHTTP(ww, r)
})
}
}

View File

@@ -0,0 +1,29 @@
package http
// Paginated is the generic list response envelope.
type Paginated[T any] struct {
Data []T `json:"data"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
}
// newPaginated builds a Paginated envelope, computing total pages and
// normalizing a nil slice to an empty array.
func newPaginated[T any](items []T, page, pageSize, total int) Paginated[T] {
totalPages := 0
if pageSize > 0 {
totalPages = (total + pageSize - 1) / pageSize
}
if items == nil {
items = []T{}
}
return Paginated[T]{
Data: items,
Page: page,
PageSize: pageSize,
Total: total,
TotalPages: totalPages,
}
}

View File

@@ -0,0 +1,42 @@
package http
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// RouterDeps holds the handlers and dependencies the router mounts.
type RouterDeps struct {
Logger *slog.Logger
Health *HealthHandler
Categories *CategoryHandler
Datasets *DatasetHandler
OpenAPISpec []byte
}
// NewRouter builds the application's HTTP handler with middleware and routes.
func NewRouter(deps RouterDeps) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(requestLogger(deps.Logger))
r.Use(middleware.Recoverer)
// Health/readiness endpoints.
r.Get("/healthz", deps.Health.Live)
r.Get("/readyz", deps.Health.Ready)
// OpenAPI 3.1.1 spec + Redoc documentation UI.
r.Get("/openapi.yaml", deps.openAPISpec)
r.Get("/docs", docsUI)
// Domain routes. Add future feature routers here.
r.Route("/categories", deps.Categories.Register)
r.Route("/datasets", deps.Datasets.Register)
return r
}

View File

@@ -0,0 +1,59 @@
// Package http wires the chi router and runs the HTTP server with graceful
// shutdown.
package http
import (
"context"
"errors"
"log/slog"
"net/http"
"gis/internal/config"
)
// Server runs the application's HTTP server.
type Server struct {
srv *http.Server
shutdownTimeout config.HTTPConfig
log *slog.Logger
}
// NewServer builds an *http.Server from the config and handler.
func NewServer(cfg config.HTTPConfig, handler http.Handler, log *slog.Logger) *Server {
return &Server{
srv: &http.Server{
Addr: cfg.Addr(),
Handler: handler,
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
},
shutdownTimeout: cfg,
log: log,
}
}
// Run starts serving and blocks until ctx is cancelled, then shuts down
// gracefully within the configured timeout.
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.log.Info("http server listening", "addr", s.srv.Addr)
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout.ShutdownTimeout)
defer cancel()
s.log.Info("http server shutting down")
return s.srv.Shutdown(shutdownCtx)
}
}