From 551365c977f179f1c22aa9888955f1b2ef3aec08 Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:31:31 +0800 Subject: [PATCH 01/11] feat: dexcom connector --- dexcom-library/build.gradle | 47 +++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 2 ++ 2 files changed, 49 insertions(+) create mode 100644 dexcom-library/build.gradle diff --git a/dexcom-library/build.gradle b/dexcom-library/build.gradle new file mode 100644 index 00000000..5de05826 --- /dev/null +++ b/dexcom-library/build.gradle @@ -0,0 +1,47 @@ + +group = 'org.radarbase' +version = '0.0.1' + +apply plugin: 'maven-publish' + +repositories { + // Use jcenter for resolving dependencies. + // You can declare any Maven/Ivy/file repository here. + mavenCentral() +} + +dependencies { + // Use the Kotlin JDK 8 standard library. + implementation libs.kotlin.stdlib + + implementation libs.okhttp + + implementation libs.radar.schemas.commons + + implementation libs.jackson.annotations + + implementation libs.jackson.databind + + implementation libs.avro + + implementation libs.jackson.datatype.jsr310 + + // Use the Kotlin test library. + testImplementation libs.kotlin.test + + // Use the Kotlin JUnit integration. + testImplementation libs.kotlin.test.junit +} + +project.afterEvaluate { + publishing { + publications { + library(MavenPublication) { + setGroupId "$group" + setArtifactId "dexcom-library" + version "$version" + from components.java + } + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 64f23944..2a682abe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,8 @@ include(":kafka-connect-fitbit-source") include(":kafka-connect-rest-source") include(":kafka-connect-oura-source") include(":oura-library") +include(":dexcom-library") +include(":kafka-connect-dexcom-source") pluginManagement { repositories { From e9774bd5adbfeab908b56add771021a4dbc98cbb Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:07:23 +0800 Subject: [PATCH 02/11] feat: dexcom initial files --- .../radarbase/dexcom/converter/TopicData.kt | 0 .../dexcom/request/DexcomOffsetManager.kt | 0 .../dexcom/request/RequestGenerator.kt | 0 .../radarbase/dexcom/request/RestRequest.kt | 14 +++++++++ .../org/radarbase/dexcom/route/DexcomRoute.kt | 29 +++++++++++++++++++ .../org/radarbase/dexcom/route/Route.kt | 23 +++++++++++++++ .../kotlin/org/radarbase/dexcom/user/User.kt | 22 ++++++++++++++ .../radarbase/dexcom/user/UserRepository.kt | 14 +++++++++ 8 files changed, 102 insertions(+) create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/TopicData.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomOffsetManager.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RequestGenerator.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RestRequest.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRoute.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/Route.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/user/User.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/user/UserRepository.kt diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/TopicData.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/TopicData.kt new file mode 100644 index 00000000..e69de29b diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomOffsetManager.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomOffsetManager.kt new file mode 100644 index 00000000..e69de29b diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RequestGenerator.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RequestGenerator.kt new file mode 100644 index 00000000..e69de29b diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RestRequest.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RestRequest.kt new file mode 100644 index 00000000..acb988d9 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RestRequest.kt @@ -0,0 +1,14 @@ +package org.radarbase.dexcom.request + +import okhttp3.Request +import org.radarbase.dexcom.route.DexcomRoute +import org.radarbase.dexcom.user.User +import java.time.Instant + +data class RestRequest( + val request: Request, + val user: User, + val route: DexcomRoute, + val startDate: Instant, + val endDate: Instant, +) diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRoute.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRoute.kt new file mode 100644 index 00000000..66fccbdc --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRoute.kt @@ -0,0 +1,29 @@ +package org.radarbase.dexcom.route + +import okhttp3.Request +import org.radarbase.dexcom.request.RestRequest +import org.radarbase.dexcom.user.User +import org.radarbase.dexcom.user.UserRepository +import java.time.Duration + +abstract class DexcomRoute( + private val userRepository: UserRepository, + override val maxIntervalPerRequest: Duration = DEFAULT_INTERVAL_PER_REQUEST, +) : Route { + + fun createRequest(user: User, baseUrl: String, queryParams: String): Request { + val accessToken = userRepository.getAccessToken(user) + val request = + Request.Builder() + .url(baseUrl + queryParams) + .header("Authorization", "Bearer " + accessToken) + .get() + .build() + + return request + } + + companion object { + private val DEFAULT_INTERVAL_PER_REQUEST = Duration.ofDays(30L) + } +} diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/Route.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/Route.kt new file mode 100644 index 00000000..e33d3a6a --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/Route.kt @@ -0,0 +1,23 @@ +package org.radarbase.dexcom.route + +import org.radarbase.dexcom.request.RestRequest +import org.radarbase.dexcom.user.User +import java.time.Duration +import java.time.Instant + +interface Route { + + fun generateRequests(user: User, start: Instant, end: Instant): Sequence + + fun generateRequests(user: User, start: Instant, end: Instant, max: Int): Sequence + + /** + * This is how it would appear in the offsets + */ + override fun toString(): String + + /** + * The number of days to request in a single request of this route. + */ + val maxIntervalPerRequest: Duration +} \ No newline at end of file diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/user/User.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/user/User.kt new file mode 100644 index 00000000..da7414b6 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/user/User.kt @@ -0,0 +1,22 @@ +package org.radarbase.dexcom.user + +import org.radarcns.kafka.ObservationKey +import java.time.Instant + +interface User { + val id: String + val projectId: String + val userId: String + val sourceId: String + val externalId: String? + val startDate: Instant + val endDate: Instant? + val createdAt: Instant + val humanReadableUserId: String? + val serviceUserId: String? + val version: String? + val isAuthorized: Boolean + + val observationKey: ObservationKey + val versionedId: String +} diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/user/UserRepository.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/user/UserRepository.kt new file mode 100644 index 00000000..438acf85 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/user/UserRepository.kt @@ -0,0 +1,14 @@ +package org.radarbase.dexcom.user + +import java.io.IOException + +interface UserRepository { + @Throws(IOException::class) + operator fun get(key: String): User? + + @Throws(IOException::class) + fun stream(): Sequence + + @Throws(IOException::class) + fun getAccessToken(user: User): String +} From c38aa09ca4d7ade84d22c66b5ff556a24ca199b6 Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:45:24 +0800 Subject: [PATCH 03/11] feat: dexcom topicdata --- .../org/radarbase/dexcom/converter/TopicData.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/TopicData.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/TopicData.kt index e69de29b..859e634f 100644 --- a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/TopicData.kt +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/TopicData.kt @@ -0,0 +1,11 @@ +package org.radarbase.dexcom.converter + +import org.apache.avro.specific.SpecificRecord + +/** Single value for a topic. */ +data class TopicData( + val topic: String, + val key: SpecificRecord, + val value: SpecificRecord, + val offset: Long, +) \ No newline at end of file From 48f9da502cac79a30a6800133100ec76ca1d124e Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:47:05 +0800 Subject: [PATCH 04/11] feat: offsets --- .../main/kotlin/org/radarbase/dexcom/offset/Offset.kt | 11 +++++++++++ .../kotlin/org/radarbase/dexcom/offset/Offsets.kt | 5 +++++ 2 files changed, 16 insertions(+) create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt new file mode 100644 index 00000000..bb1c9361 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt @@ -0,0 +1,11 @@ +package org.radarbase.dexcom.offset + +import org.radarbase.dexcom.route.Route +import org.radarbase.dexcom.user.User +import java.time.Instant + +data class Offset( + val user: User, + val route: Route, + val offset: Instant, +) diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt new file mode 100644 index 00000000..bef09e35 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt @@ -0,0 +1,5 @@ +package org.radarbase.dexcom.offset + +data class Offsets( + val offsets: List, +) \ No newline at end of file From 9fb32ae322401f4bfe7a4354af247e534daa84b4 Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:00:32 +0800 Subject: [PATCH 05/11] feat: dexcom EGV route --- .../radarbase/dexcom/route/DexcomEGVRoute.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt new file mode 100644 index 00000000..2661df44 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt @@ -0,0 +1,25 @@ +package org.radarbase.dexcom.route + +import org.radarbase.dexcom.request.RestRequest +import org.radarbase.dexcom.user.User +import org.radarbase.dexcom.user.UserRepository +import java.time.Instant + +class DexcomEGVRoute( + userRepository: UserRepository, +) : DexcomRoute(userRepository) { + override fun toString(): String = "egv" + + override fun generateRequests( + user: User, + start: Instant, + end: Instant, + ): Sequence = emptySequence() + + override fun generateRequests( + user: User, + start: Instant, + end: Instant, + max: Int, + ): Sequence = emptySequence() +} From afdd0e6ff06a75021c750f15137388e8d133baed Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:31:02 +0800 Subject: [PATCH 06/11] feat: dexcom convertor: EGV --- .../dexcom/converter/DexcomDataConverter.kt | 37 +++++++++++++++++++ .../dexcom/converter/DexcomEGVConverter.kt | 11 ++++++ .../dexcom/converter/RecordConverter.kt | 19 ++++++++++ 3 files changed, 67 insertions(+) create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/DexcomDataConverter.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/DexcomEGVConverter.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/RecordConverter.kt diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/DexcomDataConverter.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/DexcomDataConverter.kt new file mode 100644 index 00000000..e92ee722 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/DexcomDataConverter.kt @@ -0,0 +1,37 @@ +package org.radarbase.dexcom.converter + +import com.fasterxml.jackson.databind.JsonNode +import okhttp3.Headers +import org.radarbase.dexcom.request.DexcomRequestGenerator.Companion.JSON_READER +import org.radarbase.dexcom.request.RestRequest +import org.radarbase.dexcom.user.User +import java.time.Instant + +interface DexcomDataConverter : RecordConverter { + fun processRecords( + root: JsonNode, + user: User, + ): Sequence> + + override fun convert( + request: RestRequest, + headers: Headers, + data: ByteArray, + ): List { + val node = JSON_READER.readTree(data) + + return processRecords(node, request.user) + .mapNotNull { result -> + result.fold( + { it }, + { + RecordConverter.logger.error("Data conversion failed: ${it.message}") + null + }, + ) + } + .toList() + } + + fun Instant.toEpoch(): Long = toEpochMilli() / 1000 +} diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/DexcomEGVConverter.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/DexcomEGVConverter.kt new file mode 100644 index 00000000..fe89bf93 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/DexcomEGVConverter.kt @@ -0,0 +1,11 @@ +package org.radarbase.dexcom.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.radarbase.dexcom.user.User + +class DexcomEGVConverter : DexcomDataConverter { + override fun processRecords( + root: JsonNode, + user: User, + ): Sequence> = emptySequence() +} diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/RecordConverter.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/RecordConverter.kt new file mode 100644 index 00000000..b05ac022 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/converter/RecordConverter.kt @@ -0,0 +1,19 @@ +package org.radarbase.dexcom.converter + +import okhttp3.Headers +import org.radarbase.dexcom.request.RestRequest +import org.slf4j.LoggerFactory +import java.io.IOException + +interface RecordConverter { + @Throws(IOException::class) + fun convert( + request: RestRequest, + headers: Headers, + data: ByteArray, + ): List + + companion object { + var logger = LoggerFactory.getLogger(RecordConverter::class.java) + } +} From 0807c020847f0cbd39437340933e5be9dfc67585 Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:36:15 +0800 Subject: [PATCH 07/11] feat: offsets --- .../src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt | 2 +- .../src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt index bb1c9361..d2279dd4 100644 --- a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offset.kt @@ -8,4 +8,4 @@ data class Offset( val user: User, val route: Route, val offset: Instant, -) +) \ No newline at end of file diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt index bef09e35..3681411b 100644 --- a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/offset/Offsets.kt @@ -1,5 +1,7 @@ package org.radarbase.dexcom.offset +import org.radarbase.dexcom.request.Offset + data class Offsets( val offsets: List, -) \ No newline at end of file +) From 07ac68d23d9ce094b3143bcfd197fe3301745af5 Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:44:14 +0800 Subject: [PATCH 08/11] feat: dexcom route --- .../radarbase/dexcom/route/DexcomEGVRoute.kt | 19 ++---- .../org/radarbase/dexcom/route/DexcomRoute.kt | 59 ++++++++++++++++--- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt index 2661df44..0b7897e8 100644 --- a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt @@ -1,25 +1,14 @@ package org.radarbase.dexcom.route -import org.radarbase.dexcom.request.RestRequest -import org.radarbase.dexcom.user.User +import org.radarbase.dexcom.converter.DexcomEGVConverter import org.radarbase.dexcom.user.UserRepository -import java.time.Instant class DexcomEGVRoute( userRepository: UserRepository, ) : DexcomRoute(userRepository) { - override fun toString(): String = "egv" + override fun subPath(): String = "egvs" - override fun generateRequests( - user: User, - start: Instant, - end: Instant, - ): Sequence = emptySequence() + override fun toString(): String = "dexcom_egv" - override fun generateRequests( - user: User, - start: Instant, - end: Instant, - max: Int, - ): Sequence = emptySequence() + override val converters = listOf(DexcomEGVConverter()) } diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRoute.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRoute.kt index 66fccbdc..263a48a8 100644 --- a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRoute.kt +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRoute.kt @@ -1,29 +1,72 @@ package org.radarbase.dexcom.route import okhttp3.Request +import org.radarbase.dexcom.converter.DexcomDataConverter import org.radarbase.dexcom.request.RestRequest import org.radarbase.dexcom.user.User import org.radarbase.dexcom.user.UserRepository import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter abstract class DexcomRoute( private val userRepository: UserRepository, override val maxIntervalPerRequest: Duration = DEFAULT_INTERVAL_PER_REQUEST, ) : Route { + abstract val converters: List fun createRequest(user: User, baseUrl: String, queryParams: String): Request { val accessToken = userRepository.getAccessToken(user) - val request = - Request.Builder() - .url(baseUrl + queryParams) - .header("Authorization", "Bearer " + accessToken) - .get() - .build() - - return request + return Request.Builder() + .url(baseUrl + queryParams) + .header("Authorization", "Bearer $accessToken") + .get() + .build() } + override fun generateRequests( + user: User, + start: Instant, + end: Instant, + ): Sequence { + val request = createRequest( + user, + "$DEXCOM_API_BASE_URL/${subPath()}", + "?startDate=${start.toDexcomDate()}&endDate=${end.toDexcomDate()}", + ) + return sequenceOf(RestRequest(request, user, this, start, end)) + } + + override fun generateRequests( + user: User, + start: Instant, + end: Instant, + max: Int, + ): Sequence { + return generateSequence(start) { it + maxIntervalPerRequest } + .takeWhile { it < end } + .take(max) + .map { startRange -> + val endRange = (startRange + maxIntervalPerRequest).coerceAtMost(end) + val request = createRequest( + user, + "$DEXCOM_API_BASE_URL/${subPath()}", + "?startDate=${startRange.toDexcomDate()}&endDate=${endRange.toDexcomDate()}", + ) + RestRequest(request, user, this, startRange, endRange) + } + } + + abstract fun subPath(): String + + fun Instant.toDexcomDate(): String = + LocalDateTime.ofInstant(this, ZoneOffset.UTC).format(DEXCOM_DATE_FORMAT) + companion object { + const val DEXCOM_API_BASE_URL = "https://api.dexcom.com/v3/users/self" + private val DEXCOM_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") private val DEFAULT_INTERVAL_PER_REQUEST = Duration.ofDays(30L) } } From 2199d251365367935db71c34a550a301102dd568 Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:45:53 +0800 Subject: [PATCH 09/11] feat: dexcom requests --- .../dexcom/request/DexcomOffsetManager.kt | 18 ++ .../dexcom/request/DexcomRequestGenerator.kt | 304 ++++++++++++++++++ .../radarbase/dexcom/request/DexcomResult.kt | 64 ++++ .../dexcom/request/RequestGenerator.kt | 23 ++ .../request/TooManyRequestsException.kt | 3 + 5 files changed, 412 insertions(+) create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomRequestGenerator.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomResult.kt create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/TooManyRequestsException.kt diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomOffsetManager.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomOffsetManager.kt index e69de29b..842a4b36 100644 --- a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomOffsetManager.kt +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomOffsetManager.kt @@ -0,0 +1,18 @@ +package org.radarbase.dexcom.request + +import org.radarbase.dexcom.route.Route +import org.radarbase.dexcom.user.User +import java.time.Instant + +data class Offset( + val user: User, + val route: Route, + val offset: Instant, +) + +interface DexcomOffsetManager { + + fun getOffset(route: Route, user: User): Offset? + + fun updateOffsets(route: Route, user: User, offset: Instant) +} diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomRequestGenerator.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomRequestGenerator.kt new file mode 100644 index 00000000..3cd00e58 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomRequestGenerator.kt @@ -0,0 +1,304 @@ +package org.radarbase.dexcom.request + +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import okhttp3.Response +import okhttp3.ResponseBody +import org.radarbase.dexcom.converter.TopicData +import org.radarbase.dexcom.route.DexcomRouteFactory +import org.radarbase.dexcom.route.Route +import org.radarbase.dexcom.user.User +import org.radarbase.dexcom.user.UserRepository +import org.slf4j.LoggerFactory +import java.io.IOException +import java.time.Duration +import java.time.Instant + +class DexcomRequestGenerator +@JvmOverloads +constructor( + private val userRepository: UserRepository, + private val dexcomOffsetManager: DexcomOffsetManager, + val routes: List = DexcomRouteFactory.getRoutes(userRepository), + private val defaultQueryRange: Duration = Duration.ofDays(15), +) : RequestGenerator { + private val routeNextRequest: MutableMap = mutableMapOf() + + var nextRequestTime: Instant = Instant.MIN + + private val shouldBackoff: Boolean + get() = Instant.now() < nextRequestTime + + override fun requests( + user: User, + max: Int, + ): Sequence { + return routes.asSequence() + .flatMap { route -> + if (routeReady(user, route)) { + generateRequests(route, user) + } else { + logger.info( + "Skip {} for {}: route in backoff until {}", + route, + user.versionedId, + routeNextRequest[routeKey(route, user)], + ) + emptySequence() + } + } + } + + override fun requests( + route: Route, + max: Int, + ): Sequence { + return userRepository + .stream() + .flatMap { user -> + if (routeReady(user, route)) { + generateRequests(route, user) + } else { + logger.info( + "Skip {} for {}: route in backoff until {}", + route, + user.versionedId, + routeNextRequest[routeKey(route, user)], + ) + emptySequence() + } + } + } + + override fun requests( + route: Route, + user: User, + max: Int, + ): Sequence { + return if (routeReady(user, route)) { + generateRequests(route, user) + } else { + logger.info( + "Skip {} for {}: route in backoff until {}", + route, + user.versionedId, + routeNextRequest[routeKey(route, user)], + ) + emptySequence() + } + } + + fun generateRequests( + route: Route, + user: User, + ): Sequence { + val offset = dexcomOffsetManager.getOffset(route, user) + val startDate = user.startDate + val startOffset: Instant = + if (offset == null) { + logger.info("No offsets found for $user, using the start date.") + startDate + } else { + val offsetTime = offset.offset + logger.info("Offsets found in persistence: $offsetTime") + offsetTime.coerceAtLeast(startDate) + } + val endDate = user.endDate?.coerceAtMost(Instant.now()) ?: Instant.now() + if (!startOffset.isBefore(endDate)) { + val userEnd = user.endDate + if (userEnd != null && endDate == userEnd && + Duration.between(userEnd, Instant.now()) > Duration.ofDays(30) + ) { + val key = routeKey(route, user) + routeNextRequest[key] = Instant.MAX + logger.info( + "Disable future requests for {}: user={}, endDate={} (>30d ago), startOffset={}", + route, + user.versionedId, + userEnd, + startOffset, + ) + } + logger.info( + "Skip {} for {}: interval empty (startOffset={} >= endDate={}), " + + "persistedOffset={}, userStartDate={}", + route, + user.versionedId, + startOffset, + endDate, + offset?.offset, + startDate, + ) + return emptySequence() + } + val timeSinceStart = Duration.between(startOffset, Instant.now()) + return if (timeSinceStart > HISTORICAL_DATA_THRESHOLD) { + val endTime = (startOffset + HISTORICAL_QUERY_RANGE).coerceAtMost(endDate) + route.generateRequests(user, startOffset, endTime) + } else { + route.generateRequests(user, startOffset, endDate, USER_MAX_REQUESTS) + } + } + + fun handleResponse( + req: RestRequest, + response: Response, + ): DexcomResult> { + if (response.isSuccessful) { + return DexcomResult.Success(requestSuccessful(req, response)) + } + return try { + DexcomResult.Error(requestFailed(req, response)) + } catch (e: TooManyRequestsException) { + DexcomResult.Success(emptyList()) + } + } + + override fun requestSuccessful( + request: RestRequest, + response: Response, + ): List { + logger.debug("Request successful: {}..", request.request) + val body: ResponseBody = response.body ?: return emptyList() + val data = body.bytes() + val records = + request.route.converters.flatMap { it.convert(request, response.headers, data) } + val offset = records.maxByOrNull { it.offset }?.offset + if (offset != null) { + logger.info("Writing ${records.size} records to offsets...") + val maxOffsetTime = Instant.ofEpochSecond(offset) + val dataAge = Duration.between(maxOffsetTime, Instant.now()) + val nextOffset = if (dataAge <= Duration.ofDays(7)) { + maxOffsetTime.plus(OFFSET_BUFFER) + } else { + maxOf(maxOffsetTime.plus(OFFSET_BUFFER), request.endDate) + } + dexcomOffsetManager.updateOffsets( + request.route, + request.user, + nextOffset, + ) + val nextRequestTime = Instant.now().plus(SUCCESS_BACK_OFF_TIME) + val key = routeKey(request.route, request.user) + routeNextRequest[key] = + routeNextRequest[key]?.let { if (it > nextRequestTime) it else nextRequestTime } + ?: nextRequestTime + } else { + if (request.startDate.plus(TIME_AFTER_REQUEST).isBefore(Instant.now())) { + logger.info("No records found, updating offsets to end date..") + dexcomOffsetManager.updateOffsets( + request.route, + request.user, + request.endDate, + ) + val key = routeKey(request.route, request.user) + routeNextRequest[key] = Instant.now().plus(SUCCESS_BACK_OFF_TIME) + } else { + val key = routeKey(request.route, request.user) + routeNextRequest[key] = Instant.now().plus(BACK_OFF_TIME) + } + } + return records + } + + override fun requestFailed( + request: RestRequest, + response: Response, + ): DexcomError { + return when (response.code) { + 429 -> { + logger.info("Too many requests, rate limit reached. Backing off...") + nextRequestTime = Instant.now().plus(BACK_OFF_TIME) + DexcomRateLimitError("Rate limit reached.", TooManyRequestsException(), "429") + } + 403 -> { + logger.warn( + "User ${request.user} has expired. Please renew the subscription.", + ) + routeNextRequest[routeKey(request.route, request.user)] = + Instant.now().plus(USER_BACK_OFF_TIME) + DexcomAccessForbiddenError( + "Dexcom subscription has expired or API data not available.", + IOException("Forbidden"), + "403", + ) + } + 401 -> { + logger.warn( + "User ${request.user} access token is expired, malformed, or revoked. " + + response.body?.string(), + ) + routeNextRequest[routeKey(request.route, request.user)] = + Instant.now().plus(USER_BACK_OFF_TIME) + DexcomUnauthorizedAccessError( + "Access token expired or revoked.", + IOException("Unauthorized"), + "401", + ) + } + 400 -> { + logger.warn("Client exception.") + nextRequestTime = Instant.now().plus(BACK_OFF_TIME) + routeNextRequest[routeKey(request.route, request.user)] = + Instant.now().plus(BACK_OFF_TIME) + DexcomClientException( + "Client unsupported or unauthorized.", + IOException("Invalid client"), + "400", + ) + } + 422 -> { + logger.warn("Request failed: {}, {}", request, response) + routeNextRequest[routeKey(request.route, request.user)] = + Instant.now().plus(BACK_OFF_TIME) + DexcomValidationError( + response.body?.string().orEmpty(), + IOException("Validation error"), + "422", + ) + } + 404 -> { + logger.warn("Not found.") + routeNextRequest[routeKey(request.route, request.user)] = + Instant.now().plus(BACK_OFF_TIME) + DexcomNotFoundError( + response.body?.string().orEmpty(), + IOException("Data not found"), + "404", + ) + } + else -> { + logger.warn("Request failed: {}, {}", request, response) + routeNextRequest[routeKey(request.route, request.user)] = + Instant.now().plus(BACK_OFF_TIME) + DexcomGenericError( + response.body?.string().orEmpty(), + IOException("Unknown error"), + response.code.toString(), + ) + } + } + } + + private fun routeReady(user: User, route: Route): Boolean { + val key = routeKey(route, user) + return routeNextRequest[key]?.let { Instant.now().isAfter(it) } ?: true + } + + private fun routeKey(route: Route, user: User): String = user.versionedId + "#" + route + + companion object { + private val logger = LoggerFactory.getLogger(DexcomRequestGenerator::class.java) + private val BACK_OFF_TIME = Duration.ofMinutes(10L) + private val TIME_AFTER_REQUEST = Duration.ofDays(30) + private val USER_BACK_OFF_TIME = Duration.ofHours(12L) + private val SUCCESS_BACK_OFF_TIME = Duration.ofSeconds(10L) + private val OFFSET_BUFFER = Duration.ofHours(12) + private val USER_MAX_REQUESTS = 1000 + private val HISTORICAL_DATA_THRESHOLD = Duration.ofDays(365L) + private val HISTORICAL_QUERY_RANGE = Duration.ofDays(365L) + val JSON_FACTORY = JsonFactory() + val JSON_READER = ObjectMapper(JSON_FACTORY).registerModule(JavaTimeModule()).reader() + } +} diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomResult.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomResult.kt new file mode 100644 index 00000000..2f153de9 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/DexcomResult.kt @@ -0,0 +1,64 @@ +package org.radarbase.dexcom.request + +sealed class DexcomResult { + data class Success(val value: T) : DexcomResult() + data class Error(val error: DexcomError) : DexcomResult() +} + +sealed interface DexcomError + +sealed class DexcomErrorBase( + val message: String, + val cause: Exception? = null, + val code: String, +) : DexcomError + +class DexcomRateLimitError(message: String, cause: Exception? = null, code: String) : DexcomErrorBase( + message, + cause, + code, +) + +class DexcomClientException(message: String, cause: Exception? = null, code: String) : DexcomErrorBase( + message, + cause, + code, +) + +class DexcomUnauthorizedAccessError( + message: String, + cause: Exception? = null, + code: String, +) : DexcomErrorBase( + message, + cause, + code, +) + +class DexcomAccessForbiddenError( + message: String, + cause: Exception? = null, + code: String, +) : DexcomErrorBase( + message, + cause, + code, +) + +class DexcomValidationError(message: String, cause: Exception? = null, code: String) : DexcomErrorBase( + message, + cause, + code, +) + +class DexcomGenericError(message: String, cause: Exception? = null, code: String) : DexcomErrorBase( + message, + cause, + code, +) + +class DexcomNotFoundError(message: String, cause: Exception? = null, code: String) : DexcomErrorBase( + message, + cause, + code, +) diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RequestGenerator.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RequestGenerator.kt index e69de29b..2a3f00fa 100644 --- a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RequestGenerator.kt +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/RequestGenerator.kt @@ -0,0 +1,23 @@ +package org.radarbase.dexcom.request + +import okhttp3.Response +import org.radarbase.dexcom.converter.TopicData +import org.radarbase.dexcom.route.Route +import org.radarbase.dexcom.user.User + + +interface RequestGenerator { + + fun requests(user: User, max: Int): Sequence + + fun requests(route: Route, user: User, max: Int): Sequence + + fun requests(route: Route, max: Int): Sequence + + fun requestSuccessful(request: RestRequest, response: Response): List + + fun requestFailed(request: RestRequest, response: Response): DexcomError +} + + + diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/TooManyRequestsException.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/TooManyRequestsException.kt new file mode 100644 index 00000000..fe37406f --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/request/TooManyRequestsException.kt @@ -0,0 +1,3 @@ +package org.radarbase.dexcom.request + +class TooManyRequestsException : RuntimeException() From 6d7b786aca1e63f2719bf7107d6fb1d6079f5869 Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:46:23 +0800 Subject: [PATCH 10/11] feat: gradle change --- dexcom-library/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dexcom-library/build.gradle b/dexcom-library/build.gradle index 5de05826..56f0c6d2 100644 --- a/dexcom-library/build.gradle +++ b/dexcom-library/build.gradle @@ -26,6 +26,8 @@ dependencies { implementation libs.jackson.datatype.jsr310 + implementation libs.slf4j.api + // Use the Kotlin test library. testImplementation libs.kotlin.test From be4e226488049e995477eb31d099b9b12f2cbce7 Mon Sep 17 00:00:00 2001 From: Chin-Erdene Gantulga <81845324+Chin-eng@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:48:09 +0800 Subject: [PATCH 11/11] feat: dexcom route --- .../org/radarbase/dexcom/route/DexcomEGVRoute.kt | 4 ++-- .../org/radarbase/dexcom/route/DexcomRouteFactory.kt | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRouteFactory.kt diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt index 0b7897e8..b3844c6a 100644 --- a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomEGVRoute.kt @@ -1,6 +1,6 @@ package org.radarbase.dexcom.route -import org.radarbase.dexcom.converter.DexcomEGVConverter +import org.radarbase.dexcom.converter.DexcomDataConverter import org.radarbase.dexcom.user.UserRepository class DexcomEGVRoute( @@ -10,5 +10,5 @@ class DexcomEGVRoute( override fun toString(): String = "dexcom_egv" - override val converters = listOf(DexcomEGVConverter()) + override val converters: List = listOf(DexcomEGVConverter()) } diff --git a/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRouteFactory.kt b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRouteFactory.kt new file mode 100644 index 00000000..ee8daa73 --- /dev/null +++ b/dexcom-library/src/main/kotlin/org/radarbase/dexcom/route/DexcomRouteFactory.kt @@ -0,0 +1,12 @@ +package org.radarbase.dexcom.route + +import org.radarbase.dexcom.user.UserRepository + +object DexcomRouteFactory { + + fun getRoutes(userRepository: UserRepository): List { + return listOf( + DexcomEGVRoute(userRepository), + ) + } +} \ No newline at end of file