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("south") double south,
|
||||||
@Param("east") double east,
|
@Param("east") double east,
|
||||||
@Param("north") double north);
|
@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}
|
* List active records с optional bbox spatial filter.
|
||||||
* не null — используется PostGIS {@code ST_Intersects} (GiST index hit).
|
* Backward-compat entry — без polygon.
|
||||||
* Записи без geometry колонки автоматически отфильтровываются.
|
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<FlattenedRecordResponse> listActive(
|
public List<FlattenedRecordResponse> listActive(
|
||||||
@@ -118,6 +117,24 @@ public class RecordReadService {
|
|||||||
List<DataScope> allowedScopes,
|
List<DataScope> allowedScopes,
|
||||||
OffsetDateTime asOf,
|
OffsetDateTime asOf,
|
||||||
BoundingBox bbox) {
|
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)
|
DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
|
||||||
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
|
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
|
||||||
@@ -129,11 +146,14 @@ public class RecordReadService {
|
|||||||
|
|
||||||
OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
|
OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
|
||||||
List<DictionaryRecord> records;
|
List<DictionaryRecord> records;
|
||||||
|
String[] scopesCsv = allowedScopes.stream().map(Enum::name).toArray(String[]::new);
|
||||||
if (bbox != null) {
|
if (bbox != null) {
|
||||||
String[] scopesCsv = allowedScopes.stream().map(Enum::name).toArray(String[]::new);
|
|
||||||
records = recordRepository.findActiveByBbox(
|
records = recordRepository.findActiveByBbox(
|
||||||
def.getId(), scopesCsv, when,
|
def.getId(), scopesCsv, when,
|
||||||
bbox.west(), bbox.south(), bbox.east(), bbox.north());
|
bbox.west(), bbox.south(), bbox.east(), bbox.north());
|
||||||
|
} else if (polygonGeoJson != null) {
|
||||||
|
records = recordRepository.findActiveByPolygonGeoJson(
|
||||||
|
def.getId(), scopesCsv, when, polygonGeoJson);
|
||||||
} else {
|
} else {
|
||||||
records = recordRepository.findActiveByDictionaryAndScopes(
|
records = recordRepository.findActiveByDictionaryAndScopes(
|
||||||
def.getId(), allowedScopes, when);
|
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.dto.FlattenedRecordResponse;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
|
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
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.Counter;
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -69,9 +70,11 @@ public class DictionaryReadController {
|
|||||||
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage,
|
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage,
|
||||||
@RequestParam(value = "as_scope", required = false) String asScope,
|
@RequestParam(value = "as_scope", required = false) String asScope,
|
||||||
@RequestParam(value = "at", required = false) OffsetDateTime at,
|
@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;
|
BoundingBox bbox = null;
|
||||||
|
String polygon = null;
|
||||||
if (bboxCsv != null && !bboxCsv.isBlank()) {
|
if (bboxCsv != null && !bboxCsv.isBlank()) {
|
||||||
try {
|
try {
|
||||||
bbox = BoundingBox.parse(bboxCsv);
|
bbox = BoundingBox.parse(bboxCsv);
|
||||||
@@ -79,9 +82,20 @@ public class DictionaryReadController {
|
|||||||
throw new BadBboxException(e.getMessage());
|
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();
|
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();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
if (!items.isEmpty() && items.get(0)._meta() != null && items.get(0)._meta().locale() != null) {
|
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