feat: Districts table and geojson stub

This commit is contained in:
Bakhtiyar Issakhmetov 2026-07-01 00:23:22 +05:00
parent 0b45636e5b
commit 27a8d8b4f5
7 changed files with 731 additions and 0 deletions

View File

@ -120,6 +120,8 @@ server runs it is served at `/openapi.yaml`, with an interactive **Redoc** UI at
| GET | `/datasets` | paginated list of summaries (`?page=`, `?page_size=`, `?category_id=`) |
| POST | `/datasets` | upload (multipart: `file`, `file_type`, `category_id`, `code`, `name`, `description?`, `unit?`, `meta?` (JSON), `automated?` (bool)) |
| GET | `/datasets/{id}` | full dataset (geometry as GeoJSON, `bbox` for rasters) |
| GET | `/datasets/{id}.geojson` | GeoJSON `FeatureCollection`; plain `vector` returns its geometry as a single feature with the extracted attribute table as top-level properties; `vector_with_kato` maps observations, joining the `districts` table by KATO when it has no geometry of its own |
| GET | `/datasets/{id}.kato.geojson` | GeoJSON `FeatureCollection` (vector_with_kato); ignores dataset geometry and always joins `districts` by KATO, mapping observations onto each polygon |
| GET | `/datasets/{id}/status` | processing status; long-polls with `?current=<status>` (holds up to `?wait=` secs, default 25, max 60) |
| GET | `/datasets/{id}/download` | download the stored file |
| POST | `/datasets/{id}/mapping` | set KATO column + year→date map (vector_with_kato) |

View File

@ -251,6 +251,72 @@ paths:
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
/datasets/{id}.geojson:
parameters:
- $ref: "#/components/parameters/IdParam"
get:
tags: [Datasets]
summary: Get a dataset as GeoJSON (vector / vector_with_kato)
description: |
Assembles a GeoJSON `FeatureCollection` (RFC 7946) from the dataset.
A plain `vector` dataset has no KATO mapping or observations, so the
result is a single Feature wrapping the dataset's own (dissolved)
geometry. Its extracted attribute table (e.g. a GeoPackage's table data,
stored in `properties`) is exposed as the Feature's top-level properties:
a single row becomes the properties object directly, multiple rows are
kept under a `rows` key. An empty collection is returned when the dataset
has no geometry.
A `vector_with_kato` dataset is built from its observations. When it has
its own (dissolved) geometry, the observations are taken to describe that
whole geometry: a single Feature wraps it, and its properties hold only
the observations, keyed by KATO code at the top level (each KATO mapping
to its date->value pairs). Otherwise one Feature is emitted per KATO, its
boundary taken from the `districts` table and the per-year values
flattened into the Feature's properties keyed by date; KATO codes with
no matching district are skipped.
Only `ready` datasets are served: a dataset still being processed
returns 409. Other file types (e.g. `raster`) return 422.
responses:
"200":
description: The dataset as a GeoJSON FeatureCollection
content:
application/geo+json:
schema: { $ref: "#/components/schemas/GeoJSONFeatureCollection" }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/ValidationError" }
/datasets/{id}.kato.geojson:
parameters:
- $ref: "#/components/parameters/IdParam"
get:
tags: [Datasets]
summary: Get a dataset as district-joined GeoJSON (vector_with_kato)
description: |
Like `/datasets/{id}.geojson`, but ignores any geometry the dataset
carries and always joins the `districts` table on KATO code: one Feature
is emitted per KATO, its boundary taken from the matching district and
the per-year observation values mapped into the Feature's properties
keyed by date (alongside `kato` and `name`). KATO codes with no matching
district are skipped.
Only `ready` datasets are served: a dataset still being processed
returns 409. Other file types return 422.
responses:
"200":
description: The dataset as a district-joined GeoJSON FeatureCollection
content:
application/geo+json:
schema: { $ref: "#/components/schemas/GeoJSONFeatureCollection" }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/ValidationError" }
/datasets/{id}/status:
parameters:
- $ref: "#/components/parameters/IdParam"
@ -516,6 +582,39 @@ components:
type: string
coordinates: true
GeoJSONFeature:
type: object
description: A GeoJSON Feature (RFC 7946).
required: [type, geometry, properties]
properties:
type:
type: string
enum: [Feature]
geometry:
$ref: "#/components/schemas/GeoJSONGeometry"
properties:
type: [object, "null"]
description: |
Arbitrary key/value map. For per-KATO features (no dataset geometry)
this holds `kato`, `name`, and one entry per mapped year keyed by
date (YYYY-MM-DD). For the single-feature case (dataset has its own
geometry) it holds only the observations, keyed by KATO code, each
mapping to its date->value pairs.
additionalProperties: true
GeoJSONFeatureCollection:
type: object
description: A GeoJSON FeatureCollection (RFC 7946).
required: [type, features]
properties:
type:
type: string
enum: [FeatureCollection]
features:
type: array
items:
$ref: "#/components/schemas/GeoJSONFeature"
DatasetSummary:
type: object
required: [id, category_id, name, file_type, size_bytes, status, created_at, updated_at]

