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:
+27
@@ -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);
|
||||
}
|
||||
|
||||
+24
-4
@@ -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;
|
||||
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);
|
||||
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -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) {
|
||||
|
||||
+133
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user