feat(geo): polygon GeoJSON spatial filter

Дополнение к bbox: ?polygon=<GeoJSON> для произвольных полигонов
(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.
This commit is contained in:
Zimin A.N.
2026-05-06 15:47:04 +03:00
parent 0811ad8506
commit ac84780a95
5 changed files with 311 additions and 6 deletions
@@ -119,4 +119,31 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("south") double south,
@Param("east") double east,
@Param("north") double north);
/**
* Spatial filter по произвольному polygon в формате GeoJSON. PostGIS
* {@code ST_GeomFromGeoJSON} парсит на DB-side, ST_SetSRID вешает 4326
* (GeoJSON по spec не имеет SRID, нужен explicit cast). GiST index
* на geometry колонке используется для ST_Intersects.
*
* <p>Если 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<DictionaryRecord> findActiveByPolygonGeoJson(
@Param("dictionaryId") UUID dictionaryId,
@Param("scopesCsv") String[] scopesCsv,
@Param("at") OffsetDateTime at,
@Param("polygonGeoJson") String polygonGeoJson);
}
@@ -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<FlattenedRecordResponse> listActive(
@@ -118,6 +117,24 @@ public class RecordReadService {
List<DataScope> 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<FlattenedRecordResponse> listActive(
String dictionaryName,
String acceptLanguage,
List<DataScope> 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<DictionaryRecord> records;
if (bbox != null) {
String[] scopesCsv = allowedScopes.stream().map(Enum::name).toArray(String[]::new);
if (bbox != null) {
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);
@@ -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.
*
* <p>Spec: <a href="https://datatracker.ietf.org/doc/html/rfc7946">RFC 7946</a>.
*
* <p>Принимаем только {@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);
}
}
}
@@ -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<DataScope> 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) {
@@ -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");
}
}