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(
|
List<Object[]> countActiveGroupedByDictionary(
|
||||||
@Param("allowedScopes") Collection<DataScope> allowedScopes,
|
@Param("allowedScopes") Collection<DataScope> allowedScopes,
|
||||||
@Param("at") OffsetDateTime at);
|
@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);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -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.dto.FlattenedRecordResponse;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
|
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
|
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
|
||||||
|
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
import io.micrometer.core.instrument.Timer;
|
import io.micrometer.core.instrument.Timer;
|
||||||
@@ -102,6 +103,21 @@ public class RecordReadService {
|
|||||||
String acceptLanguage,
|
String acceptLanguage,
|
||||||
List<DataScope> allowedScopes,
|
List<DataScope> allowedScopes,
|
||||||
OffsetDateTime asOf) {
|
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)
|
DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
|
||||||
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
|
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
|
||||||
@@ -112,8 +128,16 @@ public class RecordReadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
|
OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
|
||||||
List<DictionaryRecord> records = recordRepository.findActiveByDictionaryAndScopes(
|
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);
|
def.getId(), allowedScopes, when);
|
||||||
|
}
|
||||||
|
|
||||||
String chosen = localeNegotiator.negotiate(
|
String chosen = localeNegotiator.negotiate(
|
||||||
acceptLanguage, def.getSupportedLocales(), def.getDefaultLocale());
|
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.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 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;
|
||||||
@@ -67,10 +68,20 @@ public class DictionaryReadController {
|
|||||||
@PathVariable String dictionaryName,
|
@PathVariable String dictionaryName,
|
||||||
@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) {
|
||||||
|
|
||||||
|
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();
|
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();
|
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) {
|
||||||
@@ -79,4 +90,9 @@ public class DictionaryReadController {
|
|||||||
headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE);
|
headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE);
|
||||||
return new ResponseEntity<>(items, headers, 200);
|
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;
|
package cloud.nstart.terravault.ordinis.readapi.web;
|
||||||
|
|
||||||
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService.ScopeAccessDeniedException;
|
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 com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import io.micrometer.tracing.Tracer;
|
import io.micrometer.tracing.Tracer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -43,6 +44,11 @@ public class ReadApiExceptionHandler {
|
|||||||
return body(HttpStatus.BAD_REQUEST, "bad_request", e.getMessage());
|
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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ErrorBody> onGeneric(Exception e) {
|
public ResponseEntity<ErrorBody> onGeneric(Exception e) {
|
||||||
log.error("Unhandled exception in read-api", 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