feat: Add PUT and PATCH datasets endpoints
This commit is contained in:
@@ -268,6 +268,51 @@ paths:
|
|||||||
schema: { $ref: "#/components/schemas/Dataset" }
|
schema: { $ref: "#/components/schemas/Dataset" }
|
||||||
"400": { $ref: "#/components/responses/BadRequest" }
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
put:
|
||||||
|
tags: [Datasets]
|
||||||
|
summary: Replace a dataset's editable metadata
|
||||||
|
description: |
|
||||||
|
Updates category, name, description, and unit. Every field is
|
||||||
|
required; the file, file type, and processing/lifecycle state are
|
||||||
|
never touched here. Sending an empty string for `description` or
|
||||||
|
`unit` clears that (optional) field.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/DatasetPut" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Dataset" }
|
||||||
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
patch:
|
||||||
|
tags: [Datasets]
|
||||||
|
summary: Partially update a dataset's editable metadata
|
||||||
|
description: |
|
||||||
|
Updates only the fields present in the body, from category, name,
|
||||||
|
description, and unit. The file, file type, and processing/lifecycle
|
||||||
|
state are never touched here. Sending an explicit `null` for
|
||||||
|
`description` or `unit` clears that (optional) field; omitting a
|
||||||
|
field leaves it unchanged.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/DatasetPatch" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Dataset" }
|
||||||
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
delete:
|
delete:
|
||||||
tags: [Datasets]
|
tags: [Datasets]
|
||||||
summary: Delete a dataset
|
summary: Delete a dataset
|
||||||
@@ -572,6 +617,41 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
maxLength: 2000
|
maxLength: 2000
|
||||||
|
|
||||||
|
DatasetPut:
|
||||||
|
type: object
|
||||||
|
required: [category_id, name, description, unit]
|
||||||
|
properties:
|
||||||
|
category_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
maxLength: 255
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: An empty string clears the field.
|
||||||
|
unit:
|
||||||
|
type: string
|
||||||
|
description: An empty string clears the field.
|
||||||
|
|
||||||
|
DatasetPatch:
|
||||||
|
type: object
|
||||||
|
description: At least one field is required. Omitted fields are left unchanged.
|
||||||
|
minProperties: 1
|
||||||
|
properties:
|
||||||
|
category_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
maxLength: 255
|
||||||
|
description:
|
||||||
|
type: [string, "null"]
|
||||||
|
description: null clears the field.
|
||||||
|
unit:
|
||||||
|
type: [string, "null"]
|
||||||
|
description: null clears the field.
|
||||||
|
|
||||||
AttributeColumn:
|
AttributeColumn:
|
||||||
type: object
|
type: object
|
||||||
required: [name]
|
required: [name]
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ func (r *DatasetRepository) Create(ctx context.Context, d domain.Dataset) (domai
|
|||||||
return out, mapError(err)
|
return out, mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateFields overwrites a dataset's editable metadata columns (category,
|
||||||
|
// name, description, unit). description and unit are nullable: a nil pointer
|
||||||
|
// clears the column. Returns domain.ErrNotFound if the dataset does not
|
||||||
|
// exist, or domain.ErrConflict if categoryID does not reference an existing
|
||||||
|
// category.
|
||||||
|
func (r *DatasetRepository) UpdateFields(ctx context.Context, id, categoryID uuid.UUID, name string, description, unit *string) (domain.Dataset, error) {
|
||||||
|
row := r.pool.QueryRow(ctx,
|
||||||
|
`UPDATE datasets
|
||||||
|
SET category_id = $2, name = $3, description = $4, unit = $5, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+datasetColumns,
|
||||||
|
id, categoryID, name, description, unit,
|
||||||
|
)
|
||||||
|
out, err := scanDataset(row)
|
||||||
|
return out, mapError(err)
|
||||||
|
}
|
||||||
|
|
||||||
// MarkParsed stores the detected attribute columns and moves the dataset to
|
// MarkParsed stores the detected attribute columns and moves the dataset to
|
||||||
// awaiting_mapping, clearing any previous parse error.
|
// awaiting_mapping, clearing any previous parse error.
|
||||||
func (r *DatasetRepository) MarkParsed(ctx context.Context, id uuid.UUID, cols []domain.AttributeColumn) error {
|
func (r *DatasetRepository) MarkParsed(ctx context.Context, id uuid.UUID, cols []domain.AttributeColumn) error {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ var (
|
|||||||
type DatasetRepository interface {
|
type DatasetRepository interface {
|
||||||
Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error)
|
Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error)
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (domain.Dataset, error)
|
GetByID(ctx context.Context, id uuid.UUID) (domain.Dataset, error)
|
||||||
|
UpdateFields(ctx context.Context, id, categoryID uuid.UUID, name string, description, unit *string) (domain.Dataset, error)
|
||||||
ListSummaries(ctx context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error)
|
ListSummaries(ctx context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error)
|
||||||
Count(ctx context.Context, filter domain.DatasetFilter) (int, error)
|
Count(ctx context.Context, filter domain.DatasetFilter) (int, error)
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
@@ -183,10 +184,7 @@ func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dat
|
|||||||
}
|
}
|
||||||
content := io.MultiReader(bytes.NewReader(head), in.Reader)
|
content := io.MultiReader(bytes.NewReader(head), in.Reader)
|
||||||
|
|
||||||
if _, err := s.categories.GetByID(ctx, in.CategoryID); err != nil {
|
if err := s.ensureCategoryExists(ctx, in.CategoryID); err != nil {
|
||||||
if errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return domain.Dataset{}, fmt.Errorf("%w: category does not exist", domain.ErrValidation)
|
|
||||||
}
|
|
||||||
return domain.Dataset{}, err
|
return domain.Dataset{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,6 +944,81 @@ func (s *DatasetService) Get(ctx context.Context, id uuid.UUID) (domain.Dataset,
|
|||||||
return s.repo.GetByID(ctx, id)
|
return s.repo.GetByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DatasetUpdateInput carries the full set of editable metadata fields for a
|
||||||
|
// PUT replace. Description and Unit being nil clears those (nullable)
|
||||||
|
// columns.
|
||||||
|
type DatasetUpdateInput struct {
|
||||||
|
CategoryID uuid.UUID
|
||||||
|
Name string
|
||||||
|
Description *string
|
||||||
|
Unit *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update replaces a dataset's editable metadata (category, name, description,
|
||||||
|
// unit). File-related and lifecycle fields are untouched.
|
||||||
|
func (s *DatasetService) Update(ctx context.Context, id uuid.UUID, in DatasetUpdateInput) (domain.Dataset, error) {
|
||||||
|
if err := s.ensureCategoryExists(ctx, in.CategoryID); err != nil {
|
||||||
|
return domain.Dataset{}, err
|
||||||
|
}
|
||||||
|
return s.repo.UpdateFields(ctx, id, in.CategoryID, in.Name, in.Description, in.Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatasetPatchInput carries a sparse set of metadata field changes. A nil
|
||||||
|
// CategoryID/Name leaves that column unchanged. Description/Unit are only
|
||||||
|
// applied when their paired Set flag is true; a nil value with Set true
|
||||||
|
// clears the (nullable) column.
|
||||||
|
type DatasetPatchInput struct {
|
||||||
|
CategoryID *uuid.UUID
|
||||||
|
Name *string
|
||||||
|
Description *string
|
||||||
|
DescriptionSet bool
|
||||||
|
Unit *string
|
||||||
|
UnitSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch applies a sparse update to a dataset's editable metadata (category,
|
||||||
|
// name, description, unit). Fields absent from the input are left unchanged.
|
||||||
|
func (s *DatasetService) Patch(ctx context.Context, id uuid.UUID, in DatasetPatchInput) (domain.Dataset, error) {
|
||||||
|
current, err := s.repo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Dataset{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryID := current.CategoryID
|
||||||
|
if in.CategoryID != nil {
|
||||||
|
categoryID = *in.CategoryID
|
||||||
|
if err := s.ensureCategoryExists(ctx, categoryID); err != nil {
|
||||||
|
return domain.Dataset{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := current.Name
|
||||||
|
if in.Name != nil {
|
||||||
|
name = *in.Name
|
||||||
|
}
|
||||||
|
description := current.Description
|
||||||
|
if in.DescriptionSet {
|
||||||
|
description = in.Description
|
||||||
|
}
|
||||||
|
unit := current.Unit
|
||||||
|
if in.UnitSet {
|
||||||
|
unit = in.Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.UpdateFields(ctx, id, categoryID, name, description, unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureCategoryExists validates that categoryID references an existing
|
||||||
|
// category, mirroring the check performed on upload.
|
||||||
|
func (s *DatasetService) ensureCategoryExists(ctx context.Context, categoryID uuid.UUID) error {
|
||||||
|
if _, err := s.categories.GetByID(ctx, categoryID); err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return fmt.Errorf("%w: category does not exist", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Status-wait bounds and polling cadence for long polling.
|
// Status-wait bounds and polling cadence for long polling.
|
||||||
const (
|
const (
|
||||||
DefaultStatusWait = 25 * time.Second
|
DefaultStatusWait = 25 * time.Second
|
||||||
|
|||||||
@@ -56,6 +56,19 @@ func (r *stubDatasetRepo) GetByID(_ context.Context, id uuid.UUID) (domain.Datas
|
|||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stubDatasetRepo) UpdateFields(_ context.Context, id, categoryID uuid.UUID, name string, description, unit *string) (domain.Dataset, error) {
|
||||||
|
d, ok := r.store[id]
|
||||||
|
if !ok {
|
||||||
|
return domain.Dataset{}, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
d.CategoryID = categoryID
|
||||||
|
d.Name = name
|
||||||
|
d.Description = description
|
||||||
|
d.Unit = unit
|
||||||
|
r.store[id] = d
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *stubDatasetRepo) ListSummaries(_ context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error) {
|
func (r *stubDatasetRepo) ListSummaries(_ context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error) {
|
||||||
r.lastFilter = filter
|
r.lastFilter = filter
|
||||||
r.lastLimit = limit
|
r.lastLimit = limit
|
||||||
@@ -1298,3 +1311,121 @@ func TestDatasetService_Delete_RemovesObject(t *testing.T) {
|
|||||||
t.Fatalf("object not removed: %v", store.removed)
|
t.Fatalf("object not removed: %v", store.removed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string { return &s }
|
||||||
|
|
||||||
|
func TestDatasetService_Update_Success(t *testing.T) {
|
||||||
|
repo := newStubDatasetRepo()
|
||||||
|
id := uuid.New()
|
||||||
|
repo.store[id] = domain.Dataset{ID: id, Name: "Old", Description: strPtr("old desc"), Unit: strPtr("old unit")}
|
||||||
|
svc := newDatasetService(repo, &stubStore{}, true)
|
||||||
|
|
||||||
|
newCategory := uuid.New()
|
||||||
|
got, err := svc.Update(context.Background(), id, DatasetUpdateInput{
|
||||||
|
CategoryID: newCategory,
|
||||||
|
Name: "New",
|
||||||
|
Description: strPtr("new desc"),
|
||||||
|
Unit: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got.CategoryID != newCategory || got.Name != "New" {
|
||||||
|
t.Fatalf("update not applied: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Description == nil || *got.Description != "new desc" {
|
||||||
|
t.Fatalf("description not updated: %v", got.Description)
|
||||||
|
}
|
||||||
|
if got.Unit != nil {
|
||||||
|
t.Fatalf("unit should be cleared, got %v", *got.Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetService_Update_MissingCategory(t *testing.T) {
|
||||||
|
repo := newStubDatasetRepo()
|
||||||
|
id := uuid.New()
|
||||||
|
repo.store[id] = domain.Dataset{ID: id, Name: "Old"}
|
||||||
|
svc := newDatasetService(repo, &stubStore{}, false)
|
||||||
|
|
||||||
|
_, err := svc.Update(context.Background(), id, DatasetUpdateInput{CategoryID: uuid.New(), Name: "New"})
|
||||||
|
if !errors.Is(err, domain.ErrValidation) {
|
||||||
|
t.Fatalf("want ErrValidation, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetService_Update_NotFound(t *testing.T) {
|
||||||
|
repo := newStubDatasetRepo()
|
||||||
|
svc := newDatasetService(repo, &stubStore{}, true)
|
||||||
|
|
||||||
|
_, err := svc.Update(context.Background(), uuid.New(), DatasetUpdateInput{CategoryID: uuid.New(), Name: "New"})
|
||||||
|
if !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Fatalf("want ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetService_Patch_PartialUpdate(t *testing.T) {
|
||||||
|
repo := newStubDatasetRepo()
|
||||||
|
id := uuid.New()
|
||||||
|
origCategory := uuid.New()
|
||||||
|
repo.store[id] = domain.Dataset{
|
||||||
|
ID: id, CategoryID: origCategory, Name: "Old",
|
||||||
|
Description: strPtr("old desc"), Unit: strPtr("old unit"),
|
||||||
|
}
|
||||||
|
svc := newDatasetService(repo, &stubStore{}, true)
|
||||||
|
|
||||||
|
// Only Name is set; everything else must be left unchanged.
|
||||||
|
got, err := svc.Patch(context.Background(), id, DatasetPatchInput{Name: strPtr("New")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "New" {
|
||||||
|
t.Fatalf("name not updated: %+v", got)
|
||||||
|
}
|
||||||
|
if got.CategoryID != origCategory {
|
||||||
|
t.Fatalf("category should be unchanged, got %v", got.CategoryID)
|
||||||
|
}
|
||||||
|
if got.Description == nil || *got.Description != "old desc" {
|
||||||
|
t.Fatalf("description should be unchanged, got %v", got.Description)
|
||||||
|
}
|
||||||
|
if got.Unit == nil || *got.Unit != "old unit" {
|
||||||
|
t.Fatalf("unit should be unchanged, got %v", got.Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetService_Patch_ClearsNullableField(t *testing.T) {
|
||||||
|
repo := newStubDatasetRepo()
|
||||||
|
id := uuid.New()
|
||||||
|
repo.store[id] = domain.Dataset{ID: id, Name: "Old", Description: strPtr("old desc")}
|
||||||
|
svc := newDatasetService(repo, &stubStore{}, true)
|
||||||
|
|
||||||
|
got, err := svc.Patch(context.Background(), id, DatasetPatchInput{DescriptionSet: true, Description: nil})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Description != nil {
|
||||||
|
t.Fatalf("description should be cleared, got %v", *got.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetService_Patch_MissingCategory(t *testing.T) {
|
||||||
|
repo := newStubDatasetRepo()
|
||||||
|
id := uuid.New()
|
||||||
|
repo.store[id] = domain.Dataset{ID: id, Name: "Old"}
|
||||||
|
svc := newDatasetService(repo, &stubStore{}, false)
|
||||||
|
|
||||||
|
newCategory := uuid.New()
|
||||||
|
_, err := svc.Patch(context.Background(), id, DatasetPatchInput{CategoryID: &newCategory})
|
||||||
|
if !errors.Is(err, domain.ErrValidation) {
|
||||||
|
t.Fatalf("want ErrValidation, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetService_Patch_NotFound(t *testing.T) {
|
||||||
|
repo := newStubDatasetRepo()
|
||||||
|
svc := newDatasetService(repo, &stubStore{}, true)
|
||||||
|
|
||||||
|
_, err := svc.Patch(context.Background(), uuid.New(), DatasetPatchInput{Name: strPtr("New")})
|
||||||
|
if !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Fatalf("want ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func (h *DatasetHandler) Register(r chi.Router) {
|
|||||||
r.Get("/", h.list)
|
r.Get("/", h.list)
|
||||||
r.Post("/", h.upload)
|
r.Post("/", h.upload)
|
||||||
r.Get("/{id}", h.get)
|
r.Get("/{id}", h.get)
|
||||||
|
r.Put("/{id}", h.update)
|
||||||
|
r.Patch("/{id}", h.patch)
|
||||||
r.Get("/{id}.geojson", h.geojson)
|
r.Get("/{id}.geojson", h.geojson)
|
||||||
r.Get("/{id}.cog", h.cog)
|
r.Get("/{id}.cog", h.cog)
|
||||||
r.Get("/{id}/status", h.status)
|
r.Get("/{id}/status", h.status)
|
||||||
@@ -306,6 +308,150 @@ func (h *DatasetHandler) get(w http.ResponseWriter, r *http.Request) {
|
|||||||
httputil.WriteJSON(w, http.StatusOK, dataset)
|
httputil.WriteJSON(w, http.StatusOK, dataset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// datasetPutRequest is the full JSON body for PUT /datasets/{id}: every
|
||||||
|
// editable field is required. Description and Unit are pointers so an empty
|
||||||
|
// string is distinguishable from an absent field; a blank value clears the
|
||||||
|
// (nullable) column. File-related and lifecycle fields are not editable here.
|
||||||
|
type datasetPutRequest struct {
|
||||||
|
CategoryID string `json:"category_id" validate:"required,uuid"`
|
||||||
|
Name string `json:"name" validate:"required,max=255"`
|
||||||
|
Description *string `json:"description" validate:"required"`
|
||||||
|
Unit *string `json:"unit" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// update replaces a dataset's editable metadata (category, name, description,
|
||||||
|
// unit).
|
||||||
|
func (h *DatasetHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUIDParam(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := httputil.DecodeJSON[datasetPutRequest](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
|
||||||
|
}
|
||||||
|
categoryID, err := uuid.Parse(req.CategoryID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "category_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataset, err := h.svc.Update(r.Context(), id, service.DatasetUpdateInput{
|
||||||
|
CategoryID: categoryID,
|
||||||
|
Name: strings.TrimSpace(req.Name),
|
||||||
|
Description: normalizeOptional(*req.Description),
|
||||||
|
Unit: normalizeOptional(*req.Unit),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, dataset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// datasetPatchFields lists the JSON keys accepted by PATCH /datasets/{id}.
|
||||||
|
var datasetPatchFields = map[string]struct{}{
|
||||||
|
"category_id": {}, "name": {}, "description": {}, "unit": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// patch applies a sparse update to a dataset's editable metadata (category,
|
||||||
|
// name, description, unit): only fields present in the body are changed.
|
||||||
|
// Sending an explicit null for description/unit clears that column.
|
||||||
|
func (h *DatasetHandler) patch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUIDParam(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := httputil.DecodeJSON[map[string]json.RawMessage](w, r)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, "request body must include at least one field")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in service.DatasetPatchInput
|
||||||
|
for key, val := range raw {
|
||||||
|
if _, ok := datasetPatchFields[key]; !ok {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "unknown field: "+key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "category_id":
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(val, &s); err != nil || s == "" {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "category_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cid, err := uuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "category_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.CategoryID = &cid
|
||||||
|
case "name":
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(val, &s); err != nil || strings.TrimSpace(s) == "" {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "name must not be blank")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(s)
|
||||||
|
in.Name = &name
|
||||||
|
case "description":
|
||||||
|
desc, err := decodeOptionalPatchString(val)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "description must be a string or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Description, in.DescriptionSet = desc, true
|
||||||
|
case "unit":
|
||||||
|
unit, err := decodeOptionalPatchString(val)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "unit must be a string or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Unit, in.UnitSet = unit, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataset, err := h.svc.Patch(r.Context(), id, in)
|
||||||
|
if err != nil {
|
||||||
|
respondDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, dataset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeOptional trims s and returns nil when blank, so an empty string
|
||||||
|
// clears a nullable column rather than being stored verbatim.
|
||||||
|
func normalizeOptional(s string) *string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeOptionalPatchString unmarshals a PATCH field that may be a JSON string
|
||||||
|
// or null, returning nil (clear the column) for null or a blank string.
|
||||||
|
func decodeOptionalPatchString(raw json.RawMessage) (*string, error) {
|
||||||
|
if string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalizeOptional(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
// geojson returns the dataset's pre-assembled GeoJSON FeatureCollection (RFC
|
// geojson returns the dataset's pre-assembled GeoJSON FeatureCollection (RFC
|
||||||
// 7946), generated and stored at processing time. A vector_with_kato dataset's
|
// 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
|
// collection joins the districts table on KATO code (one feature per KATO with
|
||||||
|
|||||||
Reference in New Issue
Block a user