This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -0,0 +1,25 @@
package space.nstart.pcp.routepassportconsumer
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.kafka.annotation.EnableKafka
/**
* Главный класс Spring Boot приложения.
*
* Приложение поднимает только инфраструктуру потребителя Kafka и сервис парсинга JSON.
*/
@ConfigurationPropertiesScan
@SpringBootApplication
@EnableKafka
class RoutePassportConsumerApplication
/**
* Точка входа приложения.
*
* @param args аргументы командной строки, которые передаются в Spring Boot.
*/
fun main(args: Array<String>) {
runApplication<RoutePassportConsumerApplication>(*args)
}
@@ -0,0 +1,131 @@
package space.nstart.pcp.routepassportconsumer.config
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.JsonMappingException
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.header.Headers
import org.apache.kafka.common.header.internals.RecordHeader
import org.apache.kafka.common.header.internals.RecordHeaders
import org.apache.kafka.common.serialization.StringDeserializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.listener.ContainerProperties
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
import org.springframework.kafka.listener.DefaultErrorHandler
import org.springframework.util.backoff.ExponentialBackOff
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import tools.jackson.core.JacksonException
import java.nio.charset.StandardCharsets
@Configuration
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
class KafkaConsumerConfig(
@param:Value("\${spring.kafka.bootstrap-servers}")
private val bootstrapServers: String,
@param:Value("\${spring.kafka.consumer.group-id}")
private val groupId: String,
@param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}")
private val autoOffsetReset: String,
) {
@Bean
fun consumerFactory(): ConsumerFactory<String, String> =
DefaultKafkaConsumerFactory(
mutableMapOf<String, Any>(
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ConsumerConfig.GROUP_ID_CONFIG to groupId,
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset,
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false,
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
),
)
@Bean
fun kafkaListenerContainerFactory(
kafkaErrorHandler: DefaultErrorHandler,
): ConcurrentKafkaListenerContainerFactory<String, String> =
ConcurrentKafkaListenerContainerFactory<String, String>().apply {
setConsumerFactory(consumerFactory())
setCommonErrorHandler(kafkaErrorHandler)
containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE
}
@Bean
fun deadLetterPublishingRecoverer(
kafkaTemplate: KafkaTemplate<String, String>,
kafkaTopicsProperties: KafkaTopicsProperties,
): DeadLetterPublishingRecoverer =
DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
TopicPartition(kafkaTopicsProperties.inputDlq, record.partition())
}.apply {
addHeadersFunction { record, exception -> routePassportDlqHeaders(record, exception) }
}
@Bean
fun kafkaErrorHandler(
deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer,
retryProperties: RoutePassportConsumerRetryProperties,
): DefaultErrorHandler =
DefaultErrorHandler(
deadLetterPublishingRecoverer,
routePassportConsumerBackOff(retryProperties),
).apply {
addNotRetryableExceptions(
RoutePassportContractException::class.java,
JacksonException::class.java,
JsonProcessingException::class.java,
JsonMappingException::class.java,
JsonParseException::class.java,
)
setCommitRecovered(true)
}
private fun routePassportConsumerBackOff(
retryProperties: RoutePassportConsumerRetryProperties,
): ExponentialBackOff =
ExponentialBackOff().apply {
initialInterval = retryProperties.backoffMs
multiplier = RETRY_BACKOFF_MULTIPLIER
maxInterval = retryProperties.maxBackoffMs
maxAttempts = (retryProperties.maxAttempts - 1).coerceAtLeast(0)
}
private fun routePassportDlqHeaders(
record: ConsumerRecord<*, *>,
exception: Exception,
): Headers =
RecordHeaders().apply {
addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME)
addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name)
addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty())
addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic())
addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString())
addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString())
}
private fun Headers.addStringHeader(name: String, value: String) {
add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8)))
}
private companion object {
const val SERVICE_NAME = "pcp-route-processing-service"
const val RETRY_BACKOFF_MULTIPLIER = 2.0
const val HEADER_SERVICE_NAME = "pcp-service-name"
const val HEADER_EXCEPTION_CLASS = "pcp-exception-class"
const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message"
const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic"
const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition"
const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset"
}
}
@@ -0,0 +1,38 @@
package space.nstart.pcp.routepassportconsumer.config
import org.apache.kafka.clients.producer.ProducerConfig
import org.apache.kafka.common.serialization.StringSerializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.core.ProducerFactory
/**
* Явная producer-конфигурация для route-processing-service.
*
* В этом модуле Kafka producer bean'ы не поднимались автоконфигурацией,
* поэтому template регистрируется явно.
*/
@Configuration
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
class KafkaProducerConfig(
@param:Value("\${spring.kafka.bootstrap-servers}")
private val bootstrapServers: String,
) {
@Bean
fun producerFactory(): ProducerFactory<String, String> =
DefaultKafkaProducerFactory(
mutableMapOf<String, Any>(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
),
)
@Bean
fun kafkaTemplate(): KafkaTemplate<String, String> = KafkaTemplate(producerFactory())
}
@@ -0,0 +1,32 @@
package space.nstart.pcp.routepassportconsumer.config
import jakarta.validation.constraints.NotBlank
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated
/**
* Типизированные Kafka topic-настройки сервиса.
*
* Все значения приходят из spring-cloud-config-server и валидируются при старте,
* чтобы сервис не запускался без обязательных имен входного и выходных topic'ов.
*/
@Validated
@ConfigurationProperties(prefix = "app.kafka.topics")
data class KafkaTopicsProperties(
/** Topic, из которого сервис читает входящие паспорта маршрутов. */
@field:NotBlank
val input: String = "",
/** DLQ topic для входящих паспортов маршрутов, которые не удалось обработать. */
@field:NotBlank
val inputDlq: String = "",
/** Topic, в который сервис будет публиковать сообщение об обработанном маршруте. */
@field:NotBlank
val processedRoute: String = "",
/** Topic, в который сервис будет публиковать сообщение геопривязки маршрута. */
@field:NotBlank
val routeGeoreference: String = "",
)
@@ -0,0 +1,23 @@
package space.nstart.pcp.routepassportconsumer.config
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Базовая OpenAPI-конфигурация для route-processing-service.
*/
@Configuration
class OpenApiConfig {
@Bean
fun routeProcessingOpenApi(): OpenAPI {
return OpenAPI()
.info(
Info()
.title("PCP Route Processing Service API")
.description("REST API сервиса обработки паспортов маршрутов PCP.")
.version("v1"),
)
}
}
@@ -0,0 +1,25 @@
package space.nstart.pcp.routepassportconsumer.config
import jakarta.validation.constraints.Min
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated
/**
* Настройки bounded retry для input Kafka consumer route passport flow.
*/
@Validated
@ConfigurationProperties(prefix = "app.route-passport.consumer.retry")
data class RoutePassportConsumerRetryProperties(
/** Максимальное количество попыток обработки, включая первичную delivery. */
@field:Min(1)
val maxAttempts: Long = 3,
/** Начальная задержка retry в миллисекундах. */
@field:Min(0)
val backoffMs: Long = 1_000,
/** Максимальная задержка между retry в миллисекундах. */
@field:Min(0)
val maxBackoffMs: Long = 30_000,
)
@@ -0,0 +1,45 @@
package space.nstart.pcp.routepassportconsumer.controller
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.core.io.ClassPathResource
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import java.nio.charset.StandardCharsets
@RestController
@RequestMapping("/api/route-processing")
@Tag(name = "Route Processing", description = "Тестовые endpoint'ы сервиса обработки маршрутов")
class RouteProcessingTestController(
private val kafkaTemplate: KafkaTemplate<String, String>,
private val kafkaTopicsProperties: KafkaTopicsProperties,
) {
@GetMapping("/test")
@Operation(summary = "Проверка доступности сервиса")
fun test(): Map<String, String> = mapOf(
"status" to "ok",
"service" to "pcp-route-processing-service",
)
@PostMapping("/test/send-passport-message")
@Operation(summary = "Отправить тестовый паспорт маршрута в Kafka")
fun sendPassportMessage(): Map<String, String> {
val payload = ClassPathResource("test-passport-message.json")
.inputStream
.readAllBytes()
.toString(StandardCharsets.UTF_8)
kafkaTemplate.send(kafkaTopicsProperties.input, payload)
return mapOf(
"status" to "sent",
"topic" to kafkaTopicsProperties.input,
"resource" to "classpath:test-passport-message.json",
)
}
}
@@ -0,0 +1,108 @@
package space.nstart.pcp.routepassportconsumer.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.persistence.Version
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
import tools.jackson.databind.JsonNode
import java.math.BigDecimal
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "route_passports")
class RoutePassportEntity(
@Id
@Column(name = "route_id", nullable = false)
var routeId: UUID,
@Column(name = "route_name_full", nullable = false, columnDefinition = "TEXT")
var routeNameFull: String,
@Column(name = "route_name_short", nullable = false, length = 255)
var routeNameShort: String,
@Column(name = "ka_short", nullable = false, length = 64)
var kaShort: String,
@Column(name = "ka_full", nullable = false, length = 255)
var kaFull: String,
@Column(name = "orbit_number", nullable = false)
var orbitNumber: Long,
@Column(name = "orbit_state", nullable = false, length = 64)
var orbitState: String,
@Column(name = "interval_begin", nullable = false)
var intervalBegin: LocalDateTime,
@Column(name = "interval_end", nullable = false)
var intervalEnd: LocalDateTime,
@Column(name = "roll_angle_min", nullable = false)
var rollAngleMin: BigDecimal,
@Column(name = "roll_angle_max", nullable = false)
var rollAngleMax: BigDecimal,
@Column(name = "visir_angle_min", nullable = false)
var visirAngleMin: BigDecimal,
@Column(name = "visir_angle_max", nullable = false)
var visirAngleMax: BigDecimal,
@Column(name = "resolution_range", nullable = false)
var resolutionRange: BigDecimal,
@Column(name = "resolution_azimuth", nullable = false)
var resolutionAzimuth: BigDecimal,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "polarisation", nullable = false, columnDefinition = "jsonb")
var polarisation: JsonNode,
@Column(name = "processing_level", nullable = false, length = 64)
var processingLevel: String,
@Column(name = "geometry_wkt", nullable = false, columnDefinition = "TEXT")
var geometryWkt: String,
@Column(name = "route_status", nullable = false, length = 32)
var routeStatus: String,
@Column(name = "trace_id", length = 255)
var traceId: String? = null,
@Column(name = "correlation_id", length = 255)
var correlationId: String? = null,
@Column(name = "source_topic", length = 255)
var sourceTopic: String? = null,
@Column(name = "source_partition")
var sourcePartition: Int? = null,
@Column(name = "source_offset")
var sourceOffset: Long? = null,
@Column(name = "source_key", length = 255)
var sourceKey: String? = null,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "raw_payload", columnDefinition = "jsonb")
var rawPayload: JsonNode? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime,
@Version
@Column(name = "version", nullable = false)
var version: Long = 0,
)
@@ -0,0 +1,69 @@
package space.nstart.pcp.routepassportconsumer.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
import tools.jackson.databind.JsonNode
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "route_passport_outbox")
class RoutePassportOutboxEntity(
@Id
@Column(name = "id", nullable = false)
var id: UUID,
@Column(name = "route_id", nullable = false)
var routeId: UUID,
@Column(name = "event_type", nullable = false, length = 128)
var eventType: String,
@Column(name = "topic", nullable = false, length = 255)
var topic: String,
@Column(name = "message_key", nullable = false, length = 255)
var messageKey: String,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload", nullable = false, columnDefinition = "jsonb")
var payload: JsonNode,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "headers", columnDefinition = "jsonb")
var headers: JsonNode? = null,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
var status: RoutePassportOutboxStatus,
@Column(name = "attempts", nullable = false)
var attempts: Int,
@Column(name = "max_attempts", nullable = false)
var maxAttempts: Int,
@Column(name = "next_attempt_at", nullable = false)
var nextAttemptAt: LocalDateTime,
@Column(name = "locked_until")
var lockedUntil: LocalDateTime? = null,
@Column(name = "last_error", columnDefinition = "TEXT")
var lastError: String? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime,
@Column(name = "published_at")
var publishedAt: LocalDateTime? = null,
)
@@ -0,0 +1,10 @@
package space.nstart.pcp.routepassportconsumer.entity
/**
* Состояние события outbox в жизненном цикле публикации.
*/
enum class RoutePassportOutboxStatus {
PENDING,
PUBLISHED,
FAILED,
}
@@ -0,0 +1,6 @@
package space.nstart.pcp.routepassportconsumer.exception
class RoutePassportContractException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)
@@ -0,0 +1,95 @@
package space.nstart.pcp.routepassportconsumer.listener
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.slf4j.LoggerFactory
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.kafka.support.Acknowledgment
import org.springframework.stereotype.Component
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import space.nstart.pcp.routepassportconsumer.service.RoutePassportIngestionService
import space.nstart.pcp.routepassportconsumer.service.RoutePassportJsonParserService
import space.nstart.pcp.routepassportconsumer.service.RoutePassportSourceRecordMetadata
/**
* Kafka listener, который получает JSON паспорта маршрута и передаёт его в транзакционный ingestion.
*/
@Component
class RoutePassportMessageListener(
/** Сервис парсинга входного JSON сообщения в итоговый DTO. */
private val routePassportJsonParserService: RoutePassportJsonParserService,
/** Сервис транзакционного сохранения паспорта маршрута и outbox-событий. */
private val routePassportIngestionService: RoutePassportIngestionService,
/** Набор Kafka topic'ов, который приходит из config server и валидируется при старте. */
val kafkaTopicsProperties: KafkaTopicsProperties,
) {
private val log = LoggerFactory.getLogger(this::class.java)
/**
* Обрабатывает входное сообщение Kafka.
*
* Offset подтверждается только после успешного возврата из transactional ingestion.
*/
@KafkaListener(
topics = ["#{__listener.kafkaTopicsProperties.input}"],
groupId = "\${spring.kafka.consumer.group-id}",
)
fun listen(
record: ConsumerRecord<String, String>,
acknowledgment: Acknowledgment,
) {
val rawPayload = record.value()
val sourceRecord = RoutePassportSourceRecordMetadata(
topic = record.topic(),
partition = record.partition(),
offset = record.offset(),
key = record.key(),
)
try {
log.debug(
"Kafka record received: sourceTopic={}, partition={}, offset={}, key={}, payloadSize={}",
sourceRecord.topic,
sourceRecord.partition,
sourceRecord.offset,
sourceRecord.key,
rawPayload.length,
)
val parsedMessage = routePassportJsonParserService.parse(rawPayload)
routePassportIngestionService.ingest(
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
)
// Transactional ingest commits before returning; only then the Kafka offset is acknowledged.
acknowledgment.acknowledge()
log.info(
"Route passport ingested: sourceTopic={}, partition={}, offset={}, key={}, payloadSize={}, routeId={}, traceId={}, correlationId={}",
sourceRecord.topic,
sourceRecord.partition,
sourceRecord.offset,
sourceRecord.key,
rawPayload.length,
parsedMessage.routePassportDto.routeId,
parsedMessage.metadata.traceId,
parsedMessage.metadata.correlationId,
)
} catch (exception: Exception) {
log.error(
"Failed to process Kafka record: sourceTopic={}, partition={}, offset={}, key={}, error={}",
sourceRecord.topic,
sourceRecord.partition,
sourceRecord.offset,
sourceRecord.key,
exception.message,
exception,
)
throw exception
}
}
}
@@ -0,0 +1,26 @@
package space.nstart.pcp.routepassportconsumer.repository
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import java.util.UUID
interface RoutePassportOutboxRepository : JpaRepository<RoutePassportOutboxEntity, UUID> {
fun existsByRouteIdAndEventType(routeId: UUID, eventType: String): Boolean
@Query(
value = """
SELECT *
FROM route_passport_outbox
WHERE status = 'PENDING'
AND next_attempt_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY created_at
LIMIT :limit
FOR UPDATE SKIP LOCKED
""",
nativeQuery = true,
)
fun findDueForUpdateSkipLocked(@Param("limit") limit: Int): List<RoutePassportOutboxEntity>
}
@@ -0,0 +1,7 @@
package space.nstart.pcp.routepassportconsumer.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity
import java.util.UUID
interface RoutePassportRepository : JpaRepository<RoutePassportEntity, UUID>
@@ -0,0 +1,143 @@
package space.nstart.pcp.routepassportconsumer.service
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.LinearRing
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTWriter
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_types_lib.dto.routes.RawGeoJsonGeometryDto
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import tools.jackson.databind.JsonNode
/**
* Конвертирует поддерживаемую GeoJSON geometry входного паспорта маршрута в WKT.
*/
@Service
open class RouteGeometryConverter {
private val geometryFactory = GeometryFactory()
private val wktWriter = WKTWriter()
open fun convert(geometry: RawGeoJsonGeometryDto?): String {
if (geometry == null) {
throw RoutePassportContractException("Отсутствует обязательное поле geometry")
}
val jtsGeometry = when (geometry.type) {
"Polygon" -> toPolygon(geometry.coordinates, fieldName = "geometry.coordinates")
"MultiPolygon" -> toMultiPolygon(geometry.coordinates)
else -> throw RoutePassportContractException("Неподдерживаемый тип geometry: ${geometry.type}")
}
return writeCompactWkt(jtsGeometry)
}
private fun toMultiPolygon(coordinatesNode: JsonNode): Geometry {
requireArray(coordinatesNode, "geometry.coordinates")
if (coordinatesNode.size() == 0) {
throw RoutePassportContractException("MultiPolygon geometry.coordinates не должен быть пустым")
}
val polygons = coordinatesNode.mapIndexed { index, polygonNode ->
toPolygon(polygonNode, fieldName = "geometry.coordinates[$index]")
}.toTypedArray()
return geometryFactory.createMultiPolygon(polygons)
}
private fun toPolygon(
coordinatesNode: JsonNode,
fieldName: String,
): Polygon {
requireArray(coordinatesNode, fieldName)
if (coordinatesNode.size() == 0) {
throw RoutePassportContractException("Polygon $fieldName должен содержать хотя бы один linear ring")
}
val shell = toLinearRing(coordinatesNode[0], "$fieldName[0]")
val holes = coordinatesNode.drop(1).mapIndexed { index, ringNode ->
toLinearRing(ringNode, "$fieldName[${index + 1}]")
}.toTypedArray()
return try {
geometryFactory.createPolygon(shell, holes)
} catch (exception: IllegalArgumentException) {
throw RoutePassportContractException("Некорректная структура Polygon $fieldName", exception)
}
}
private fun toLinearRing(
ringNode: JsonNode,
fieldName: String,
): LinearRing {
requireArray(ringNode, fieldName)
if (ringNode.size() < MIN_LINEAR_RING_POINTS) {
throw RoutePassportContractException(
"Linear ring $fieldName должен содержать минимум $MIN_LINEAR_RING_POINTS точки",
)
}
val coordinates = ringNode.mapIndexed { index, coordinateNode ->
toCoordinate(coordinateNode, "$fieldName[$index]")
}.toTypedArray()
if (!coordinates.first().equals2D(coordinates.last())) {
throw RoutePassportContractException("Linear ring $fieldName должен быть замкнут")
}
return try {
geometryFactory.createLinearRing(coordinates)
} catch (exception: IllegalArgumentException) {
throw RoutePassportContractException("Некорректная структура linear ring $fieldName", exception)
}
}
private fun toCoordinate(
coordinateNode: JsonNode,
fieldName: String,
): Coordinate {
requireArray(coordinateNode, fieldName)
if (coordinateNode.size() < MIN_COORDINATE_VALUES) {
throw RoutePassportContractException("Точка $fieldName должна содержать минимум две координаты")
}
if (!coordinateNode[0].isNumber || !coordinateNode[1].isNumber) {
throw RoutePassportContractException("Точка $fieldName должна содержать числовые longitude и latitude")
}
return try {
Coordinate(
coordinateNode[0].decimalValue().toDouble(),
coordinateNode[1].decimalValue().toDouble(),
)
} catch (exception: RuntimeException) {
throw RoutePassportContractException("Точка $fieldName содержит некорректные координаты", exception)
}
}
private fun requireArray(
node: JsonNode,
fieldName: String,
) {
if (!node.isArray) {
throw RoutePassportContractException("Поле $fieldName должно быть массивом GeoJSON")
}
}
private fun writeCompactWkt(geometry: Geometry): String =
when (geometry.geometryType) {
"Polygon" -> wktWriter.write(geometry).removePrefix("POLYGON ").let { "POLYGON$it" }
"MultiPolygon" -> wktWriter.write(geometry).removePrefix("MULTIPOLYGON ").let { "MULTIPOLYGON$it" }
else -> wktWriter.write(geometry)
}
private companion object {
const val MIN_COORDINATE_VALUES = 2
const val MIN_LINEAR_RING_POINTS = 4
}
}
@@ -0,0 +1,200 @@
package space.nstart.pcp.routepassportconsumer.service
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportRepository
import tools.jackson.databind.JsonNode
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
import java.util.UUID
/**
* Сохраняет разобранный route passport и outbox-события в одной DB transaction.
*/
@Service
class RoutePassportIngestionService(
private val routePassportRepository: RoutePassportRepository,
private val routePassportOutboxRepository: RoutePassportOutboxRepository,
private val objectMapper: ObjectMapper,
private val kafkaTopicsProperties: KafkaTopicsProperties,
) {
@Transactional
fun ingest(
parsedMessage: ParsedRoutePassportMessage,
rawPayload: String,
sourceRecord: RoutePassportSourceRecordMetadata,
) {
val now = LocalDateTime.now()
val routePassportDto = parsedMessage.routePassportDto
val routePassportEntity = routePassportRepository.findById(routePassportDto.routeId)
.map { existingEntity ->
existingEntity.applyRoutePassport(
routePassportDto = routePassportDto,
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
updatedAt = now,
)
}
.orElseGet {
routePassportDto.toEntity(
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
now = now,
)
}
routePassportRepository.save(routePassportEntity)
createOutboxIfAbsent(
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
topic = kafkaTopicsProperties.processedRoute,
parsedMessage = parsedMessage,
now = now,
)
createOutboxIfAbsent(
eventType = PcpKafkaEvent.RouteGeoRefEvent,
topic = kafkaTopicsProperties.routeGeoreference,
parsedMessage = parsedMessage,
now = now,
)
}
private fun createOutboxIfAbsent(
eventType: PcpKafkaEvent,
topic: String,
parsedMessage: ParsedRoutePassportMessage,
now: LocalDateTime,
) {
val routeId = parsedMessage.routePassportDto.routeId
val eventTypeName = eventType.name
if (routePassportOutboxRepository.existsByRouteIdAndEventType(routeId, eventTypeName)) {
return
}
routePassportOutboxRepository.save(
RoutePassportOutboxEntity(
id = UUID.randomUUID(),
routeId = routeId,
eventType = eventTypeName,
topic = topic,
messageKey = routeId.toString(),
payload = buildOutboxPayload(eventType, parsedMessage),
headers = null,
status = RoutePassportOutboxStatus.PENDING,
attempts = 0,
maxAttempts = 10,
nextAttemptAt = now,
lockedUntil = null,
lastError = null,
createdAt = now,
updatedAt = now,
publishedAt = null,
),
)
}
private fun buildOutboxPayload(
eventType: PcpKafkaEvent,
parsedMessage: ParsedRoutePassportMessage,
): JsonNode {
val message = KafkaMessage(
type = eventType,
data = parsedMessage.routePassportDto,
traceId = parsedMessage.metadata.traceId,
correlationId = parsedMessage.metadata.correlationId,
group = "pcp",
).apply {
source = "route-processing"
}
return objectMapper.valueToTree(message)
}
private fun RoutePassportEntity.applyRoutePassport(
routePassportDto: RoutePassportDto,
parsedMessage: ParsedRoutePassportMessage,
rawPayload: String,
sourceRecord: RoutePassportSourceRecordMetadata,
updatedAt: LocalDateTime,
): RoutePassportEntity {
routeNameFull = routePassportDto.routeNameFull
routeNameShort = routePassportDto.routeNameShort
kaShort = routePassportDto.kaShort
kaFull = routePassportDto.kaFull
orbitNumber = routePassportDto.orbitNumber
orbitState = routePassportDto.orbitState
intervalBegin = routePassportDto.intervalBegin
intervalEnd = routePassportDto.intervalEnd
rollAngleMin = routePassportDto.rollAngle.min
rollAngleMax = routePassportDto.rollAngle.max
visirAngleMin = routePassportDto.visirAngle.min
visirAngleMax = routePassportDto.visirAngle.max
resolutionRange = routePassportDto.resolutionRange
resolutionAzimuth = routePassportDto.resolutionAzimuth
polarisation = objectMapper.valueToTree(routePassportDto.polarisation)
processingLevel = routePassportDto.processingLevel
geometryWkt = routePassportDto.geometry
routeStatus = routePassportDto.routeStatus.name
traceId = parsedMessage.metadata.traceId
correlationId = parsedMessage.metadata.correlationId
sourceTopic = sourceRecord.topic
sourcePartition = sourceRecord.partition
sourceOffset = sourceRecord.offset
sourceKey = sourceRecord.key
this.rawPayload = objectMapper.readTree(rawPayload)
this.updatedAt = updatedAt
return this
}
private fun RoutePassportDto.toEntity(
parsedMessage: ParsedRoutePassportMessage,
rawPayload: String,
sourceRecord: RoutePassportSourceRecordMetadata,
now: LocalDateTime,
): RoutePassportEntity =
RoutePassportEntity(
routeId = routeId,
routeNameFull = routeNameFull,
routeNameShort = routeNameShort,
kaShort = kaShort,
kaFull = kaFull,
orbitNumber = orbitNumber,
orbitState = orbitState,
intervalBegin = intervalBegin,
intervalEnd = intervalEnd,
rollAngleMin = rollAngle.min,
rollAngleMax = rollAngle.max,
visirAngleMin = visirAngle.min,
visirAngleMax = visirAngle.max,
resolutionRange = resolutionRange,
resolutionAzimuth = resolutionAzimuth,
polarisation = objectMapper.valueToTree(polarisation),
processingLevel = processingLevel,
geometryWkt = geometry,
routeStatus = routeStatus.name,
traceId = parsedMessage.metadata.traceId,
correlationId = parsedMessage.metadata.correlationId,
sourceTopic = sourceRecord.topic,
sourcePartition = sourceRecord.partition,
sourceOffset = sourceRecord.offset,
sourceKey = sourceRecord.key,
rawPayload = objectMapper.readTree(rawPayload),
createdAt = now,
updatedAt = now,
)
}
@@ -0,0 +1,108 @@
package space.nstart.pcp.routepassportconsumer.service
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
import space.nstart.pcp.pcp_types_lib.dto.routes.RawFeaturePassportJsonDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RawPassportEnvelopeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportMessageMetadata
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import tools.jackson.core.JacksonException
import tools.jackson.databind.ObjectMapper
import java.math.BigDecimal
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
import java.util.UUID
/**
* Сервис, который парсит JSON паспорта маршрута в итоговый DTO.
*/
@Service
class RoutePassportJsonParserService(
/** Jackson ObjectMapper, который выполняет базовую десериализацию JSON. */
private val objectMapper: ObjectMapper,
/** Converter GeoJSON geometry входного паспорта маршрута в WKT. */
private val routeGeometryConverter: RouteGeometryConverter,
) {
/**
* Парсит входной JSON и возвращает готовый DTO.
*
* @param json исходное Kafka-сообщение envelope-формата с полезной нагрузкой в поле `data`.
* @return DTO с уже преобразованными полями.
*/
fun parse(json: String): ParsedRoutePassportMessage {
val rawEnvelope: RawPassportEnvelopeDto = try {
objectMapper.readValue(json, RawPassportEnvelopeDto::class.java)
} catch (exception: JacksonException) {
throw RoutePassportContractException("Некорректный JSON contract паспорта маршрута", exception)
}
val routePassportDto = rawEnvelope.data.toRoutePassportDto()
return ParsedRoutePassportMessage(
metadata = RoutePassportMessageMetadata(
traceId = rawEnvelope.traceId,
correlationId = rawEnvelope.correlationId,
),
routePassportDto = routePassportDto,
)
}
/**
* Преобразует feature из envelope-сообщения в бизнес DTO.
*/
private fun RawFeaturePassportJsonDto.toRoutePassportDto(): RoutePassportDto {
val routePassportDto = RoutePassportDto(
routeId = routeId ?: UUID.nameUUIDFromBytes(id.toByteArray(StandardCharsets.UTF_8)),
kaShort = properties.missionId,
kaFull = properties.platform,
routeNameFull = id,
routeNameShort = collection,
orbitNumber = properties.orbitNumber.toLong(),
orbitState = properties.orbitState,
intervalBegin = LocalDateTime.parse(properties.intervalBegin),
intervalEnd = LocalDateTime.parse(properties.intervalEnd),
rollAngle = properties.rollAngle.toAngleRangeDto(fieldName = "rollAngle"),
visirAngle = properties.visirAngle.toAngleRangeDto(fieldName = "visirAngle"),
resolutionRange = properties.resolutionRange.toBigDecimal(),
resolutionAzimuth = properties.resolutionAzimuth.toBigDecimal(),
polarisation = properties.polarisation,
processingLevel = properties.processingLevel,
geometry = routeGeometryConverter.convert(geometry),
)
return routePassportDto
}
/**
* Преобразует список чисел в DTO диапазона.
*
* @receiver список значений диапазона.
* @param fieldName имя поля для текста ошибки.
* @return DTO диапазона с минимальным и максимальным значением.
*/
private fun List<BigDecimal>.toAngleRangeDto(fieldName: String): AngleRangeDto {
if (this.isEmpty()) {
throw RoutePassportContractException("Поле $fieldName не должно быть пустым")
}
/** Минимальное значение диапазона. */
val minValue: BigDecimal = this.minOrNull()
?: throw RoutePassportContractException("Не удалось определить минимум для поля $fieldName")
/** Максимальное значение диапазона. */
val maxValue: BigDecimal = this.maxOrNull()
?: throw RoutePassportContractException("Не удалось определить максимум для поля $fieldName")
/** DTO диапазона после вычисления минимума и максимума. */
val angleRangeDto: AngleRangeDto = AngleRangeDto(
min = minValue,
max = maxValue,
)
return angleRangeDto
}
}
@@ -0,0 +1,113 @@
package space.nstart.pcp.routepassportconsumer.service
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.LoggerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult
import org.springframework.stereotype.Service
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import tools.jackson.databind.ObjectMapper
import java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture
/**
* Сервис публикации обработанного маршрута в Kafka.
*/
@Service
class RoutePassportKafkaPublisherService(
/** ObjectMapper сериализует итоговый DTO в JSON перед отправкой. */
private val objectMapper: ObjectMapper,
/** KafkaTemplate отправляет одно и то же сообщение в несколько topic'ов. */
private val kafkaTemplate: KafkaTemplate<String, String>,
/** Набор Kafka topic'ов, в которые публикуется результат обработки. */
private val kafkaTopicsProperties: KafkaTopicsProperties,
) {
private val log = LoggerFactory.getLogger(this::class.java)
/**
* Публикует полный RoutePassportDto в оба выходных topic'а.
*/
fun publish(parsedMessage: ParsedRoutePassportMessage) {
val routePassportDto = parsedMessage.routePassportDto
log.info(
"Publishing route passport: routeNameFull={}, processedRouteTopic={}, routeGeoreferenceTopic={}",
routePassportDto.routeNameFull,
kafkaTopicsProperties.processedRoute,
kafkaTopicsProperties.routeGeoreference,
)
sendWithLogging(
topic = kafkaTopicsProperties.processedRoute,
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
payload = buildPayload(
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
parsedMessage = parsedMessage,
),
traceId = parsedMessage.metadata.traceId,
routeNameFull = routePassportDto.routeNameFull,
)
sendWithLogging(
topic = kafkaTopicsProperties.routeGeoreference,
eventType = PcpKafkaEvent.RouteGeoRefEvent,
payload = buildPayload(
eventType = PcpKafkaEvent.RouteGeoRefEvent,
parsedMessage = parsedMessage,
),
traceId = parsedMessage.metadata.traceId,
routeNameFull = routePassportDto.routeNameFull,
)
}
private fun buildPayload(eventType: PcpKafkaEvent, parsedMessage: ParsedRoutePassportMessage): String {
val message = KafkaMessage(
type = eventType,
data = parsedMessage.routePassportDto,
traceId = parsedMessage.metadata.traceId,
correlationId = parsedMessage.metadata.correlationId,
group = "pcp",
).apply {
source = "route-processing"
}
return objectMapper.writeValueAsString(message)
}
private fun sendWithLogging(
topic: String,
eventType: PcpKafkaEvent,
payload: String,
traceId: String,
routeNameFull: String,
) {
val record = ProducerRecord(topic, traceId, payload)
record.headers().add("type", eventType.name.toByteArray(StandardCharsets.UTF_8))
val future: CompletableFuture<SendResult<String, String>> = kafkaTemplate.send(record)
future.whenComplete { result, exception ->
if (exception == null) {
log.info(
"Kafka message published: routeNameFull={}, topic={}, type={}, partition={}, offset={}",
routeNameFull,
topic,
eventType,
result.recordMetadata.partition(),
result.recordMetadata.offset(),
)
} else {
log.error(
"Failed to publish kafka message: routeNameFull={}, topic={}, type={}",
routeNameFull,
topic,
eventType,
exception,
)
}
}
}
}
@@ -0,0 +1,11 @@
package space.nstart.pcp.routepassportconsumer.service
/**
* Metadata входной Kafka record, из которой был получен route passport.
*/
data class RoutePassportSourceRecordMetadata(
val topic: String,
val partition: Int,
val offset: Long,
val key: String?,
)
@@ -0,0 +1,28 @@
spring:
application:
name: pcp-route-processing-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.100.160:38888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
datasource:
driver-class-name: org.postgresql.Driver
url: ${PCP_ROUTE_PROCESSING_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/pcp_route_processing}
username: ${PCP_ROUTE_PROCESSING_DATASOURCE_USERNAME:postgres}
password: ${PCP_ROUTE_PROCESSING_DATASOURCE_PASSWORD:password}
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
@@ -0,0 +1,46 @@
CREATE TABLE route_passports (
route_id UUID PRIMARY KEY,
route_name_full TEXT NOT NULL,
route_name_short VARCHAR(255) NOT NULL,
ka_short VARCHAR(64) NOT NULL,
ka_full VARCHAR(255) NOT NULL,
orbit_number BIGINT NOT NULL,
orbit_state VARCHAR(64) NOT NULL,
interval_begin TIMESTAMP NOT NULL,
interval_end TIMESTAMP NOT NULL,
roll_angle_min NUMERIC NOT NULL,
roll_angle_max NUMERIC NOT NULL,
visir_angle_min NUMERIC NOT NULL,
visir_angle_max NUMERIC NOT NULL,
resolution_range NUMERIC NOT NULL,
resolution_azimuth NUMERIC NOT NULL,
polarisation JSONB NOT NULL,
processing_level VARCHAR(64) NOT NULL,
geometry_wkt TEXT NOT NULL,
route_status VARCHAR(32) NOT NULL,
trace_id VARCHAR(255),
correlation_id VARCHAR(255),
source_topic VARCHAR(255),
source_partition INTEGER,
source_offset BIGINT,
source_key VARCHAR(255),
raw_payload JSONB,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_route_passports_interval
ON route_passports (interval_begin, interval_end);
CREATE INDEX idx_route_passports_ka_orbit
ON route_passports (ka_short, orbit_number);
CREATE INDEX idx_route_passports_status
ON route_passports (route_status);
CREATE UNIQUE INDEX ux_route_passports_source_record
ON route_passports (source_topic, source_partition, source_offset)
WHERE source_topic IS NOT NULL
AND source_partition IS NOT NULL
AND source_offset IS NOT NULL;
@@ -0,0 +1,38 @@
CREATE TABLE route_passport_outbox (
id UUID PRIMARY KEY,
route_id UUID NOT NULL,
event_type VARCHAR(128) NOT NULL,
topic VARCHAR(255) NOT NULL,
message_key VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
headers JSONB,
status VARCHAR(32) NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 10,
next_attempt_at TIMESTAMP NOT NULL,
locked_until TIMESTAMP,
last_error TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
published_at TIMESTAMP,
CONSTRAINT fk_route_passport_outbox_route
FOREIGN KEY (route_id)
REFERENCES route_passports (route_id)
ON DELETE CASCADE,
CONSTRAINT ux_route_passport_outbox_route_event
UNIQUE (route_id, event_type),
CONSTRAINT chk_route_passport_outbox_status
CHECK (status IN ('PENDING', 'PUBLISHED', 'FAILED'))
);
CREATE INDEX idx_route_passport_outbox_due
ON route_passport_outbox (status, next_attempt_at, created_at);
CREATE INDEX idx_route_passport_outbox_locked_until
ON route_passport_outbox (locked_until);
CREATE INDEX idx_route_passport_outbox_route_id
ON route_passport_outbox (route_id);
@@ -0,0 +1,207 @@
{
"id": "51b07d12-1823-4bd5-b55d-de02a5304c03",
"traceid": "c0dd08ea-dd64-4633-8845-d1b163bdf506",
"correlationid": "db605d5f-77e9-45b4-82cc-709837306257",
"group": "stac_test",
"source": "stac-writer-service",
"type": "METADATA_NORMALIZED",
"time": "2026-03-31T09:39:13.251462",
"data": {
"id": "S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE",
"routeId": "51b07d12-1823-4bd5-b55d-de02a5304c03",
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
67.675827,
38.114815
],
[
64.779434,
38.514816
],
[
65.094017,
40.015545
],
[
68.05294,
39.616795
],
[
67.675827,
38.114815
]
]
]
},
"bbox": [
64.779434,
40.015545,
68.05294,
38.114815
],
"properties": {
"datetime": "2026-03-31T12:08:30.570573Z",
"created": null,
"updated": null,
"platform": "sentinel-1a",
"instruments": null,
"processing:level": "GRD",
"route:orbit_number": "59796",
"missionId": "S1A",
"sat:orbit_state": "Descending",
"polarisation": [
"VH",
"VV"
],
"rollAngle": [
30.07864486915724,
30.09378936608302
],
"visirAngle": [
30.63331560676386,
46.07086702096005
],
"intervalBegin": "2025-06-25T01:38:54.481190",
"intervalEnd": "2025-06-25T01:39:19.479675",
"resolutionRange": "1.000000e+01",
"resolutionAzimuth": "1.000000e+01"
},
"links": [
{
"rel": "self",
"href": "http://app-opensearch:8082/collections/S1A-GRDH/items/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE",
"type": "application/geo+json",
"title": null
},
{
"rel": "parent",
"href": "http://app-opensearch:8082/collections/S1A-GRDH",
"type": "application/json",
"title": null
},
{
"rel": "collection",
"href": "http://app-opensearch:8082/collections/S1A-GRDH",
"type": "application/json",
"title": null
},
{
"rel": "root",
"href": "http://app-opensearch:8082",
"type": "application/json",
"title": null
}
],
"assets": {
"schema-calibration-vh": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-calibration-vv": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-noise-vh": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-noise-vv": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-product-vh": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-product-vv": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"safe-manifest": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53",
"title": "safe",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"vh": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648",
"title": "tif",
"description": null,
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
"roles": [
"visual"
]
},
"vv": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb",
"title": "tif",
"description": null,
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
"roles": [
"visual"
]
},
"kml": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54",
"title": "kml",
"description": null,
"type": "application/kml",
"roles": [
"metadata"
]
},
"thumbnail": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0",
"title": "png",
"description": null,
"type": "image/png",
"roles": [
"thumbnail",
"overview"
]
}
},
"collection": "S1A-GRDH",
"stac_version": "1.0.0",
"stac_extensions": null
},
"datacontenttype": "application/json",
"specversion": "1.0"
}