View File

@ -0,0 +1,31 @@
package domain
import "encoding/json"
// District is an administrative district boundary keyed by KATO code. Geometry
// is the boundary serialized as a GeoJSON geometry object in EPSG:4326.
type District struct {
Kato string `json:"kato"`
Name string `json:"name"`
Geometry json.RawMessage `json:"geometry"`
}
// GeoJSON object type tags (RFC 7946).
const (
GeoJSONFeatureCollection = "FeatureCollection"
GeoJSONFeature = "Feature"
)
// FeatureCollection is a GeoJSON FeatureCollection (RFC 7946).
type FeatureCollection struct {
Type string `json:"type"`
Features []Feature `json:"features"`
}
// Feature is a GeoJSON Feature (RFC 7946). Geometry is a raw GeoJSON geometry
// object (or JSON null when absent); Properties is an arbitrary key/value map.
type Feature struct {
Type string `json:"type"`
Geometry json.RawMessage `json:"geometry"`
Properties map[string]any `json:"properties"`
}

View File

@ -0,0 +1,65 @@
package postgres
import (
"context"
"gis/internal/domain"
"github.com/google/uuid"
)
// ListAllObservations returns every observation for a dataset ordered by
// (kato_code, date). Unlike ListObservations it is unpaginated: it backs GeoJSON
// assembly, where all of a dataset's observations are needed at once.
func (r *DatasetRepository) ListAllObservations(ctx context.Context, datasetID uuid.UUID) ([]domain.Observation, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+observationColumns+`
FROM dataset_observations
WHERE dataset_id = $1
ORDER BY kato_code, date`,
datasetID)
if err != nil {
return nil, mapError(err)
}
defer rows.Close()
out := make([]domain.Observation, 0)
for rows.Next() {
o, err := scanObservation(rows)
if err != nil {
return nil, mapError(err)
}
out = append(out, o)
}
return out, mapError(rows.Err())
}
// DistrictGeometriesByKato returns the districts whose KATO codes are in katos,
// keyed by KATO, with each boundary serialized as a GeoJSON geometry object in
// EPSG:4326. KATO codes with no matching district are simply absent from the
// result map.
func (r *DatasetRepository) DistrictGeometriesByKato(ctx context.Context, katos []string) (map[string]domain.District, error) {
out := make(map[string]domain.District)
if len(katos) == 0 {
return out, nil
}
rows, err := r.pool.Query(ctx,
`SELECT kato, name, ST_AsGeoJSON(coordinates)::jsonb
FROM districts
WHERE kato = ANY($1)`,
katos)
if err != nil {
return nil, mapError(err)
}
defer rows.Close()
for rows.Next() {
var d domain.District
if err := rows.Scan(&d.Kato, &d.Name, &d.Geometry); err != nil {
return nil, mapError(err)
}
out[d.Kato] = d
}
return out, mapError(rows.Err())
}

View File

