fix: Code for category
This commit is contained in:
parent
46add88fd5
commit
148d1fb841
@ -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:
|
||||||
|
|||||||
@ -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) },
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user