From ac84780a95c006a386a10c617ef0608780a207dc Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 15:47:04 +0300 Subject: [PATCH] feat(geo): polygon GeoJSON spatial filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дополнение к bbox: ?polygon= для произвольных полигонов (ground stations coverage, custom AOI). Один из bbox/polygon — взаимоисключающие; передача обоих → 400. Repository: - findActiveByPolygonGeoJson native query через PostGIS ST_GeomFromGeoJSON(polygon) → ST_SetSRID(_, 4326). DB-side parsing, не нужен jts-io-common. GeoJsonPolygon validator (app-side, structural): - Парсит JSON, проверяет {"type":"Polygon","coordinates":[[...]]} - RFC 7946 closed-ring check: first point == last - >= 4 points в каждом ring (3 вертекса + closing point) - Поддержка holes (multiple rings) - Coordinate range validation: lon ∈ [-180,180], lat ∈ [-90,90] - DoS guards: MAX_JSON_BYTES = 1MB, MAX_POINTS = 50_000 - Canonical re-serialize (whitespace stripped) перед DB Tests (13 в GeoJsonPolygonTest): - Valid square + polygon with hole - Reject empty/null/non-JSON/non-object/wrong-type/missing-coords - Reject too-few points / unclosed ring - Reject lon/lat out of range - Reject malformed point [no lat] - Reject too-large JSON Total project tests: 161 → 174. API now supports: GET /api/v1/{dict}/records?bbox=west,south,east,north GET /api/v1/{dict}/records?polygon={"type":"Polygon",...} Альтум-задача (rect AOI) и геопортал (polygon AOI) разблокированы от client-side full-fetch + filter. --- .../record/DictionaryRecordRepository.java | 27 ++++ .../readapi/service/RecordReadService.java | 28 +++- .../readapi/spatial/GeoJsonPolygon.java | 111 +++++++++++++++ .../readapi/web/DictionaryReadController.java | 18 ++- .../readapi/spatial/GeoJsonPolygonTest.java | 133 ++++++++++++++++++ 5 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/spatial/GeoJsonPolygon.java create mode 100644 ordinis-read-api/src/test/java/cloud/nstart/terravault/ordinis/readapi/spatial/GeoJsonPolygonTest.java diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java index b0e2842..8dc1fa5 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java @@ -119,4 +119,31 @@ public interface DictionaryRecordRepository extends JpaRepositoryЕсли GeoJSON malformed, Postgres вернёт SQL error → Hibernate + * выбросит DataAccessException → перехватывается в exception handler + * как 400 Bad Request. + */ + @Query(value = """ + SELECT * FROM dictionary_records r + WHERE r.dictionary_id = :dictionaryId + AND r.data_scope = ANY(CAST(:scopesCsv AS TEXT[])) + AND r.valid_from <= :at + AND r.valid_to > :at + AND r.geometry IS NOT NULL + AND ST_Intersects( + r.geometry, + ST_SetSRID(ST_GeomFromGeoJSON(:polygonGeoJson), 4326)) + """, nativeQuery = true) + List findActiveByPolygonGeoJson( + @Param("dictionaryId") UUID dictionaryId, + @Param("scopesCsv") String[] scopesCsv, + @Param("at") OffsetDateTime at, + @Param("polygonGeoJson") String polygonGeoJson); } diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java index 0595bed..94dd0e6 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java @@ -107,9 +107,8 @@ public class RecordReadService { } /** - * List active records с optional bbox spatial filter. Если {@code bbox} - * не null — используется PostGIS {@code ST_Intersects} (GiST index hit). - * Записи без geometry колонки автоматически отфильтровываются. + * List active records с optional bbox spatial filter. + * Backward-compat entry — без polygon. */ @Transactional(readOnly = true) public List listActive( @@ -118,6 +117,24 @@ public class RecordReadService { List allowedScopes, OffsetDateTime asOf, BoundingBox bbox) { + return listActive(dictionaryName, acceptLanguage, allowedScopes, asOf, bbox, null); + } + + /** + * List active records с optional bbox или polygon GeoJSON spatial filter. + * Только один из {@code bbox}/{@code polygonGeoJson} может быть non-null + * (caller валидирует). Используется PostGIS {@code ST_Intersects} с + * GiST index hit. Записи без geometry колонки автоматически + * отфильтровываются. + */ + @Transactional(readOnly = true) + public List listActive( + String dictionaryName, + String acceptLanguage, + List allowedScopes, + OffsetDateTime asOf, + BoundingBox bbox, + String polygonGeoJson) { DictionaryDefinition def = definitionRepository.findByName(dictionaryName) .orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName)); @@ -129,11 +146,14 @@ public class RecordReadService { OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf; List records; + String[] scopesCsv = allowedScopes.stream().map(Enum::name).toArray(String[]::new); if (bbox != null) { - String[] scopesCsv = allowedScopes.stream().map(Enum::name).toArray(String[]::new); records = recordRepository.findActiveByBbox( def.getId(), scopesCsv, when, bbox.west(), bbox.south(), bbox.east(), bbox.north()); + } else if (polygonGeoJson != null) { + records = recordRepository.findActiveByPolygonGeoJson( + def.getId(), scopesCsv, when, polygonGeoJson); } else { records = recordRepository.findActiveByDictionaryAndScopes( def.getId(), allowedScopes, when); diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/spatial/GeoJsonPolygon.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/spatial/GeoJsonPolygon.java new file mode 100644 index 0000000..a3300c4 --- /dev/null +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/spatial/GeoJsonPolygon.java @@ -0,0 +1,111 @@ +package cloud.nstart.terravault.ordinis.readapi.spatial; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Минимальная structural валидация GeoJSON Polygon перед передачей в PostGIS. + * Не пытаемся parse в JTS — это сделает {@code ST_GeomFromGeoJSON} на + * DB-side. Цель этого парсера: отсечь явный мусор (не JSON, не Polygon, + * слишком короткий ring) до того как попадём в repository. + * + *

Spec: RFC 7946. + * + *

Принимаем только {@code {"type":"Polygon","coordinates":[[...]]}}. + * MultiPolygon, Feature, FeatureCollection — не поддержано в v1 (consumer + * пусть extract'нет первый geometry на своей стороне). + */ +public final class GeoJsonPolygon { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Лимит на размер JSON в bytes — защита от DoS. 1 MB достаточно для + * любого вменяемого polygon (1MB ≈ 50k coordinates). */ + static final int MAX_JSON_BYTES = 1_000_000; + + /** Лимит на coordinate count в exterior ring + holes. */ + static final int MAX_POINTS = 50_000; + + private GeoJsonPolygon() {} + + /** + * Validate + canonicalize. Возвращает уже-канонический JSON string + * (whitespace stripped, key order normalized) — этот вариант идёт в + * Postgres ST_GeomFromGeoJSON. + * + * @throws IllegalArgumentException на любую структурную ошибку + */ + public static String validateAndCanonicalize(String rawJson) { + if (rawJson == null || rawJson.isBlank()) { + throw new IllegalArgumentException("polygon GeoJSON is empty"); + } + if (rawJson.length() > MAX_JSON_BYTES) { + throw new IllegalArgumentException( + "polygon GeoJSON exceeds " + MAX_JSON_BYTES + " bytes"); + } + JsonNode root; + try { + root = MAPPER.readTree(rawJson); + } catch (Exception e) { + throw new IllegalArgumentException("polygon is not valid JSON: " + e.getMessage()); + } + if (!root.isObject()) { + throw new IllegalArgumentException("polygon GeoJSON must be an object"); + } + JsonNode type = root.get("type"); + if (type == null || !"Polygon".equals(type.asText())) { + throw new IllegalArgumentException( + "polygon GeoJSON type must be \"Polygon\", got: " + (type == null ? "null" : type.asText())); + } + JsonNode coords = root.get("coordinates"); + if (coords == null || !coords.isArray() || coords.isEmpty()) { + throw new IllegalArgumentException("polygon coordinates must be a non-empty array"); + } + + int totalPoints = 0; + for (int i = 0; i < coords.size(); i++) { + JsonNode ring = coords.get(i); + if (!ring.isArray() || ring.size() < 4) { + throw new IllegalArgumentException( + "polygon ring #" + i + " must have >= 4 points (closed linear ring)"); + } + // RFC 7946: first == last + JsonNode first = ring.get(0); + JsonNode last = ring.get(ring.size() - 1); + if (!first.equals(last)) { + throw new IllegalArgumentException( + "polygon ring #" + i + " must be closed (first point == last point)"); + } + for (int j = 0; j < ring.size(); j++) { + JsonNode p = ring.get(j); + if (!p.isArray() || p.size() < 2) { + throw new IllegalArgumentException( + "polygon ring #" + i + " point #" + j + " must be [longitude, latitude]"); + } + double lon = p.get(0).asDouble(); + double lat = p.get(1).asDouble(); + if (lon < -180 || lon > 180) { + throw new IllegalArgumentException( + "longitude " + lon + " out of range [-180, 180] (ring #" + i + " point #" + j + ")"); + } + if (lat < -90 || lat > 90) { + throw new IllegalArgumentException( + "latitude " + lat + " out of range [-90, 90] (ring #" + i + " point #" + j + ")"); + } + totalPoints++; + if (totalPoints > MAX_POINTS) { + throw new IllegalArgumentException( + "polygon has > " + MAX_POINTS + " points total"); + } + } + } + + // Canonical form: whitespace stripped, predictable encoding для + // Postgres parsing. + try { + return MAPPER.writeValueAsString(root); + } catch (Exception e) { + throw new IllegalStateException("Failed to re-serialize validated GeoJSON", e); + } + } +} diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java index dfdff39..99a8815 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java @@ -5,6 +5,7 @@ import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse; import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService; import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox; +import cloud.nstart.terravault.ordinis.readapi.spatial.GeoJsonPolygon; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.http.HttpHeaders; @@ -69,9 +70,11 @@ public class DictionaryReadController { @RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage, @RequestParam(value = "as_scope", required = false) String asScope, @RequestParam(value = "at", required = false) OffsetDateTime at, - @RequestParam(value = "bbox", required = false) String bboxCsv) { + @RequestParam(value = "bbox", required = false) String bboxCsv, + @RequestParam(value = "polygon", required = false) String polygonGeoJson) { BoundingBox bbox = null; + String polygon = null; if (bboxCsv != null && !bboxCsv.isBlank()) { try { bbox = BoundingBox.parse(bboxCsv); @@ -79,9 +82,20 @@ public class DictionaryReadController { throw new BadBboxException(e.getMessage()); } } + if (polygonGeoJson != null && !polygonGeoJson.isBlank()) { + try { + polygon = GeoJsonPolygon.validateAndCanonicalize(polygonGeoJson); + } catch (IllegalArgumentException e) { + throw new BadBboxException("polygon: " + e.getMessage()); + } + } + if (bbox != null && polygon != null) { + throw new BadBboxException( + "Specify either bbox or polygon, not both"); + } List scopes = scopeContext.resolveForRead(asScope).stream().toList(); - var items = service.listActive(dictionaryName, acceptLanguage, scopes, at, bbox); + var items = service.listActive(dictionaryName, acceptLanguage, scopes, at, bbox, polygon); HttpHeaders headers = new HttpHeaders(); if (!items.isEmpty() && items.get(0)._meta() != null && items.get(0)._meta().locale() != null) { diff --git a/ordinis-read-api/src/test/java/cloud/nstart/terravault/ordinis/readapi/spatial/GeoJsonPolygonTest.java b/ordinis-read-api/src/test/java/cloud/nstart/terravault/ordinis/readapi/spatial/GeoJsonPolygonTest.java new file mode 100644 index 0000000..7e18296 --- /dev/null +++ b/ordinis-read-api/src/test/java/cloud/nstart/terravault/ordinis/readapi/spatial/GeoJsonPolygonTest.java @@ -0,0 +1,133 @@ +package cloud.nstart.terravault.ordinis.readapi.spatial; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GeoJsonPolygonTest { + + // Простой square Moscow ~37.5..37.8, ~55.5..55.8 (closed: first == last) + private static final String VALID_SQUARE = """ + {"type":"Polygon","coordinates":[[ + [37.5,55.5],[37.8,55.5],[37.8,55.8],[37.5,55.8],[37.5,55.5] + ]]}"""; + + @Test + void acceptsValidPolygon() { + String canonical = GeoJsonPolygon.validateAndCanonicalize(VALID_SQUARE); + // Whitespace stripped в canonical form + assertThat(canonical).doesNotContain("\n"); + assertThat(canonical).contains("\"type\":\"Polygon\""); + assertThat(canonical).contains("\"coordinates\""); + } + + @Test + void acceptsPolygonWithHole() { + String withHole = """ + {"type":"Polygon","coordinates":[ + [[0,0],[10,0],[10,10],[0,10],[0,0]], + [[2,2],[3,2],[3,3],[2,3],[2,2]] + ]}"""; + String canonical = GeoJsonPolygon.validateAndCanonicalize(withHole); + assertThat(canonical).contains("Polygon"); + } + + @Test + void rejectsEmpty() { + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty"); + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsNonJson() { + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize("not json {{")) + .hasMessageContaining("not valid JSON"); + } + + @Test + void rejectsNonObjectRoot() { + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize("[]")) + .hasMessageContaining("must be an object"); + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize("\"hello\"")) + .hasMessageContaining("must be an object"); + } + + @Test + void rejectsWrongType() { + String point = """ + {"type":"Point","coordinates":[37.5,55.5]}"""; + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize(point)) + .hasMessageContaining("type must be \"Polygon\""); + } + + @Test + void rejectsMissingCoordinates() { + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize("{\"type\":\"Polygon\"}")) + .hasMessageContaining("coordinates"); + } + + @Test + void rejectsTooFewPointsInRing() { + String triangle = """ + {"type":"Polygon","coordinates":[[ + [0,0],[1,0],[0,0] + ]]}"""; + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize(triangle)) + .hasMessageContaining(">= 4 points"); + } + + @Test + void rejectsUnclosedRing() { + String unclosed = """ + {"type":"Polygon","coordinates":[[ + [0,0],[1,0],[1,1],[0,1] + ]]}"""; + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize(unclosed)) + .hasMessageContaining("must be closed"); + } + + @Test + void rejectsLongitudeOutOfRange() { + String bad = """ + {"type":"Polygon","coordinates":[[ + [-181,0],[1,0],[1,1],[0,1],[-181,0] + ]]}"""; + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize(bad)) + .hasMessageContaining("longitude"); + } + + @Test + void rejectsLatitudeOutOfRange() { + String bad = """ + {"type":"Polygon","coordinates":[[ + [0,91],[1,0],[1,1],[0,1],[0,91] + ]]}"""; + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize(bad)) + .hasMessageContaining("latitude"); + } + + @Test + void rejectsPointWithoutLatitude() { + String bad = """ + {"type":"Polygon","coordinates":[[ + [0],[1,0],[1,1],[0,1],[0] + ]]}"""; + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize(bad)) + .hasMessageContaining("[longitude, latitude]"); + } + + @Test + void rejectsTooLargeJson() { + StringBuilder huge = new StringBuilder("{\"type\":\"Polygon\",\"coordinates\":[["); + while (huge.length() < GeoJsonPolygon.MAX_JSON_BYTES) { + huge.append("[0,0],"); + } + huge.append("[0,0]]]}"); + assertThatThrownBy(() -> GeoJsonPolygon.validateAndCanonicalize(huge.toString())) + .hasMessageContaining("exceeds"); + } +}