@ -38,6 +38,8 @@ type DatasetRepository interface {
ReplaceObservations(ctx context.Context, datasetID uuid.UUID, obs []domain.Observation) error
ListObservations(ctx context.Context, datasetID uuid.UUID, katoCode *string, limit, offset int) ([]domain.Observation, error)
CountObservations(ctx context.Context, datasetID uuid.UUID, katoCode *string) (int, error)
ListAllObservations(ctx context.Context, datasetID uuid.UUID) ([]domain.Observation, error)
DistrictGeometriesByKato(ctx context.Context, katos []string) (map[string]domain.District, error)
}
// Pagination defaults for dataset listings.
@ -616,6 +618,221 @@ func (s *DatasetService) ListObservations(ctx context.Context, id uuid.UUID, kat
return ObservationPage{Items: items, Page: page, PageSize: pageSize, Total: total}, nil
}
// GeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a vector or
// vector_with_kato dataset.
//
// A plain vector dataset has no KATO mapping or observations, so the result is a
// single geometry-only Feature wrapping the dataset's own (dissolved) geometry,
// with empty properties (or an empty collection when the dataset has no
// geometry).
//
// A vector_with_kato dataset is built from its observations: when it carries its
// own (dissolved) geometry the observations are taken to describe that whole
// geometry, yielding a single Feature whose properties hold only the
// observations keyed by KATO code (each KATO mapping to its date->value pairs);
// otherwise one Feature is emitted per KATO, its boundary taken from the
// districts table and its observation values placed into the Feature's
// properties keyed by date. KATO codes with no matching district are skipped.
//
// Only ready datasets are served; a dataset still being processed yields a
// conflict.
func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
dataset, err := s.loadGeoJSONDataset(ctx, id, true)
if err != nil {
return domain.FeatureCollection{}, err
}
// Plain vector: no KATO mapping or observations. Return the dataset's own
// geometry as a single Feature, exposing the extracted attribute table (e.g.
// a GeoPackage's table data) as the Feature's top-level properties.
if dataset.FileType == domain.FileTypeVector {
fc := domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: []domain.Feature{}}
if hasGeometry(dataset.Geometry) {
fc.Features = append(fc.Features, domain.Feature{
Type: domain.GeoJSONFeature,
Geometry: dataset.Geometry,
Properties: vectorFeatureProperties(dataset.Properties),
})
}
return fc, nil
}
obs, err := s.repo.ListAllObservations(ctx, id)
if err != nil {
return domain.FeatureCollection{}, err
}
grouped, order := groupObservationsByKato(obs)
// The dataset has its own geometry (the dissolved union of all features): the
// observations describe that whole geometry, so emit a single Feature wrapping
// it whose properties hold only the observations, keyed by KATO code.
if hasGeometry(dataset.Geometry) {
props := make(map[string]any, len(order))
for _, kato := range order {
props[kato] = grouped[kato]
}
return domain.FeatureCollection{
Type: domain.GeoJSONFeatureCollection,
Features: []domain.Feature{{
Type: domain.GeoJSONFeature,
Geometry: dataset.Geometry,
Properties: props,
}},
}, nil
}
// No geometry: build one Feature per KATO from the districts table.
features, err := s.districtFeatures(ctx, grouped, order)
if err != nil {
return domain.FeatureCollection{}, err
}
return domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: features}, nil
}
// KatoGeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a
// vector_with_kato dataset by always joining the districts table on KATO code,
// ignoring any geometry the dataset carries. One Feature is emitted per KATO,
// its boundary taken from the districts table and its observation values placed
// into the Feature's properties keyed by date (alongside `kato` and `name`).
// KATO codes with no matching district are skipped. Plain vector datasets are
// not supported (they have no KATO observations). Only ready datasets are
// served; a dataset still being processed yields a conflict.
func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
if _, err := s.loadGeoJSONDataset(ctx, id, false); err != nil {
return domain.FeatureCollection{}, err
}
obs, err := s.repo.ListAllObservations(ctx, id)
if err != nil {
return domain.FeatureCollection{}, err
}
grouped, order := groupObservationsByKato(obs)
features, err := s.districtFeatures(ctx, grouped, order)
if err != nil {
return domain.FeatureCollection{}, err
}
return domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: features}, nil
}
// loadGeoJSONDataset fetches a dataset for a GeoJSON endpoint and validates that
// it is ready and of a supported file type. vector_with_kato is always
// accepted; plain vector is accepted only when allowVector is true (the
// .kato.geojson endpoint requires KATO observations, which plain vector lacks).
func (s *DatasetService) loadGeoJSONDataset(ctx context.Context, id uuid.UUID, allowVector bool) (domain.Dataset, error) {
dataset, err := s.repo.GetByID(ctx, id)
if err != nil {
return domain.Dataset{}, err
}
supported := dataset.FileType == domain.FileTypeVectorWithKato ||
(allowVector && dataset.FileType == domain.FileTypeVector)
if !supported {
allowed := "vector_with_kato"
if allowVector {
allowed = "vector and vector_with_kato"
}
return domain.Dataset{}, fmt.Errorf("%w: geojson is only available for %s datasets", domain.ErrValidation, allowed)
}
if dataset.Status != domain.DatasetStatusReady {
return domain.Dataset{}, fmt.Errorf("%w: dataset is not ready (status %q)", domain.ErrConflict, dataset.Status)
}
return dataset, nil
}
// districtFeatures builds one Feature per KATO from the districts table, placing
// the grouped observation values into each Feature's properties keyed by date
// (alongside `kato` and `name`). KATO codes with no matching district are
// skipped. order drives the deterministic feature order.
func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[string]map[string]any, order []string) ([]domain.Feature, error) {
districts, err := s.repo.DistrictGeometriesByKato(ctx, order)
if err != nil {
return nil, err
}
features := make([]domain.Feature, 0, len(order))
for _, kato := range order {
dist, ok := districts[kato]
if !ok {
continue // skip KATO codes with no district boundary
}
props := map[string]any{"kato": kato, "name": dist.Name}
for date, value := range grouped[kato] {
props[date] = value
}
features = append(features, domain.Feature{
Type: domain.GeoJSONFeature,
Geometry: dist.Geometry,
Properties: props,
})
}
return features, nil
}
// hasGeometry reports whether a dataset's GeoJSON geometry (as produced by
// ST_AsGeoJSON) is a real geometry rather than absent or JSON null.
func hasGeometry(geom json.RawMessage) bool {
t := bytes.TrimSpace(geom)
return len(t) > 0 && !bytes.Equal(t, []byte("null"))
}
// vectorFeatureProperties turns a plain vector dataset's stored attribute table
// into a Feature's properties object. The table is persisted as a JSON array of
// row objects: a single row becomes the top-level properties directly, multiple
// rows are kept under a "rows" key (so no data is lost while the value stays a
// valid GeoJSON properties object), and an empty/absent table yields {}.
func vectorFeatureProperties(raw json.RawMessage) map[string]any {
if len(bytes.TrimSpace(raw)) == 0 {
return map[string]any{}
}
var rows []map[string]any
if err := json.Unmarshal(raw, &rows); err == nil {
switch len(rows) {
case 0:
return map[string]any{}
case 1:
return rows[0]
default:
return map[string]any{"rows": rows}
}
}
// Fallback: the column already holds a plain object.
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err == nil {
return obj
}
return map[string]any{}
}
// groupObservationsByKato groups observations by KATO code into date->value
// maps. The value is the numeric Value when present, otherwise ValueText,
// otherwise nil (an empty cell). order lists KATO codes in first-seen order so
// the resulting feature order is deterministic.
func groupObservationsByKato(obs []domain.Observation) (map[string]map[string]any, []string) {
grouped := make(map[string]map[string]any)
order := make([]string, 0)
for _, o := range obs {
values, ok := grouped[o.KatoCode]
if !ok {
values = make(map[string]any)
grouped[o.KatoCode] = values
order = append(order, o.KatoCode)
}
values[o.Date] = observationValue(o)
}
return grouped, order
}
// observationValue returns the typed cell value: numeric Value, else ValueText,
// else nil.
func observationValue(o domain.Observation) any {
switch {
case o.Value != nil:
return *o.Value
case o.ValueText != nil:
return *o.ValueText
default:
return nil
}
}
// Get returns a dataset by id.
func (s *DatasetService) Get(ctx context.Context, id uuid.UUID) (domain.Dataset, error) {
return s.repo.GetByID(ctx, id)

View File

@ -20,6 +20,7 @@ import (
type stubDatasetRepo struct {
store map[uuid.UUID]domain.Dataset
observations map[uuid.UUID][]domain.Observation
districts map[string]domain.District
createErr error
deleted []uuid.UUID
lastLimit, lastOffset int
@ -29,6 +30,7 @@ func newStubDatasetRepo() *stubDatasetRepo {
return &stubDatasetRepo{
store: map[uuid.UUID]domain.Dataset{},
observations: map[uuid.UUID][]domain.Observation{},
districts: map[string]domain.District{},
}
}
@ -155,6 +157,20 @@ func (r *stubDatasetRepo) CountObservations(_ context.Context, id uuid.UUID, _ *
return len(r.observations[id]), nil
}
func (r *stubDatasetRepo) ListAllObservations(_ context.Context, id uuid.UUID) ([]domain.Observation, error) {
return r.observations[id], nil
}
func (r *stubDatasetRepo) DistrictGeometriesByKato(_ context.Context, katos []string) (map[string]domain.District, error) {
out := make(map[string]domain.District)
for _, k := range katos {
if d, ok := r.districts[k]; ok {
out[k] = d
}
}
return out, nil
}
// stubEnqueuer records parse, properties, extract, and convert enqueues.
type stubEnqueuer struct {
enqueued []uuid.UUID
@ -787,6 +803,267 @@ func TestDatasetService_Extract(t *testing.T) {
}
}
func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
ctx := context.Background()
id := uuid.New()
repo := newStubDatasetRepo()
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
v2020, v2021 := 100.0, 150.0
repo.observations[id] = []domain.Observation{
{KatoCode: "710000000", Date: "2020-01-01", Value: &v2020},
{KatoCode: "710000000", Date: "2021-01-01", Value: &v2021},
{KatoCode: "999999999", Date: "2020-01-01", Value: &v2020}, // no district -> skipped
}
repo.districts["710000000"] = domain.District{
Kato: "710000000", Name: "Astana",
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
}
svc := newDatasetService(repo, &stubStore{}, true)
fc, err := svc.GeoJSON(ctx, id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fc.Type != domain.GeoJSONFeatureCollection {
t.Fatalf("type = %q", fc.Type)
}
if len(fc.Features) != 1 {
t.Fatalf("want 1 feature (the unmatched KATO is skipped), got %d", len(fc.Features))
}
f := fc.Features[0]
if f.Type != domain.GeoJSONFeature {
t.Fatalf("feature type = %q", f.Type)
}
if len(f.Geometry) == 0 {
t.Fatal("feature geometry should come from the district")
}
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
t.Fatalf("unexpected properties: %+v", f.Properties)
}
if f.Properties["2020-01-01"] != 100.0 || f.Properties["2021-01-01"] != 150.0 {
t.Fatalf("year values not flattened into properties: %+v", f.Properties)
}
// The whole thing must marshal to valid GeoJSON.
if _, err := json.Marshal(fc); err != nil {
t.Fatalf("feature collection not valid JSON: %v", err)
}
}
func TestDatasetService_GeoJSON_UsesDatasetGeometry(t *testing.T) {
ctx := context.Background()
id := uuid.New()
repo := newStubDatasetRepo()
geom := json.RawMessage(`{"type":"MultiPolygon","coordinates":[]}`)
repo.store[id] = domain.Dataset{
ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady,
Name: "Population", Geometry: geom,
}
v := 42.0
repo.observations[id] = []domain.Observation{
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
{KatoCode: "710000000", Date: "2021-01-01", Value: &v},
}
svc := newDatasetService(repo, &stubStore{}, true)
fc, err := svc.GeoJSON(ctx, id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fc.Features) != 1 {
t.Fatalf("want a single feature wrapping the dataset geometry, got %d", len(fc.Features))
}
f := fc.Features[0]
if string(f.Geometry) != string(geom) {
t.Fatalf("feature should reuse dataset geometry, got %s", f.Geometry)
}
// Properties hold only the observations, keyed by KATO at the top level.
if _, ok := f.Properties["name"]; ok {
t.Fatalf("properties should contain only observations, got metadata: %+v", f.Properties)
}
kato, ok := f.Properties["710000000"].(map[string]any)
if !ok {
t.Fatalf("observations not keyed by KATO at top level: %+v", f.Properties)
}
if kato["2020-01-01"] != 42.0 || kato["2021-01-01"] != 42.0 {
t.Fatalf("year values missing under KATO: %+v", kato)
}
}
func TestDatasetService_KatoGeoJSON_IgnoresDatasetGeometry(t *testing.T) {
ctx := context.Background()
id := uuid.New()
repo := newStubDatasetRepo()
// Dataset HAS its own geometry, which KatoGeoJSON must ignore entirely.
repo.store[id] = domain.Dataset{
ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady,
Name: "Population", Geometry: json.RawMessage(`{"type":"MultiPolygon","coordinates":[[[[0,0]]]]}`),
}
v := 100.0
repo.observations[id] = []domain.Observation{
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
{KatoCode: "999999999", Date: "2020-01-01", Value: &v}, // no district -> skipped
}
district := json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`)
repo.districts["710000000"] = domain.District{Kato: "710000000", Name: "Astana", Geometry: district}
svc := newDatasetService(repo, &stubStore{}, true)
fc, err := svc.KatoGeoJSON(ctx, id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fc.Features) != 1 {
t.Fatalf("want 1 feature (unmatched KATO skipped), got %d", len(fc.Features))
}
f := fc.Features[0]
if string(f.Geometry) != string(district) {
t.Fatalf("geometry must come from the district, not the dataset: %s", f.Geometry)
}
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
t.Fatalf("unexpected properties: %+v", f.Properties)
}
if f.Properties["2020-01-01"] != 100.0 {
t.Fatalf("observation value not mapped onto polygon: %+v", f.Properties)
}
}
func TestDatasetService_KatoGeoJSON_ConflictWhenNotReady(t *testing.T) {
id := uuid.New()
repo := newStubDatasetRepo()
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusParsing}
svc := newDatasetService(repo, &stubStore{}, true)
if _, err := svc.KatoGeoJSON(context.Background(), id); !errors.Is(err, domain.ErrConflict) {
t.Fatalf("want ErrConflict for non-ready dataset, got %v", err)
}
}
func TestDatasetService_GeoJSON_ConflictWhenNotReady(t *testing.T) {
id := uuid.New()
repo := newStubDatasetRepo()
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusExtracting}
svc := newDatasetService(repo, &stubStore{}, true)
if _, err := svc.GeoJSON(context.Background(), id); !errors.Is(err, domain.ErrConflict) {
t.Fatalf("want ErrConflict for non-ready dataset, got %v", err)
}
}
func TestDatasetService_GeoJSON_RejectsRaster(t *testing.T) {
id := uuid.New()
repo := newStubDatasetRepo()
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeRaster, Status: domain.DatasetStatusReady}
svc := newDatasetService(repo, &stubStore{}, true)
if _, err := svc.GeoJSON(context.Background(), id); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
}
func TestDatasetService_GeoJSON_Vector_GeometryOnly(t *testing.T) {
id := uuid.New()
repo := newStubDatasetRepo()
geom := json.RawMessage(`{"type":"MultiPolygon","coordinates":[[[[1,1],[2,2],[3,1],[1,1]]]]}`)
repo.store[id] = domain.Dataset{
ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady,
Name: "Roads", Geometry: geom,
}
svc := newDatasetService(repo, &stubStore{}, true)
fc, err := svc.GeoJSON(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fc.Features) != 1 {
t.Fatalf("want a single geometry-only feature, got %d", len(fc.Features))
}
f := fc.Features[0]
if string(f.Geometry) != string(geom) {
t.Fatalf("feature should reuse the dataset geometry, got %s", f.Geometry)
}
if len(f.Properties) != 0 {
t.Fatalf("vector feature should have empty properties, got %+v", f.Properties)
}
if _, err := json.Marshal(fc); err != nil {
t.Fatalf("feature collection not valid JSON: %v", err)
}
}
func TestDatasetService_GeoJSON_Vector_TableDataAsProperties(t *testing.T) {
id := uuid.New()
repo := newStubDatasetRepo()
geom := json.RawMessage(`{"type":"Polygon","coordinates":[[[1,1],[2,2],[3,1],[1,1]]]}`)
repo.store[id] = domain.Dataset{
ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady,
Geometry: geom,
Properties: json.RawMessage(`[{"name":"Astana","pop":"1000"}]`), // single gpkg row
}
svc := newDatasetService(repo, &stubStore{}, true)
fc, err := svc.GeoJSON(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fc.Features) != 1 {
t.Fatalf("want 1 feature, got %d", len(fc.Features))
}
props := fc.Features[0].Properties
if props["name"] != "Astana" || props["pop"] != "1000" {
t.Fatalf("table data not exposed as top-level properties: %+v", props)
}
}
func TestDatasetService_GeoJSON_Vector_MultiRowTableData(t *testing.T) {
id := uuid.New()
repo := newStubDatasetRepo()
geom := json.RawMessage(`{"type":"Polygon","coordinates":[[[1,1],[2,2],[3,1],[1,1]]]}`)
repo.store[id] = domain.Dataset{
ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady,
Geometry: geom,
Properties: json.RawMessage(`[{"name":"Astana"},{"name":"Almaty"}]`),
}
svc := newDatasetService(repo, &stubStore{}, true)
fc, err := svc.GeoJSON(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rows, ok := fc.Features[0].Properties["rows"].([]map[string]any)
if !ok || len(rows) != 2 {
t.Fatalf("multi-row table data not kept under \"rows\": %+v", fc.Features[0].Properties)
}
}
func TestDatasetService_GeoJSON_Vector_NoGeometry(t *testing.T) {
id := uuid.New()
repo := newStubDatasetRepo()
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady}
svc := newDatasetService(repo, &stubStore{}, true)
fc, err := svc.GeoJSON(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fc.Type != domain.GeoJSONFeatureCollection {
t.Fatalf("type = %q", fc.Type)
}
if len(fc.Features) != 0 {
t.Fatalf("want empty feature collection when vector has no geometry, got %d", len(fc.Features))
}
}
func TestDatasetService_KatoGeoJSON_RejectsVector(t *testing.T) {
id := uuid.New()
repo := newStubDatasetRepo()
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady}
svc := newDatasetService(repo, &stubStore{}, true)
if _, err := svc.KatoGeoJSON(context.Background(), id); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation for vector on .kato.geojson, got %v", err)
}
}
func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
repo := newStubDatasetRepo()
repo.store[uuid.New()] = domain.Dataset{}

View File

@ -36,6 +36,8 @@ func (h *DatasetHandler) Register(r chi.Router) {
r.Get("/", h.list)
r.Post("/", h.upload)
r.Get("/{id}", h.get)
r.Get("/{id}.geojson", h.geojson)
r.Get("/{id}.kato.geojson", h.katoGeoJSON)
r.Get("/{id}/status", h.status)
r.Get("/{id}/download", h.download)
r.Post("/{id}/mapping", h.mapping)
@ -274,6 +276,44 @@ func (h *DatasetHandler) get(w http.ResponseWriter, r *http.Request) {
httputil.WriteJSON(w, http.StatusOK, dataset)
}
// geojson returns the dataset as a GeoJSON FeatureCollection (RFC 7946). For a
// vector_with_kato dataset it serves the dataset's own geometry as a single
// feature when present, otherwise one feature per KATO joined to the districts
// table. Only vector_with_kato datasets are supported.
func (h *DatasetHandler) geojson(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDParam(w, r, "id")
if !ok {
return
}
fc, err := h.svc.GeoJSON(r.Context(), id)
if err != nil {
respondDomainError(w, err)
return
}
w.Header().Set("Content-Type", "application/geo+json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(fc)
}
// katoGeoJSON returns the dataset as a GeoJSON FeatureCollection (RFC 7946),
// ignoring any geometry the dataset carries and instead joining the districts
// table on KATO code: one feature per KATO with the observation values mapped
// onto its district polygon. Only vector_with_kato datasets are supported.
func (h *DatasetHandler) katoGeoJSON(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDParam(w, r, "id")
if !ok {
return
}
fc, err := h.svc.KatoGeoJSON(r.Context(), id)
if err != nil {
respondDomainError(w, err)
return
}
w.Header().Set("Content-Type", "application/geo+json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(fc)
}
func (h *DatasetHandler) download(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDParam(w, r, "id")
if !ok {