fix: Code for category

This commit is contained in:
Bakhtiyar Issakhmetov 2026-06-27 19:35:53 +05:00
parent 46add88fd5
commit 148d1fb841
14 changed files with 127 additions and 64 deletions

View File

@ -160,10 +160,18 @@ paths:
- name: category_id - name: category_id
in: query in: query
required: false required: false
description: Filter to a category. description: Filter to a category by id.
schema: schema:
type: string type: string
format: uuid format: uuid
- name: category_code
in: query
required: false
description: >-
Filter to a category by its code (slug). An unknown code yields an
empty page.
schema:
type: string
responses: responses:
"200": "200":
description: A page of dataset summaries description: A page of dataset summaries
@ -187,7 +195,7 @@ paths:
multipart/form-data: multipart/form-data:
schema: schema:
type: object type: object
required: [file, file_type, category_id, code] required: [file, file_type, category_id]
properties: properties:
file: file:
type: string type: string
@ -198,9 +206,6 @@ paths:
category_id: category_id:
type: string type: string
format: uuid format: uuid
code:
type: string
description: Business code.
name: name:
type: string type: string
description: Display name; defaults to the filename if omitted. description: Display name; defaults to the filename if omitted.
@ -441,7 +446,7 @@ components:
Category: Category:
type: object type: object
required: [id, name, description, created_at, updated_at] required: [id, code, name, description, created_at, updated_at]
properties: properties:
id: id:
type: string type: string
@ -449,6 +454,9 @@ components:
parent_id: parent_id:
type: [string, "null"] type: [string, "null"]
format: uuid format: uuid
code:
type: string
description: Slug (lowercase latin letters, digits, and dashes).
name: name:
type: string type: string
description: description:
@ -462,11 +470,16 @@ components:
CategoryInput: CategoryInput:
type: object type: object
required: [name] required: [code, name]
properties: properties:
parent_id: parent_id:
type: [string, "null"] type: [string, "null"]
format: uuid format: uuid
code:
type: string
maxLength: 255
pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$"
description: Slug (lowercase latin letters, digits, and dashes).
name: name:
type: string type: string
maxLength: 255 maxLength: 255
@ -505,7 +518,7 @@ components:
DatasetSummary: DatasetSummary:
type: object type: object
required: [id, category_id, code, name, file_type, size_bytes, status, created_at, updated_at] required: [id, category_id, name, file_type, size_bytes, status, created_at, updated_at]
properties: properties:
id: id:
type: string type: string
@ -513,8 +526,6 @@ components:
category_id: category_id:
type: string type: string
format: uuid format: uuid
code:
type: string
name: name:
type: string type: string
description: description:
@ -538,7 +549,7 @@ components:
Dataset: Dataset:
type: object type: object
required: required:
[id, category_id, code, name, filename, storage_key, file_type, [id, category_id, name, filename, storage_key, file_type,
size_bytes, content_type, automated, status, created_at, updated_at] size_bytes, content_type, automated, status, created_at, updated_at]
properties: properties:
id: id:
@ -547,8 +558,6 @@ components:
category_id: category_id:
type: string type: string
format: uuid format: uuid
code:
type: string
name: name:
type: string type: string
description: description:

View File

