diff --git a/README.md b/README.md index 358bce5..a7a2a7c 100644 --- a/README.md +++ b/README.md @@ -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=` (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) | diff --git a/api/openapi.yaml b/api/openapi.yaml index 4abe8c2..11cc570 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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] diff --git a/internal/domain/geojson.go b/internal/domain/geojson.go new file mode 100644 index 0000000..7c3a8e0 --- /dev/null +++ b/internal/domain/geojson.go @@ -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"` +} diff --git a/internal/repository/postgres/district.go b/internal/repository/postgres/district.go new file mode 100644 index 0000000..048fcc1 --- /dev/null +++ b/internal/repository/postgres/district.go @@ -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()) +} diff --git a/internal/service/dataset.go b/internal/service/dataset.go index 2f96cf7..75c495e 100644 --- a/internal/service/dataset.go +++ b/internal/service/dataset.go @@ -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) diff --git a/internal/service/dataset_test.go b/internal/service/dataset_test.go index 7bb83fb..c735b12 100644 --- a/internal/service/dataset_test.go +++ b/internal/service/dataset_test.go @@ -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{} diff --git a/internal/transport/http/dataset_handler.go b/internal/transport/http/dataset_handler.go index 7e9f016..45c05f2 100644 --- a/internal/transport/http/dataset_handler.go +++ b/internal/transport/http/dataset_handler.go @@ -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 {