diff --git a/README.md b/README.md index a7a2a7c..9007522 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ the KATO column and the year columns: `?kato_code=`). ```sh -curl -X POST localhost:8080/datasets//mapping -H 'Content-Type: application/json' -d '{ +curl -X POST https://dssgis.dwh.kz/datasets//mapping -H 'Content-Type: application/json' -d '{ "kato_column": "като", "year_columns": [ {"column": "F_2023", "date": "2023-01-01"}, @@ -131,7 +131,7 @@ server runs it is served at `/openapi.yaml`, with an interactive **Redoc** UI at Example upload: ```sh -curl -X POST localhost:8080/datasets \ +curl -X POST https://dssgis.dwh.kz/datasets \ -F file=@sample.geojson -F file_type=vector -F category_id= \ -F code=POP_2026 -F name=Population -F description="Resident population" -F unit=people ``` diff --git a/api/openapi.yaml b/api/openapi.yaml index 7d1ed01..9f61b53 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -22,8 +22,8 @@ info: name: Proprietary servers: - - url: http://localhost:8080 - description: Local development + - url: https://dssgis.dwh.kz/ + description: Production tags: - name: Health @@ -291,12 +291,13 @@ paths: 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. + whole geometry: a single Feature wraps it, and its properties nest the + observations under a `data` object, keyed by KATO code (each KATO mapping + to its district `name` and its own `data` map of date->value pairs). + Otherwise one Feature is emitted per KATO, its + boundary taken from the `districts` table and the per-year values nested + under a `data` object (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 (e.g. `raster`) return 422. @@ -321,9 +322,9 @@ paths: 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. + the per-year observation values nested under a `data` object (keyed by + date) in the Feature's properties, 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. diff --git a/internal/service/dataset.go b/internal/service/dataset.go index 3db3df9..a0f4795 100644 --- a/internal/service/dataset.go +++ b/internal/service/dataset.go @@ -628,11 +628,13 @@ func (s *DatasetService) ListObservations(ctx context.Context, id uuid.UUID, kat // // 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. +// geometry, yielding a single Feature whose properties nest the observations +// under `data`, keyed by KATO code (each KATO mapping to its district `name` and +// its own `data` map of date->value pairs); otherwise one Feature is emitted per +// KATO, its boundary taken from the +// districts table and its observation values nested under a `data` object (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 yields a // conflict. @@ -665,24 +667,33 @@ func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.Feat // 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. + // it whose properties nest the observations under `data`, keyed by KATO code. + // Each KATO entry carries the district `name` alongside its own `data` map of + // date->value pairs. if hasGeometry(dataset.Geometry) { - props := make(map[string]any, len(order)) + districts, err := s.repo.DistrictGeometriesByKato(ctx, order) + if err != nil { + return domain.FeatureCollection{}, err + } + data := make(map[string]any, len(order)) for _, kato := range order { - props[kato] = grouped[kato] + data[kato] = map[string]any{ + "name": districts[kato].Name, + "data": grouped[kato], + } } return domain.FeatureCollection{ Type: domain.GeoJSONFeatureCollection, Features: []domain.Feature{{ Type: domain.GeoJSONFeature, Geometry: dataset.Geometry, - Properties: props, + Properties: map[string]any{"data": data}, }}, }, nil } // No geometry: build one Feature per KATO from the districts table. - features, err := s.districtFeatures(ctx, grouped, order) + features, err := s.districtFeatures(ctx, grouped, order, true) if err != nil { return domain.FeatureCollection{}, err } @@ -692,9 +703,9 @@ func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.Feat // 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 +// its boundary taken from the districts table and its observation values nested +// under a `data` object (keyed by date) in the Feature's properties, 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) { @@ -706,7 +717,7 @@ func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain. return domain.FeatureCollection{}, err } grouped, order := groupObservationsByKato(obs) - features, err := s.districtFeatures(ctx, grouped, order) + features, err := s.districtFeatures(ctx, grouped, order, true) if err != nil { return domain.FeatureCollection{}, err } @@ -738,11 +749,13 @@ func (s *DatasetService) loadGeoJSONDataset(ctx context.Context, id uuid.UUID, a 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) { +// districtFeatures builds one Feature per KATO from the districts table, +// alongside `kato` and `name` in each Feature's properties. When nestData is +// true the grouped observation values (keyed by date) are placed under a nested +// `data` object; otherwise they are spread as flat date-keyed properties. 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, nestData bool) ([]domain.Feature, error) { districts, err := s.repo.DistrictGeometriesByKato(ctx, order) if err != nil { return nil, err @@ -754,8 +767,12 @@ func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[strin 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 + if nestData { + props["data"] = grouped[kato] + } else { + for date, value := range grouped[kato] { + props[date] = value + } } features = append(features, domain.Feature{ Type: domain.GeoJSONFeature, diff --git a/internal/service/dataset_test.go b/internal/service/dataset_test.go index ac58f2f..51de767 100644 --- a/internal/service/dataset_test.go +++ b/internal/service/dataset_test.go @@ -843,8 +843,12 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) { 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) + data, ok := f.Properties["data"].(map[string]any) + if !ok { + t.Fatalf("observations not nested under data: %+v", f.Properties) + } + if data["2020-01-01"] != 100.0 || data["2021-01-01"] != 150.0 { + t.Fatalf("year values not nested under data: %+v", data) } // The whole thing must marshal to valid GeoJSON. @@ -867,6 +871,7 @@ func TestDatasetService_GeoJSON_UsesDatasetGeometry(t *testing.T) { {KatoCode: "710000000", Date: "2020-01-01", Value: &v}, {KatoCode: "710000000", Date: "2021-01-01", Value: &v}, } + repo.districts["710000000"] = domain.District{Kato: "710000000", Name: "Astana"} svc := newDatasetService(repo, &stubStore{}, true) fc, err := svc.GeoJSON(ctx, id) @@ -880,16 +885,28 @@ func TestDatasetService_GeoJSON_UsesDatasetGeometry(t *testing.T) { 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) + // Properties nest the observations under `data`, keyed by KATO; each KATO + // entry carries the district name and its own nested `data` map. + if _, flat := f.Properties["710000000"]; flat { + t.Fatalf("observations must be nested under data, not at top level: %+v", f.Properties) } - kato, ok := f.Properties["710000000"].(map[string]any) + data, ok := f.Properties["data"].(map[string]any) if !ok { - t.Fatalf("observations not keyed by KATO at top level: %+v", f.Properties) + t.Fatalf("observations not nested under data: %+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) + kato, ok := data["710000000"].(map[string]any) + if !ok { + t.Fatalf("observations not keyed by KATO under data: %+v", data) + } + if kato["name"] != "Astana" { + t.Fatalf("district name missing per KATO: %+v", kato) + } + values, ok := kato["data"].(map[string]any) + if !ok { + t.Fatalf("per-KATO observations not nested under data: %+v", kato) + } + if values["2020-01-01"] != 42.0 || values["2021-01-01"] != 42.0 { + t.Fatalf("year values missing under KATO data: %+v", values) } } @@ -925,8 +942,15 @@ func TestDatasetService_KatoGeoJSON_IgnoresDatasetGeometry(t *testing.T) { 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) + data, ok := f.Properties["data"].(map[string]any) + if !ok { + t.Fatalf("observations must be nested under data: %+v", f.Properties) + } + if data["2020-01-01"] != 100.0 { + t.Fatalf("observation value not mapped under data: %+v", data) + } + if _, flat := f.Properties["2020-01-01"]; flat { + t.Fatalf("observation dates must not be flat in properties: %+v", f.Properties) } }