@ -11,6 +11,7 @@ import (
"gis/api" "gis/api"
"gis/internal/config" "gis/internal/config"
"gis/internal/domain"
"gis/internal/messaging/rabbitmq" "gis/internal/messaging/rabbitmq"
"gis/internal/parser" "gis/internal/parser"
"gis/internal/platform/logger" "gis/internal/platform/logger"
@ -87,6 +88,9 @@ func New(ctx context.Context) (*App, error) {
// Handler builds the HTTP handler with all routes and readiness checks wired. // Handler builds the HTTP handler with all routes and readiness checks wired.
func (a *App) Handler() stdhttp.Handler { func (a *App) Handler() stdhttp.Handler {
validate := validator.New(validator.WithRequiredStructEnabled()) validate := validator.New(validator.WithRequiredStructEnabled())
validate.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
return domain.ValidSlug(fl.Field().String())
})
health := transporthttp.NewHealthHandler(map[string]transporthttp.ReadinessCheck{ health := transporthttp.NewHealthHandler(map[string]transporthttp.ReadinessCheck{
"postgres": func(ctx context.Context) error { return a.pool.Ping(ctx) }, "postgres": func(ctx context.Context) error { return a.pool.Ping(ctx) },

View File

@ -1,6 +1,7 @@
package domain package domain
import ( import (
"regexp"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -11,8 +12,19 @@ import (
type Category struct { type Category struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ParentID *uuid.UUID `json:"parent_id"` ParentID *uuid.UUID `json:"parent_id"`
Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// slugPattern matches a slug: lowercase latin letters and digits in
// dash-separated groups, e.g. "population", "land-use-2024".
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// ValidSlug reports whether s is a valid slug (lowercase latin letters, digits,
// and single dashes between groups; no leading, trailing, or repeated dashes).
func ValidSlug(s string) bool {
return slugPattern.MatchString(s)
}

View File

@ -145,7 +145,6 @@ type YearColumn struct {
type DatasetSummary struct { type DatasetSummary struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
Unit *string `json:"unit"` Unit *string `json:"unit"`
@ -160,7 +159,6 @@ type DatasetSummary struct {
type Dataset struct { type Dataset struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
Unit *string `json:"unit"` Unit *string `json:"unit"`

View File

@ -20,21 +20,21 @@ func NewCategoryRepository(pool *pgxpool.Pool) *CategoryRepository {
return &CategoryRepository{pool: pool} return &CategoryRepository{pool: pool}
} }
const categoryColumns = `id, parent_id, name, description, created_at, updated_at` const categoryColumns = `id, parent_id, code, name, description, created_at, updated_at`
func scanCategory(row pgx.Row) (domain.Category, error) { func scanCategory(row pgx.Row) (domain.Category, error) {
var c domain.Category var c domain.Category
err := row.Scan(&c.ID, &c.ParentID, &c.Name, &c.Description, &c.CreatedAt, &c.UpdatedAt) err := row.Scan(&c.ID, &c.ParentID, &c.Code, &c.Name, &c.Description, &c.CreatedAt, &c.UpdatedAt)
return c, err return c, err
} }
// Create inserts a new category and returns the stored row. // Create inserts a new category and returns the stored row.
func (r *CategoryRepository) Create(ctx context.Context, c domain.Category) (domain.Category, error) { func (r *CategoryRepository) Create(ctx context.Context, c domain.Category) (domain.Category, error) {
row := r.pool.QueryRow(ctx, row := r.pool.QueryRow(ctx,
`INSERT INTO categories (parent_id, name, description) `INSERT INTO categories (parent_id, code, name, description)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING `+categoryColumns, RETURNING `+categoryColumns,
c.ParentID, c.Name, c.Description, c.ParentID, c.Code, c.Name, c.Description,
) )
out, err := scanCategory(row) out, err := scanCategory(row)
return out, mapError(err) return out, mapError(err)
@ -48,6 +48,14 @@ func (r *CategoryRepository) GetByID(ctx context.Context, id uuid.UUID) (domain.
return out, mapError(err) return out, mapError(err)
} }
// GetByCode returns the category with the given code, or domain.ErrNotFound.
func (r *CategoryRepository) GetByCode(ctx context.Context, code string) (domain.Category, error) {
row := r.pool.QueryRow(ctx,
`SELECT `+categoryColumns+` FROM categories WHERE code = $1`, code)
out, err := scanCategory(row)
return out, mapError(err)
}
// List returns categories ordered by name. When parentID is non-nil it filters // List returns categories ordered by name. When parentID is non-nil it filters
// to that parent's direct children; otherwise it returns all categories. // to that parent's direct children; otherwise it returns all categories.
func (r *CategoryRepository) List(ctx context.Context, parentID *uuid.UUID) ([]domain.Category, error) { func (r *CategoryRepository) List(ctx context.Context, parentID *uuid.UUID) ([]domain.Category, error) {
@ -82,10 +90,10 @@ func (r *CategoryRepository) List(ctx context.Context, parentID *uuid.UUID) ([]d
func (r *CategoryRepository) Update(ctx context.Context, c domain.Category) (domain.Category, error) { func (r *CategoryRepository) Update(ctx context.Context, c domain.Category) (domain.Category, error) {
row := r.pool.QueryRow(ctx, row := r.pool.QueryRow(ctx,
`UPDATE categories `UPDATE categories
SET parent_id = $2, name = $3, description = $4, updated_at = now() SET parent_id = $2, code = $3, name = $4, description = $5, updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING `+categoryColumns, RETURNING `+categoryColumns,
c.ID, c.ParentID, c.Name, c.Description, c.ID, c.ParentID, c.Code, c.Name, c.Description,
) )
out, err := scanCategory(row) out, err := scanCategory(row)
return out, mapError(err) return out, mapError(err)

View File

@ -26,7 +26,7 @@ func NewDatasetRepository(pool *pgxpool.Pool) *DatasetRepository {
// datasetColumns lists the dataset columns for SELECT and RETURNING. The // datasetColumns lists the dataset columns for SELECT and RETURNING. The
// geometry is exposed as GeoJSON (jsonb) rather than its raw EWKB form, and a // geometry is exposed as GeoJSON (jsonb) rather than its raw EWKB form, and a
// bounding box array is derived for raster datasets only. // bounding box array is derived for raster datasets only.
const datasetColumns = `id, category_id, code, name, description, unit, filename, storage_key, cog_storage_key, file_type, size_bytes, content_type, properties, meta, automated, status, attribute_columns, kato_column, year_columns, parse_error, ST_AsGeoJSON(geometry)::jsonb AS geometry, const datasetColumns = `id, category_id, name, description, unit, filename, storage_key, cog_storage_key, file_type, size_bytes, content_type, properties, meta, automated, status, attribute_columns, kato_column, year_columns, parse_error, ST_AsGeoJSON(geometry)::jsonb AS geometry,
CASE WHEN file_type = 'raster' AND geometry IS NOT NULL CASE WHEN file_type = 'raster' AND geometry IS NOT NULL
THEN ARRAY[ST_XMin(geometry), ST_YMin(geometry), ST_XMax(geometry), ST_YMax(geometry)] THEN ARRAY[ST_XMin(geometry), ST_YMin(geometry), ST_XMax(geometry), ST_YMax(geometry)]
ELSE NULL END AS bbox, ELSE NULL END AS bbox,
@ -35,7 +35,7 @@ const datasetColumns = `id, category_id, code, name, description, unit, filename
func scanDataset(row pgx.Row) (domain.Dataset, error) { func scanDataset(row pgx.Row) (domain.Dataset, error) {
var d domain.Dataset var d domain.Dataset
err := row.Scan( err := row.Scan(
&d.ID, &d.CategoryID, &d.Code, &d.Name, &d.Description, &d.Unit, &d.ID, &d.CategoryID, &d.Name, &d.Description, &d.Unit,
&d.Filename, &d.StorageKey, &d.CogStorageKey, &d.FileType, &d.SizeBytes, &d.ContentType, &d.Filename, &d.StorageKey, &d.CogStorageKey, &d.FileType, &d.SizeBytes, &d.ContentType,
&d.Properties, &d.Meta, &d.Automated, &d.Status, &d.Properties, &d.Meta, &d.Automated, &d.Status,
&d.AttributeColumns, &d.KatoColumn, &d.YearColumns, &d.ParseError, &d.AttributeColumns, &d.KatoColumn, &d.YearColumns, &d.ParseError,
@ -56,10 +56,10 @@ func nullableJSON(raw json.RawMessage) any {
// Create inserts a new dataset and returns the stored row. // Create inserts a new dataset and returns the stored row.
func (r *DatasetRepository) Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error) { func (r *DatasetRepository) Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error) {
row := r.pool.QueryRow(ctx, row := r.pool.QueryRow(ctx,
`INSERT INTO datasets (category_id, code, name, description, unit, filename, storage_key, file_type, size_bytes, content_type, properties, meta, automated, status) `INSERT INTO datasets (category_id, name, description, unit, filename, storage_key, file_type, size_bytes, content_type, properties, meta, automated, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING `+datasetColumns, RETURNING `+datasetColumns,
d.CategoryID, d.Code, d.Name, d.Description, d.Unit, d.Filename, d.StorageKey, d.FileType, d.SizeBytes, d.ContentType, d.CategoryID, d.Name, d.Description, d.Unit, d.Filename, d.StorageKey, d.FileType, d.SizeBytes, d.ContentType,
nullableJSON(d.Properties), nullableJSON(d.Meta), d.Automated, d.Status, nullableJSON(d.Properties), nullableJSON(d.Meta), d.Automated, d.Status,
) )
out, err := scanDataset(row) out, err := scanDataset(row)
@ -269,12 +269,12 @@ func (r *DatasetRepository) GetByID(ctx context.Context, id uuid.UUID) (domain.D
return out, mapError(err) return out, mapError(err)
} }
const datasetSummaryColumns = `id, category_id, code, name, description, unit, file_type, size_bytes, status, created_at, updated_at` const datasetSummaryColumns = `id, category_id, name, description, unit, file_type, size_bytes, status, created_at, updated_at`
func scanDatasetSummary(row pgx.Row) (domain.DatasetSummary, error) { func scanDatasetSummary(row pgx.Row) (domain.DatasetSummary, error) {
var d domain.DatasetSummary var d domain.DatasetSummary
err := row.Scan( err := row.Scan(
&d.ID, &d.CategoryID, &d.Code, &d.Name, &d.Description, &d.Unit, &d.ID, &d.CategoryID, &d.Name, &d.Description, &d.Unit,
&d.FileType, &d.SizeBytes, &d.Status, &d.CreatedAt, &d.UpdatedAt, &d.FileType, &d.SizeBytes, &d.Status, &d.CreatedAt, &d.UpdatedAt,
) )
return d, err return d, err

View File

@ -25,6 +25,7 @@ type CategoryRepository interface {
// CategoryInput carries the mutable fields of a category. // CategoryInput carries the mutable fields of a category.
type CategoryInput struct { type CategoryInput struct {
ParentID *uuid.UUID ParentID *uuid.UUID
Code string
Name string Name string
Description string Description string
} }
@ -39,13 +40,17 @@ func NewCategoryService(repo CategoryRepository) *CategoryService {
return &CategoryService{repo: repo} return &CategoryService{repo: repo}
} }
// Create validates the parent (if any) and stores a new category. // Create validates the code and parent (if any) and stores a new category.
func (s *CategoryService) Create(ctx context.Context, in CategoryInput) (domain.Category, error) { func (s *CategoryService) Create(ctx context.Context, in CategoryInput) (domain.Category, error) {
if !domain.ValidSlug(in.Code) {
return domain.Category{}, fmt.Errorf("%w: code must be a slug (lowercase latin letters, digits, and dashes)", domain.ErrValidation)
}
if err := s.ensureParentExists(ctx, in.ParentID); err != nil { if err := s.ensureParentExists(ctx, in.ParentID); err != nil {
return domain.Category{}, err return domain.Category{}, err
} }
return s.repo.Create(ctx, domain.Category{ return s.repo.Create(ctx, domain.Category{
ParentID: in.ParentID, ParentID: in.ParentID,
Code: in.Code,
Name: in.Name, Name: in.Name,
Description: in.Description, Description: in.Description,
}) })
@ -61,8 +66,11 @@ func (s *CategoryService) List(ctx context.Context, parentID *uuid.UUID) ([]doma
return s.repo.List(ctx, parentID) return s.repo.List(ctx, parentID)
} }
// Update validates the parent change (existence + no cycles) and stores it. // Update validates the code and parent change (existence + no cycles) and stores it.
func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, in CategoryInput) (domain.Category, error) { func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, in CategoryInput) (domain.Category, error) {
if !domain.ValidSlug(in.Code) {
return domain.Category{}, fmt.Errorf("%w: code must be a slug (lowercase latin letters, digits, and dashes)", domain.ErrValidation)
}
if _, err := s.repo.GetByID(ctx, id); err != nil { if _, err := s.repo.GetByID(ctx, id); err != nil {
return domain.Category{}, err return domain.Category{}, err
} }
@ -75,6 +83,7 @@ func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, in CategoryI
return s.repo.Update(ctx, domain.Category{ return s.repo.Update(ctx, domain.Category{
ID: id, ID: id,
ParentID: in.ParentID, ParentID: in.ParentID,
Code: in.Code,
Name: in.Name, Name: in.Name,
Description: in.Description, Description: in.Description,
}) })

View File

@ -54,7 +54,7 @@ func TestCategoryService_Create(t *testing.T) {
t.Run("root category succeeds", func(t *testing.T) { t.Run("root category succeeds", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo()) svc := NewCategoryService(newStubCategoryRepo())
got, err := svc.Create(ctx, CategoryInput{Name: "root"}) got, err := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -63,10 +63,19 @@ func TestCategoryService_Create(t *testing.T) {
} }
}) })
t.Run("invalid code is a validation error", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
for _, code := range []string{"", "Root", "with space", "-leading", "double--dash"} {
if _, err := svc.Create(ctx, CategoryInput{Code: code, Name: "x"}); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("code %q: want ErrValidation, got %v", code, err)
}
}
})
t.Run("missing parent is a validation error", func(t *testing.T) { t.Run("missing parent is a validation error", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo()) svc := NewCategoryService(newStubCategoryRepo())
missing := uuid.New() missing := uuid.New()
_, err := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &missing}) _, err := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &missing})
if !errors.Is(err, domain.ErrValidation) { if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err) t.Fatalf("want ErrValidation, got %v", err)
} }
@ -75,9 +84,9 @@ func TestCategoryService_Create(t *testing.T) {
t.Run("existing parent succeeds", func(t *testing.T) { t.Run("existing parent succeeds", func(t *testing.T) {
repo := newStubCategoryRepo() repo := newStubCategoryRepo()
svc := NewCategoryService(repo) svc := NewCategoryService(repo)
root, _ := svc.Create(ctx, CategoryInput{Name: "root"}) root, _ := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
child, err := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &root.ID}) child, err := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &root.ID})
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -92,26 +101,26 @@ func TestCategoryService_Update_PreventsCycles(t *testing.T) {
repo := newStubCategoryRepo() repo := newStubCategoryRepo()
svc := NewCategoryService(repo) svc := NewCategoryService(repo)
root, _ := svc.Create(ctx, CategoryInput{Name: "root"}) root, _ := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
child, _ := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &root.ID}) child, _ := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &root.ID})
t.Run("category cannot be its own parent", func(t *testing.T) { t.Run("category cannot be its own parent", func(t *testing.T) {
_, err := svc.Update(ctx, root.ID, CategoryInput{Name: "root", ParentID: &root.ID}) _, err := svc.Update(ctx, root.ID, CategoryInput{Code: "root", Name: "root", ParentID: &root.ID})
if !errors.Is(err, domain.ErrValidation) { if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err) t.Fatalf("want ErrValidation, got %v", err)
} }
}) })
t.Run("category cannot descend from its own child", func(t *testing.T) { t.Run("category cannot descend from its own child", func(t *testing.T) {
_, err := svc.Update(ctx, root.ID, CategoryInput{Name: "root", ParentID: &child.ID}) _, err := svc.Update(ctx, root.ID, CategoryInput{Code: "root", Name: "root", ParentID: &child.ID})
if !errors.Is(err, domain.ErrValidation) { if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err) t.Fatalf("want ErrValidation, got %v", err)
} }
}) })
t.Run("valid reparent succeeds", func(t *testing.T) { t.Run("valid reparent succeeds", func(t *testing.T) {
other, _ := svc.Create(ctx, CategoryInput{Name: "other"}) other, _ := svc.Create(ctx, CategoryInput{Code: "other", Name: "other"})
updated, err := svc.Update(ctx, child.ID, CategoryInput{Name: "child", ParentID: &other.ID}) updated, err := svc.Update(ctx, child.ID, CategoryInput{Code: "child", Name: "child", ParentID: &other.ID})
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -123,7 +132,7 @@ func TestCategoryService_Update_PreventsCycles(t *testing.T) {
func TestCategoryService_Update_MissingCategory(t *testing.T) { func TestCategoryService_Update_MissingCategory(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo()) svc := NewCategoryService(newStubCategoryRepo())
_, err := svc.Update(context.Background(), uuid.New(), CategoryInput{Name: "x"}) _, err := svc.Update(context.Background(), uuid.New(), CategoryInput{Code: "x", Name: "x"})
if !errors.Is(err, domain.ErrNotFound) { if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err) t.Fatalf("want ErrNotFound, got %v", err)
} }

View File

@ -61,9 +61,11 @@ type ObjectStore interface {
Remove(ctx context.Context, key string) error Remove(ctx context.Context, key string) error
} }
// categoryReader lets the dataset service verify a category exists before upload. // categoryReader lets the dataset service verify a category exists before upload
// and resolve a category code to its id for list filtering.
type categoryReader interface { type categoryReader interface {
GetByID(ctx context.Context, id uuid.UUID) (domain.Category, error) GetByID(ctx context.Context, id uuid.UUID) (domain.Category, error)
GetByCode(ctx context.Context, code string) (domain.Category, error)
} }
// JobEnqueuer schedules asynchronous dataset jobs. // JobEnqueuer schedules asynchronous dataset jobs.
@ -90,7 +92,6 @@ type RasterConverter interface {
// UploadInput carries everything needed to store a new dataset. // UploadInput carries everything needed to store a new dataset.
type UploadInput struct { type UploadInput struct {
CategoryID uuid.UUID CategoryID uuid.UUID
Code string
Name string Name string
Description *string Description *string
Unit *string Unit *string
@ -140,9 +141,6 @@ func NewDatasetService(
// Upload validates input, stores the object, and persists the dataset. If the // Upload validates input, stores the object, and persists the dataset. If the
// database write fails after upload, the stored object is removed. // database write fails after upload, the stored object is removed.
func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dataset, error) { func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dataset, error) {
if in.Code == "" {
return domain.Dataset{}, fmt.Errorf("%w: code is required", domain.ErrValidation)
}
if !in.FileType.Valid() { if !in.FileType.Valid() {
return domain.Dataset{}, fmt.Errorf("%w: unknown file_type %q", domain.ErrValidation, in.FileType) return domain.Dataset{}, fmt.Errorf("%w: unknown file_type %q", domain.ErrValidation, in.FileType)
} }
@ -193,7 +191,6 @@ func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dat
dataset, err := s.repo.Create(ctx, domain.Dataset{ dataset, err := s.repo.Create(ctx, domain.Dataset{
CategoryID: in.CategoryID, CategoryID: in.CategoryID,
Code: in.Code,
Name: name, Name: name,
Description: in.Description, Description: in.Description,
Unit: in.Unit, Unit: in.Unit,
@ -588,8 +585,10 @@ func (s *DatasetService) WaitForStatus(ctx context.Context, id uuid.UUID, curren
} }
// ListSummaries returns a page of dataset summaries, optionally filtered to a // ListSummaries returns a page of dataset summaries, optionally filtered to a
// category. page is 1-based; page and pageSize are clamped to sane bounds. // category by id and/or by code. page is 1-based; page and pageSize are clamped
func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUID, page, pageSize int) (DatasetPage, error) { // to sane bounds. When categoryCode is set it is resolved to its category id; an
// unknown code yields an empty page.
func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUID, categoryCode *string, page, pageSize int) (DatasetPage, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@ -600,6 +599,17 @@ func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUI
pageSize = MaxPageSize pageSize = MaxPageSize
} }
if categoryCode != nil {
category, err := s.categories.GetByCode(ctx, *categoryCode)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return DatasetPage{Items: []domain.DatasetSummary{}, Page: page, PageSize: pageSize}, nil
}
return DatasetPage{}, err
}
categoryID = &category.ID
}
items, err := s.repo.ListSummaries(ctx, categoryID, pageSize, (page-1)*pageSize) items, err := s.repo.ListSummaries(ctx, categoryID, pageSize, (page-1)*pageSize)
if err != nil { if err != nil {
return DatasetPage{}, err return DatasetPage{}, err

View File

@ -254,11 +254,17 @@ func (s stubCategoryReader) GetByID(_ context.Context, id uuid.UUID) (domain.Cat
return domain.Category{ID: id}, nil return domain.Category{ID: id}, nil
} }
func (s stubCategoryReader) GetByCode(_ context.Context, code string) (domain.Category, error) {
if !s.exists {
return domain.Category{}, domain.ErrNotFound
}
return domain.Category{ID: uuid.New(), Code: code}, nil
}
func validUpload() UploadInput { func validUpload() UploadInput {
body := `{"type":"FeatureCollection","features":[]}` body := `{"type":"FeatureCollection","features":[]}`
return UploadInput{ return UploadInput{
CategoryID: uuid.New(), CategoryID: uuid.New(),
Code: "POP",
Name: "Population", Name: "Population",
Filename: "data.geojson", Filename: "data.geojson",
FileType: domain.FileTypeVector, FileType: domain.FileTypeVector,
@ -278,7 +284,6 @@ func TestDatasetService_Upload_Validation(t *testing.T) {
name string name string
mutate func(*UploadInput) mutate func(*UploadInput)
}{ }{
{"missing code", func(in *UploadInput) { in.Code = "" }},
{"invalid file type", func(in *UploadInput) { in.FileType = "bogus" }}, {"invalid file type", func(in *UploadInput) { in.FileType = "bogus" }},
{"unknown extension", func(in *UploadInput) { in.Filename = "data.txt" }}, {"unknown extension", func(in *UploadInput) { in.Filename = "data.txt" }},
{"extension/type mismatch", func(in *UploadInput) { in.Filename = "data.tif" }}, // .tif is raster {"extension/type mismatch", func(in *UploadInput) { in.Filename = "data.tif" }}, // .tif is raster
@ -732,7 +737,7 @@ func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
svc := newDatasetService(repo, &stubStore{}, true) svc := newDatasetService(repo, &stubStore{}, true)
// page < 1 -> 1, pageSize > max -> MaxPageSize, offset = 0. // page < 1 -> 1, pageSize > max -> MaxPageSize, offset = 0.
res, err := svc.ListSummaries(context.Background(), nil, 0, 10_000) res, err := svc.ListSummaries(context.Background(), nil, nil, 0, 10_000)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -747,7 +752,7 @@ func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
} }
// page 3, pageSize 20 -> offset 40. // page 3, pageSize 20 -> offset 40.
if _, err := svc.ListSummaries(context.Background(), nil, 3, 20); err != nil { if _, err := svc.ListSummaries(context.Background(), nil, nil, 3, 20); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if repo.lastOffset != 40 || repo.lastLimit != 20 { if repo.lastOffset != 40 || repo.lastLimit != 20 {

View File

@ -34,12 +34,13 @@ func (h *CategoryHandler) Register(r chi.Router) {
type categoryRequest struct { type categoryRequest struct {
ParentID *string `json:"parent_id" validate:"omitempty,uuid"` ParentID *string `json:"parent_id" validate:"omitempty,uuid"`
Code string `json:"code" validate:"required,max=255,slug"`
Name string `json:"name" validate:"required,max=255"` Name string `json:"name" validate:"required,max=255"`
Description string `json:"description" validate:"max=2000"` Description string `json:"description" validate:"max=2000"`
} }
func (r categoryRequest) toInput() (service.CategoryInput, error) { func (r categoryRequest) toInput() (service.CategoryInput, error) {
in := service.CategoryInput{Name: r.Name, Description: r.Description} in := service.CategoryInput{Code: r.Code, Name: r.Name, Description: r.Description}
if r.ParentID != nil { if r.ParentID != nil {
id, err := uuid.Parse(*r.ParentID) id, err := uuid.Parse(*r.ParentID)
if err != nil { if err != nil {

View File

@ -131,12 +131,6 @@ func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) {
return return
} }
code := r.FormValue("code")
if code == "" {
httputil.WriteError(w, http.StatusUnprocessableEntity, "code is required")
return
}
meta, ok := optionalJSONFormValue(w, r, "meta") meta, ok := optionalJSONFormValue(w, r, "meta")
if !ok { if !ok {
return return
@ -162,7 +156,6 @@ func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) {
dataset, err := h.svc.Upload(r.Context(), service.UploadInput{ dataset, err := h.svc.Upload(r.Context(), service.UploadInput{
CategoryID: categoryID, CategoryID: categoryID,
Code: code,
Name: r.FormValue("name"), Name: r.FormValue("name"),
Description: optionalFormValue(r, "description"), Description: optionalFormValue(r, "description"),
Unit: optionalFormValue(r, "unit"), Unit: optionalFormValue(r, "unit"),
@ -220,6 +213,10 @@ func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
var categoryCode *string
if v := strings.TrimSpace(r.URL.Query().Get("category_code")); v != "" {
categoryCode = &v
}
page, ok := parsePositiveIntQuery(w, r, "page", 1) page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok { if !ok {
return return
@ -229,7 +226,7 @@ func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) {
return return
} }
res, err := h.svc.ListSummaries(r.Context(), categoryID, page, pageSize) res, err := h.svc.ListSummaries(r.Context(), categoryID, categoryCode, page, pageSize)
if err != nil { if err != nil {
respondDomainError(w, err) respondDomainError(w, err)
return return

View File

@ -2,6 +2,8 @@
CREATE TABLE categories ( CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES categories (id) ON DELETE RESTRICT, parent_id UUID REFERENCES categories (id) ON DELETE RESTRICT,
-- Slug: lowercase latin letters, digits, and dashes. Unique.
code VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

View File

@ -4,7 +4,6 @@ CREATE TYPE file_type AS ENUM ('vector_with_kato', 'vector', 'raster');
CREATE TABLE datasets ( CREATE TABLE datasets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID NOT NULL REFERENCES categories (id) ON DELETE RESTRICT, category_id UUID NOT NULL REFERENCES categories (id) ON DELETE RESTRICT,
code VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
unit VARCHAR(255), unit VARCHAR(255),