diff --git a/api/openapi.yaml b/api/openapi.yaml index 7881e0a..1ea78b6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -268,6 +268,51 @@ paths: schema: { $ref: "#/components/schemas/Dataset" } "400": { $ref: "#/components/responses/BadRequest" } "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: tags: [Datasets] summary: Delete a dataset @@ -572,6 +617,41 @@ components: type: string 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: type: object required: [name] diff --git a/internal/repository/postgres/dataset.go b/internal/repository/postgres/dataset.go index ae90946..08695fe 100644 --- a/internal/repository/postgres/dataset.go +++ b/internal/repository/postgres/dataset.go @@ -68,6 +68,23 @@ func (r *DatasetRepository) Create(ctx context.Context, d domain.Dataset) (domai 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 // awaiting_mapping, clearing any previous parse error. func (r *DatasetRepository) MarkParsed(ctx context.Context, id uuid.UUID, cols []domain.AttributeColumn) error { diff --git a/internal/service/dataset.go b/internal/service/dataset.go index 7f0a76b..80c8d83 100644 --- a/internal/service/dataset.go +++ b/internal/service/dataset.go @@ -35,6 +35,7 @@ var ( type DatasetRepository interface { Create(ctx context.Context, d domain.Dataset) (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) Count(ctx context.Context, filter domain.DatasetFilter) (int, 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) - if _, err := s.categories.GetByID(ctx, in.CategoryID); err != nil { - if errors.Is(err, domain.ErrNotFound) { - return domain.Dataset{}, fmt.Errorf("%w: category does not exist", domain.ErrValidation) - } + if err := s.ensureCategoryExists(ctx, in.CategoryID); err != nil { 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) } +// 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. const ( DefaultStatusWait = 25 * time.Second diff --git a/internal/service/dataset_test.go b/internal/service/dataset_test.go index a3480f7..74aba7b 100644 --- a/internal/service/dataset_test.go +++ b/internal/service/dataset_test.go @@ -56,6 +56,19 @@ func (r *stubDatasetRepo) GetByID(_ context.Context, id uuid.UUID) (domain.Datas 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) { r.lastFilter = filter r.lastLimit = limit @@ -1298,3 +1311,121 @@ func TestDatasetService_Delete_RemovesObject(t *testing.T) { 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) + } +} diff --git a/internal/transport/http/dataset_handler.go b/internal/transport/http/dataset_handler.go index 254919f..db3949a 100644 --- a/internal/transport/http/dataset_handler.go +++ b/internal/transport/http/dataset_handler.go @@ -37,6 +37,8 @@ func (h *DatasetHandler) Register(r chi.Router) { r.Get("/", h.list) r.Post("/", h.upload) r.Get("/{id}", h.get) + r.Put("/{id}", h.update) + r.Patch("/{id}", h.patch) r.Get("/{id}.geojson", h.geojson) r.Get("/{id}.cog", h.cog) 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) } +// 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 // 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