Second checkpoint
This commit is contained in:
173
internal/transport/http/category_handler.go
Normal file
173
internal/transport/http/category_handler.go
Normal 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
|
||||
}
|
||||
313
internal/transport/http/dataset_handler.go
Normal file
313
internal/transport/http/dataset_handler.go
Normal 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)
|
||||
}
|
||||
24
internal/transport/http/errors.go
Normal file
24
internal/transport/http/errors.go
Normal 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")
|
||||
}
|
||||
}
|
||||
50
internal/transport/http/health.go
Normal file
50
internal/transport/http/health.go
Normal 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,
|
||||
})
|
||||
}
|
||||
33
internal/transport/http/middleware.go
Normal file
33
internal/transport/http/middleware.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
29
internal/transport/http/pagination.go
Normal file
29
internal/transport/http/pagination.go
Normal 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,
|
||||
}
|
||||
}
|
||||
42
internal/transport/http/router.go
Normal file
42
internal/transport/http/router.go
Normal 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
|
||||
}
|
||||
59
internal/transport/http/server.go
Normal file
59
internal/transport/http/server.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user