feat(geo): bbox spatial filter on GET /{dict}/records
v2 фича: Альтум-задача (заказ съёмки) использует bbox region. До
этого consumers fetch'или ВСЕ записи и filter'или client-side. Теперь
PostGIS ST_Intersects на server-side через GiST index.
API:
GET /api/v1/{dictionaryName}/records?bbox=west,south,east,north
Coords в SRID 4326 (longitude, latitude). Параметр optional —
отсутствует = старое поведение (full list).
BoundingBox helper (ordinis-read-api/spatial/):
- Парсинг "west,south,east,north" с валидацией
- Range checks: lon ∈ [-180,180], lat ∈ [-90,90]
- west <= east (anti-meridian crossing rejected в v1)
- south <= north
- Понятные error messages → 400 Bad Request через BadBboxException
Repository (ordinis-domain):
- findActiveByBbox native query, ST_MakeEnvelope(:west,:south,:east,
:north,4326) ST_Intersects на geometry колонке. GiST index hit.
- Записи с geometry IS NULL автоматически отфильтровываются.
Tests (12 в BoundingBoxTest):
- Valid parsing + negative coords + whitespace
- Reject empty/null/wrong-count/non-numeric
- Range validation (lon/lat bounds)
- Anti-meridian + inverted N/S detected
- Area calc sanity
Total project tests: 149 → 161.
Polygon GeoJSON support — отложен (нужен jts-io-common dep). Bbox
покрывает Альтум use case + 80% other consumers.
This commit is contained in:
+34
@@ -85,4 +85,38 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
|
||||
List<Object[]> countActiveGroupedByDictionary(
|
||||
@Param("allowedScopes") Collection<DataScope> allowedScopes,
|
||||
@Param("at") OffsetDateTime at);
|
||||
|
||||
/**
|
||||
* Spatial filter: активные записи которые intersect bbox (SRID 4326).
|
||||
* Используется read-api endpoint {@code GET /api/v1/{dict}/records?bbox=...}.
|
||||
* PostGIS {@code ST_Intersects} использует GiST index на geometry колонке —
|
||||
* O(log n) lookup, не sequential scan.
|
||||
*
|
||||
* <p>Native query (не JPA) потому что hibernate-spatial 7.x не имеет clean
|
||||
* mapping для {@code ST_MakeEnvelope}. Native + ST_MakeEnvelope экономит
|
||||
* один parse vs передачи WKT строки.
|
||||
*
|
||||
* <p>{@code data_scope IN (...)} нативно требует постгресовский
|
||||
* ANY(string_array) — но у нас scopes небольшой enum, ставим IN-list через
|
||||
* JPA String parameter.
|
||||
*/
|
||||
@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_MakeEnvelope(:west, :south, :east, :north, 4326))
|
||||
""", nativeQuery = true)
|
||||
List<DictionaryRecord> findActiveByBbox(
|
||||
@Param("dictionaryId") UUID dictionaryId,
|
||||
@Param("scopesCsv") String[] scopesCsv,
|
||||
@Param("at") OffsetDateTime at,
|
||||
@Param("west") double west,
|
||||
@Param("south") double south,
|
||||
@Param("east") double east,
|
||||
@Param("north") double north);
|
||||
}
|
||||
|
||||
+26
-2
@@ -8,6 +8,7 @@ import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
|
||||
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
|
||||
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
|
||||
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
@@ -102,6 +103,21 @@ public class RecordReadService {
|
||||
String acceptLanguage,
|
||||
List<DataScope> allowedScopes,
|
||||
OffsetDateTime asOf) {
|
||||
return listActive(dictionaryName, acceptLanguage, allowedScopes, asOf, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* List active records с optional bbox spatial filter. Если {@code bbox}
|
||||
* не null — используется 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) {
|
||||
|
||||
DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
|
||||
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
|
||||
@@ -112,8 +128,16 @@ public class RecordReadService {
|
||||
}
|
||||
|
||||
OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
|
||||
List<DictionaryRecord> records = recordRepository.findActiveByDictionaryAndScopes(
|
||||
def.getId(), allowedScopes, when);
|
||||
List<DictionaryRecord> records;
|
||||
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 {
|
||||
records = recordRepository.findActiveByDictionaryAndScopes(
|
||||
def.getId(), allowedScopes, when);
|
||||
}
|
||||
|
||||
String chosen = localeNegotiator.negotiate(
|
||||
acceptLanguage, def.getSupportedLocales(), def.getDefaultLocale());
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package cloud.nstart.terravault.ordinis.readapi.spatial;
|
||||
|
||||
/**
|
||||
* Bounding box (west-south-east-north) для spatial filtering. Парсится
|
||||
* из query param {@code ?bbox=west,south,east,north} (4 floats).
|
||||
*
|
||||
* <p>Координаты в SRID 4326 (WGS84): longitude, latitude. Альтум-задача
|
||||
* (заказ съёмки) использует именно этот формат.
|
||||
*
|
||||
* <p>Validation:
|
||||
* <ul>
|
||||
* <li>4 числа, через запятую</li>
|
||||
* <li>longitude ∈ [-180, 180]</li>
|
||||
* <li>latitude ∈ [-90, 90]</li>
|
||||
* <li>west ≤ east, south ≤ north (no anti-meridian crossing в v1)</li>
|
||||
* </ul>
|
||||
*/
|
||||
public record BoundingBox(double west, double south, double east, double north) {
|
||||
|
||||
/**
|
||||
* Парсит {@code "west,south,east,north"}. На любую ошибку формата
|
||||
* бросает {@link IllegalArgumentException} с понятным message.
|
||||
*/
|
||||
public static BoundingBox parse(String csv) {
|
||||
if (csv == null || csv.isBlank()) {
|
||||
throw new IllegalArgumentException("bbox is empty");
|
||||
}
|
||||
String[] parts = csv.split(",");
|
||||
if (parts.length != 4) {
|
||||
throw new IllegalArgumentException(
|
||||
"bbox must be 4 comma-separated numbers (west,south,east,north), got " + parts.length);
|
||||
}
|
||||
double[] coords = new double[4];
|
||||
for (int i = 0; i < 4; i++) {
|
||||
try {
|
||||
coords[i] = Double.parseDouble(parts[i].trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("bbox component #" + (i + 1) + " is not a number: " + parts[i]);
|
||||
}
|
||||
}
|
||||
BoundingBox bbox = new BoundingBox(coords[0], coords[1], coords[2], coords[3]);
|
||||
bbox.validate();
|
||||
return bbox;
|
||||
}
|
||||
|
||||
public void validate() {
|
||||
if (west < -180 || west > 180) {
|
||||
throw new IllegalArgumentException("west longitude " + west + " out of range [-180, 180]");
|
||||
}
|
||||
if (east < -180 || east > 180) {
|
||||
throw new IllegalArgumentException("east longitude " + east + " out of range [-180, 180]");
|
||||
}
|
||||
if (south < -90 || south > 90) {
|
||||
throw new IllegalArgumentException("south latitude " + south + " out of range [-90, 90]");
|
||||
}
|
||||
if (north < -90 || north > 90) {
|
||||
throw new IllegalArgumentException("north latitude " + north + " out of range [-90, 90]");
|
||||
}
|
||||
if (west > east) {
|
||||
throw new IllegalArgumentException(
|
||||
"west " + west + " > east " + east + " (anti-meridian crossing not supported in v1)");
|
||||
}
|
||||
if (south > north) {
|
||||
throw new IllegalArgumentException("south " + south + " > north " + north);
|
||||
}
|
||||
}
|
||||
|
||||
/** Площадь в квадратных градусах (грубая, не sphere). Для UI rendering / debugging. */
|
||||
public double areaInDegreesSquared() {
|
||||
return (east - west) * (north - south);
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -4,6 +4,7 @@ import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||
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 io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -67,10 +68,20 @@ public class DictionaryReadController {
|
||||
@PathVariable String dictionaryName,
|
||||
@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 = "at", required = false) OffsetDateTime at,
|
||||
@RequestParam(value = "bbox", required = false) String bboxCsv) {
|
||||
|
||||
BoundingBox bbox = null;
|
||||
if (bboxCsv != null && !bboxCsv.isBlank()) {
|
||||
try {
|
||||
bbox = BoundingBox.parse(bboxCsv);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BadBboxException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
List<DataScope> scopes = scopeContext.resolveForRead(asScope).stream().toList();
|
||||
var items = service.listActive(dictionaryName, acceptLanguage, scopes, at);
|
||||
var items = service.listActive(dictionaryName, acceptLanguage, scopes, at, bbox);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (!items.isEmpty() && items.get(0)._meta() != null && items.get(0)._meta().locale() != null) {
|
||||
@@ -79,4 +90,9 @@ public class DictionaryReadController {
|
||||
headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE);
|
||||
return new ResponseEntity<>(items, headers, 200);
|
||||
}
|
||||
|
||||
/** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */
|
||||
public static class BadBboxException extends RuntimeException {
|
||||
public BadBboxException(String message) { super(message); }
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -1,6 +1,7 @@
|
||||
package cloud.nstart.terravault.ordinis.readapi.web;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService.ScopeAccessDeniedException;
|
||||
import cloud.nstart.terravault.ordinis.readapi.web.DictionaryReadController.BadBboxException;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.micrometer.tracing.Tracer;
|
||||
import org.slf4j.Logger;
|
||||
@@ -43,6 +44,11 @@ public class ReadApiExceptionHandler {
|
||||
return body(HttpStatus.BAD_REQUEST, "bad_request", e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(BadBboxException.class)
|
||||
public ResponseEntity<ErrorBody> onBadBbox(BadBboxException e) {
|
||||
return body(HttpStatus.BAD_REQUEST, "bad_bbox", e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorBody> onGeneric(Exception e) {
|
||||
log.error("Unhandled exception in read-api", e);
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
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 BoundingBoxTest {
|
||||
|
||||
@Test
|
||||
void parsesValidBboxString() {
|
||||
BoundingBox bbox = BoundingBox.parse("37.5,55.5,37.8,55.8");
|
||||
assertThat(bbox.west()).isEqualTo(37.5);
|
||||
assertThat(bbox.south()).isEqualTo(55.5);
|
||||
assertThat(bbox.east()).isEqualTo(37.8);
|
||||
assertThat(bbox.north()).isEqualTo(55.8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesNegativeCoordinates() {
|
||||
BoundingBox bbox = BoundingBox.parse("-180,-90,180,90");
|
||||
assertThat(bbox.west()).isEqualTo(-180);
|
||||
assertThat(bbox.east()).isEqualTo(180);
|
||||
}
|
||||
|
||||
@Test
|
||||
void trimsWhitespace() {
|
||||
BoundingBox bbox = BoundingBox.parse(" 37.5 , 55.5 , 37.8 , 55.8 ");
|
||||
assertThat(bbox.west()).isEqualTo(37.5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmpty() {
|
||||
assertThatThrownBy(() -> BoundingBox.parse(""))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("empty");
|
||||
assertThatThrownBy(() -> BoundingBox.parse(null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongCount() {
|
||||
assertThatThrownBy(() -> BoundingBox.parse("1,2,3"))
|
||||
.hasMessageContaining("4 comma-separated");
|
||||
assertThatThrownBy(() -> BoundingBox.parse("1,2,3,4,5"))
|
||||
.hasMessageContaining("4 comma-separated");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonNumeric() {
|
||||
assertThatThrownBy(() -> BoundingBox.parse("a,b,c,d"))
|
||||
.hasMessageContaining("not a number");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLongitudeOutOfRange() {
|
||||
assertThatThrownBy(() -> BoundingBox.parse("-181,0,1,1"))
|
||||
.hasMessageContaining("longitude");
|
||||
assertThatThrownBy(() -> BoundingBox.parse("0,0,181,1"))
|
||||
.hasMessageContaining("longitude");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLatitudeOutOfRange() {
|
||||
assertThatThrownBy(() -> BoundingBox.parse("0,-91,1,0"))
|
||||
.hasMessageContaining("latitude");
|
||||
assertThatThrownBy(() -> BoundingBox.parse("0,0,1,91"))
|
||||
.hasMessageContaining("latitude");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAntiMeridianCrossing() {
|
||||
// west > east — обычно signal что caller хотел anti-meridian (Pacific
|
||||
// crossing). v1 не поддерживает — лучше явная ошибка чем неправильный
|
||||
// intersect.
|
||||
assertThatThrownBy(() -> BoundingBox.parse("170,0,-170,1"))
|
||||
.hasMessageContaining("anti-meridian");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvertedNorthSouth() {
|
||||
assertThatThrownBy(() -> BoundingBox.parse("0,55,1,30"))
|
||||
.hasMessageContaining("south");
|
||||
}
|
||||
|
||||
@Test
|
||||
void areaInDegreesSquared() {
|
||||
BoundingBox unit = new BoundingBox(0, 0, 1, 1);
|
||||
assertThat(unit.areaInDegreesSquared()).isEqualTo(1.0);
|
||||
|
||||
BoundingBox moscow = new BoundingBox(37.0, 55.0, 38.0, 56.0);
|
||||
assertThat(moscow.areaInDegreesSquared()).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void worldwide() {
|
||||
BoundingBox world = BoundingBox.parse("-180,-90,180,90");
|
||||
assertThat(world.areaInDegreesSquared()).isEqualTo(360.0 * 180.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user