diff --git a/.dockerignore b/.dockerignore
index c046475..a3740ce 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,3 +4,4 @@
/out
/.git
/.gradle
+/gradle/wrapper
diff --git a/.editorconfig b/.editorconfig
index 58257a9..6deb38d 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -12,5 +12,9 @@ indent_style = space
indent_size = 4
continuation_indent_size = 8
+[*.kt]
+continuation_indent_size = 4
+disabled_rules=no-wildcard-imports
+
[*.gradle]
indent_size = 4
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 809d51f..8834029 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -43,7 +43,3 @@ jobs:
# Gradle check
- name: Check
run: ./gradlew check
-
- # Gradle integration test. Includes internal docker-compose management
- - name: Integration test
- run: ./gradlew integrationTest
diff --git a/Dockerfile b/Dockerfile
index f36bd9d..76d2e45 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,20 +26,20 @@ COPY ./src/ /code/src
RUN gradle distTar \
&& cd build/distributions \
&& tar xzf *.tar.gz \
- && rm *.tar.gz radar-gateway-*/lib/radar-gateway-*.jar
+ && rm *.tar.gz radar-push-endpoint-*/lib/radar-push-endpoint-*.jar
FROM openjdk:11-jre-slim
-MAINTAINER @blootsvoets
+MAINTAINER @yatharthranjan
-LABEL description="RADAR-base Gateway docker container"
+LABEL description="RADAR-base Push Api Gateway docker container"
-COPY --from=builder /code/build/distributions/radar-gateway-*/bin/* /usr/bin/
-COPY --from=builder /code/build/distributions/radar-gateway-*/lib/* /usr/lib/
-COPY --from=builder /code/build/libs/radar-gateway-*.jar /usr/lib/
+COPY --from=builder /code/build/distributions/radar-push-endpoint-*/bin/* /usr/bin/
+COPY --from=builder /code/build/distributions/radar-push-endpoint-*/lib/* /usr/lib/
+COPY --from=builder /code/build/libs/radar-push-endpoint-*.jar /usr/lib/
USER 101
EXPOSE 8090
-CMD ["radar-gateway"]
+CMD ["radar-push-endpoint"]
diff --git a/README.md b/README.md
index 813be32..b3e590a 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,31 @@
-# RADAR-Gateway
+# RADAR-PushEndpoint
-[![Build Status](https://travis-ci.org/RADAR-base/RADAR-Gateway.svg?branch=master)](https://travis-ci.org/RADAR-base/RADAR-Gateway)
-[![Codacy Badge](https://api.codacy.com/project/badge/Grade/79b2380112c5451181367ae16e112025)](https://www.codacy.com/app/RADAR-base/RADAR-Gateway?utm_source=github.com&utm_medium=referral&utm_content=RADAR-base/RADAR-Gateway&utm_campaign=Badge_Grade)
-[![Docker Build](https://img.shields.io/docker/build/radarbase/radar-gateway.svg)](https://cloud.docker.com/swarm/radarbase/repository/docker/radarbase/radar-gateway/general)
+[![Build Status](https://github.com/RADAR-base/RADAR-PushEndpoint/workflows/CI/badge.svg)](https://github.com/RADAR-base/RADAR-PushEndpoint/actions?query=workflow%3ACI+branch%3Adev+)
+[![Docker Build](https://img.shields.io/docker/cloud/build/radarbase/radar-push-endpoint)](https://hub.docker.com/repository/docker/radarbase/radar-push-endpoint)
-REST Gateway to the Apache Kafka, similar to the REST Proxy provided by Confluent. In addition, it does authentication and authorization, content validation and decompression if needed. It is available as a [docker image](https://hub.docker.com/r/radarbase/radar-gateway).
+RADAR Push Endpoint that exposes REST interface for push subscription based APIs to the Apache
+ Kafka.
## Configuration
-The [RADAR-Auth] library is used for authentication and authorization of users. Refer to the documentation there for a full description of the configuration options.
+Currently, Garmin is integrated. For adding more services, see the [Extending section](#extending).
+
+```yaml
+ pushIntegration:
+ # Push service specific config
+ garmin:
+ enabled: true
+ userRepositoryClass: "org.radarbase.push.integration.garmin.user.ServiceUserRepository"
+ service-n:
+ enabled: true
+ property-xyz: "value"
+```
+
+For Garmin, you will need to configure the endpoints in the [Garmin Developer Portal](https://healthapi.garmin.com/tools/updateEndpoints)
## Usage
-Start the REST Proxy with
+Start the Service with
```shell
docker-compose up -d --build
@@ -25,22 +38,127 @@ TOPIC=test
docker-compose exec kafka-1 kafka-topics --create --topic $TOPIC --bootstrap-server kafka-1:9092
```
-Now the gateway is accessible through and the [ManagementPortal] is available through
+Now the service is accessible through .
+Garmin endpoints are available at -
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+
+## Extending
+This section walks through add a new push service integration. These should be implemented in a
+ new package `org.radarbase.push.integration.`.
+
+### Resource
+Create a new Resource and configure the endpoints required by the push service integration. For
+ reference take a look at [GarminPushEndpoint](src/main/kotlin/org/radarbase/push/integration/garmin/resource/GarminPushEndpoint.kt)
+
+### User Repository
+Create a new UserRepository to provide user specific info and authorization info. This should
+ implement the interface [UserRepository](src/main/kotlin/org/radarbase/push/integration/common/user/UserRepository.kt).
+For reference, take a look at [ServiceUserRepository](src/main/kotlin/org/radarbase/push/integration/garmin/user/ServiceUserRepository.kt)
+
+### Auth Validator
+Create a new AuthValidator to check the requests and authorise with users provided by
+ the User Repository. This can be done by implementing the [AuthValidator](https://github.com/RADAR-base/radar-jersey/blob/master/src/main/kotlin/org/radarbase/jersey/auth/AuthValidator.kt)
+ interface provided by `radar-jersey` library.
+For reference, take a look at [GarminAuthValidator](src/main/kotlin/org/radarbase/push/integration/garmin/auth/GarminAuthValidator.kt)
+
+### Converter
+This is optional but will help keep the code consistent.
+Create Converters for converting data posted by the push service to Kafka records. This can be
+ done by implementing the [AvroConverter](src/main/kotlin/org/radarbase/push/integration/common/converter/AvroConverter.kt) interface.
+For reference, take a look at converter implementations in [garmin converter](src/main/kotlin/org/radarbase/push/integration/garmin/converter) package.
+
+### Configuration
+
+Firstly, create a Resource Enhancer to register all your required classes to Jersey Context.
+ Remember to use `named` to distinguish your service implementation.
+
+```kotlin
+class ServiceXIntegrationResourceEnhancer(private val config: Config) :
+ JerseyResourceEnhancer {
+
+ override fun ResourceConfig.enhance() {
+ packages(
+ "org.radarbase.push.integration.servicex.resource",
+ "org.radarbase.push.integration.servicex.filter"
+ )
+ }
+
+ override fun AbstractBinder.enhance() {
+
+ bind(config.pushIntegration.servicex.userRepository)
+ .to(UserRepository::class.java)
+ .named("servicex")
+ .`in`(Singleton::class.java)
+
+ bind(ServiceXAuthValidator::class.java)
+ .to(AuthValidator::class.java)
+ .named("servicex")
+ .`in`(Singleton::class.java)
+ }
+}
+```
-The access token should be generated by the aforementioned Management portal. The access token is a JWT (JSON Web Token) that should contain the `MEASUREMENT.CREATE` scope for resource `res_gateway`, and list all applicable sources to submit data for. The gateway does content validation for posted data. It requires to use the Avro format with JSON serialization, using the `application/vnd.kafka.avro.v1+json` or `application/vnd.kafka.avro.v2+json` media types, as described in the [REST Proxy documentation]. It also requires messages to have both a key and a value with schemas. The key should have a `userId` and `sourceId` field. The `userId` should match the `sub` field in the OAuth2 JWT access token. That JWT should also contain a `sources` array claim which should contain the given `sourceId`. Sources can be added in the ManagementPortal or be generated by the app dynamically and then registered with the ManagementPortal.
+Next, add your `AuthValidator` to the [DelegatedAuthValidator](src/main/kotlin/org/radarbase/push/integration/common/auth/DelegatedAuthValidator.kt) so service specific Auth can be performed.
+Make sure the path to your service's resources contain the matching string (`servicex` in this
+ case).
+
+```kotlin
+...
+
+ fun delegate(): AuthValidator {
+ return when {
+ uriInfo.matches(GARMIN_QUALIFIER) -> namedValidators.named(GARMIN_QUALIFIER).get()
+ uriInfo.matches("servicex") -> namedValidators.named("servicex").get()
+ // Add support for more as integrations are added
+ else -> throw IllegalStateException()
+ }
+ }
+
+...
+```
-Now you can access the gateway:
-```shell
-TOKEN=
-curl -H "Authorization: Bearer $TOKEN" http://localhost:8090/radar-gateway/topics
+Next, add the configuration to the [Config](src/main/kotlin/org/radarbase/gateway/Config.kt) class.
+```kotlin
+...
+data class PushIntegrationConfig(
+ val garmin: GarminConfig = GarminConfig(),
+ val servicex: ServiceXConfig
+)
+
+data class ServiceXConfig(
+ val enabled: Boolean = false,
+ val userRepositoryClass: String,
+ val property1: String,
+ val property2: List
+)
+
+...
```
-Data compressed with GZIP is decompressed if the `Content-Encoding: gzip` header is present. With `curl`, use the `-H "Content-Encoding: gzip" --data-binary @data.json.gz` flags. It can be activated in `radar-commons` Java `RestClient` by setting `RestClient.Builder.gzipCompression(true)`. Likewise it accepts Apple LZFSE encoded data by adding the header `Content-Encoding: lzfse`.
+Finally, add your newly created Resource Enhancer to [PushIntegrationEnhancerFactory](src/main/kotlin/org/radarbase/gateway/inject/PushIntegrationEnhancerFactory.kt)
+```kotlin
+...
+ // Push Service specific enhancers
+ if (config.pushIntegration.garmin.enabled) {
+ enhancersList.add(GarminPushIntegrationResourceEnhancer(config))
+ }
+ if(config.pushIntegration.servicex.enabled) {
+ enhancersList.add(ServiceXIntegrationResourceEnhancer(config))
+ }
-Otherwise, it accepts all the same Avro messages and headers as specified in the Kafka [REST Proxy documentation].
-Finally, the gateway accepts a custom binary format for data ingestion. The data must follow the binary Avro serialization of the [RecordSet schema](https://github.com/RADAR-base/RADAR-Schemas/blob/master/commons/kafka/record_set.avsc). Data in this format can be posted by using the content type `application/vnd.radarbase.avro.v1+binary`. It will construct an `ObservationKey` based on the user data in the `RecordSet`, and read the binary data values using the schema version provided in the `RecordSet`. This data sending mode can be activated in Java by using radar-commons `RestSender.Builder.useBinaryContent(true)`. Using binary mode has the added benefit of having a much more efficient GZIP encoding for many datasets.
+...
+```
-[REST Proxy documentation]: https://docs.confluent.io/current/kafka-rest/api.html
-[RADAR-Auth]: https://github.com/RADAR-base/ManagementPortal/tree/master/radar-auth
-[ManagementPortal]: https://github.com/RADAR-base/ManagementPortal
diff --git a/build.gradle.kts b/build.gradle.kts
index 7227368..fcb2422 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -11,11 +11,16 @@ plugins {
}
group = "org.radarbase"
-version = "0.5.3"
-description = "RADAR Gateway to handle secured data flow to backend."
+version = "0.1.0"
+description = "RADAR Push API Gateway to handle secured data flow to backend."
+
+dependencyLocking {
+ lockAllConfigurations()
+}
repositories {
jcenter()
+ mavenCentral()
// Non-jcenter radar releases
maven(url = "https://dl.bintray.com/radar-cns/org.radarcns")
maven(url = "https://dl.bintray.com/radar-base/org.radarbase")
@@ -39,8 +44,15 @@ dependencies {
implementation("org.apache.kafka:kafka-clients:${project.property("kafkaVersion")}")
implementation("io.confluent:kafka-avro-serializer:${project.property("confluentVersion")}")
+ implementation("org.radarcns:oauth-client-util:${project.property("radarOauthClientVersion")}")
+
implementation("org.slf4j:slf4j-api:${project.property("slf4jVersion")}")
- implementation("com.fasterxml.jackson.core:jackson-databind:${project.property("jacksonVersion")}")
+
+ val jacksonVersion: String by project
+ implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
+ implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")
+ implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion")
val grizzlyVersion: String by project
runtimeOnly("org.glassfish.grizzly:grizzly-framework-monitoring:$grizzlyVersion")
@@ -48,11 +60,16 @@ dependencies {
runtimeOnly("org.glassfish.grizzly:grizzly-http-server-monitoring:$grizzlyVersion")
runtimeOnly("ch.qos.logback:logback-classic:${project.property("logbackVersion")}")
+ val jedisVersion: String by project
+ implementation("redis.clients:jedis:$jedisVersion")
+
val junitVersion: String by project
val okhttp3Version: String by project
val radarSchemasVersion: String by project
+ implementation("org.radarcns:radar-schemas-commons:$radarSchemasVersion")
+
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
- testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
+ testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:[2.2,3.0)")
testImplementation("com.squareup.okhttp3:mockwebserver:$okhttp3Version")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
@@ -67,8 +84,8 @@ val kotlinApiVersion: String by project
tasks.withType {
kotlinOptions {
jvmTarget = "11"
- apiVersion = kotlinApiVersion
- languageVersion = kotlinApiVersion
+ apiVersion = "1.4"
+ languageVersion = "1.4"
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
index df68b0a..ad89f43 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -19,10 +19,14 @@ services:
image: confluentinc/cp-kafka:${KAFKA_CONFLUENT_VERSION}
depends_on:
- zookeeper-1
+ ports:
+ - 9093:9093
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper-1:2181
- KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka-1:9092
+ KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka-1:9092, EXTERNAL://localhost:9093
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT, EXTERNAL:PLAINTEXT
+ KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
KAFKA_COMPRESSION_TYPE: lz4
KAFKA_CONFLUENT_SUPPORT_METRICS_ENABLE: "false"
@@ -45,32 +49,20 @@ services:
SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081
SCHEMA_REGISTRY_KAFKASTORE_TOPIC_REPLICATION_FACTOR: 1
- #---------------------------------------------------------------------------#
- # Management Portal #
- #---------------------------------------------------------------------------#
- managementportal-app:
- image: radarbase/management-portal:0.6.0
- ports:
- - "127.0.0.1:8080:8080"
- environment:
- SPRING_PROFILES_ACTIVE: dev
- MANAGEMENTPORTAL_FRONTEND_CLIENT_SECRET: ""
- MANAGEMENTPORTAL_COMMON_BASE_URL: http://localhost:8080
- MANAGEMENTPORTAL_COMMON_MANAGEMENT_PORTAL_BASE_URL: http://localhost:8080
- MANAGEMENTPORTAL_OAUTH_CLIENTS_FILE: /mp-includes/config/oauth_client_details.csv
- MANAGEMENTPORTAL_CATALOGUE_SERVER_ENABLE_AUTO_IMPORT: 'false'
- JAVA_OPTS: -Xmx256m # maximum heap size for the JVM running ManagementPortal, increase this as necessary
- volumes:
- - ./src/integrationTest/docker/etc/:/mp-includes/
-
- gateway:
+ push-endpoint:
build: .
- image: radarbase/radar-gateway:SNAPSHOT
+ image: radarbase/radar-push-endpoint:SNAPSHOT
depends_on:
- kafka-1
- schema-registry-1
- - managementportal-app
ports:
- "127.0.0.1:8090:8090"
volumes:
- ./gateway.yml:/etc/radar-gateway/gateway.yml
+
+ redis:
+ image: bitnami/redis
+ ports:
+ - "6379:6379"
+ environment:
+ ALLOW_EMPTY_PASSWORD: "yes"
diff --git a/gateway.yml b/gateway.yml
index be9ab81..30f0ce5 100644
--- a/gateway.yml
+++ b/gateway.yml
@@ -3,7 +3,7 @@
server:
# URI to serve data to
- baseUri: http://0.0.0.0:8090/radar-gateway/
+ baseUri: http://0.0.0.0:8090/push/integrations/
# Maximum number of simultaneous requests to Kafka.
#maxRequests: 200
# Maximum request content length, also when decompressed.
@@ -52,3 +52,16 @@ auth:
#ecdsa: []
# RSA public keys
#rsa: []
+
+# Push Service specific configuration
+pushIntegration:
+ garmin:
+ enabled: true
+ backfill:
+ defaultEndDate: "1590844126"
+ # Redis configuration
+ redis:
+ # Redis URI
+ uri: redis://localhost:6379
+ # Key prefix for locks
+ lockPrefix: radar-push-garmin/lock/
diff --git a/gradle.properties b/gradle.properties
index 3977aa2..bf3a60e 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -2,16 +2,17 @@ org.gradle.jvmargs=-Xmx3072m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF
dockerComposeStopContainers=true
kotlinVersion=1.4.10
-kotlinApiVersion=1.4
-okhttp3Version=4.9.0
-radarJerseyVersion=0.4.1.1
-radarCommonsVersion=0.13.0
-radarSchemasVersion=0.5.14
-jacksonVersion=2.11.3
-slf4jVersion=1.7.30
-logbackVersion=1.2.3
-grizzlyVersion=2.4.4
-lzfseVersion=0.1.0
-kafkaVersion=2.5.1
-confluentVersion=5.5.2
-junitVersion=5.6.2
+okhttp3Version=4.9.+
+radarJerseyVersion=0.4.3
+radarCommonsVersion=0.13.+
+radarSchemasVersion=0.5.15
+radarOauthClientVersion=0.5.8
+jacksonVersion=2.11.+
+slf4jVersion=1.7.+
+logbackVersion=1.2.+
+grizzlyVersion=2.4.+
+lzfseVersion=0.1.+
+kafkaVersion=2.5.+
+confluentVersion=5.5.+
+junitVersion=5.+
+jedisVersion=3.3.+
diff --git a/gradle/dependency-locks/annotationProcessor.lockfile b/gradle/dependency-locks/annotationProcessor.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/annotationProcessor.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/apiDependenciesMetadata.lockfile b/gradle/dependency-locks/apiDependenciesMetadata.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/apiDependenciesMetadata.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/compileClasspath.lockfile b/gradle/dependency-locks/compileClasspath.lockfile
new file mode 100644
index 0000000..584c258
--- /dev/null
+++ b/gradle/dependency-locks/compileClasspath.lockfile
@@ -0,0 +1,55 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.squareup.okhttp3:okhttp:4.7.2
+com.squareup.okio:okio:2.6.0
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.swagger:swagger-annotations:1.6.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-pool2:2.6.2
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.lz4:lz4-java:1.7.1
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/compileOnlyDependenciesMetadata.lockfile b/gradle/dependency-locks/compileOnlyDependenciesMetadata.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/compileOnlyDependenciesMetadata.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/implementationDependenciesMetadata.lockfile b/gradle/dependency-locks/implementationDependenciesMetadata.lockfile
new file mode 100644
index 0000000..c964424
--- /dev/null
+++ b/gradle/dependency-locks/implementationDependenciesMetadata.lockfile
@@ -0,0 +1,56 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.squareup.okhttp3:okhttp:4.7.2
+com.squareup.okio:okio-metadata:2.6.0
+com.squareup.okio:okio:2.6.0
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.swagger:swagger-annotations:1.6.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-pool2:2.6.2
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.lz4:lz4-java:1.7.1
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/integrationTestAnnotationProcessor.lockfile b/gradle/dependency-locks/integrationTestAnnotationProcessor.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestAnnotationProcessor.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/integrationTestApiDependenciesMetadata.lockfile b/gradle/dependency-locks/integrationTestApiDependenciesMetadata.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestApiDependenciesMetadata.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/integrationTestCompileClasspath.lockfile b/gradle/dependency-locks/integrationTestCompileClasspath.lockfile
new file mode 100644
index 0000000..82ba7a3
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestCompileClasspath.lockfile
@@ -0,0 +1,70 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0
+com.squareup.okhttp3:mockwebserver:4.9.1
+com.squareup.okhttp3:okhttp:4.9.1
+com.squareup.okio:okio:2.8.0
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.swagger:swagger-annotations:1.6.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+junit:junit:4.13
+net.bytebuddy:byte-buddy-agent:1.9.0
+net.bytebuddy:byte-buddy:1.9.0
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-pool2:2.6.2
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.apiguardian:apiguardian-api:1.1.0
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.hamcrest:hamcrest-core:1.3
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.junit.jupiter:junit-jupiter-api:5.7.0
+org.junit.platform:junit-platform-commons:1.7.0
+org.junit:junit-bom:5.7.0
+org.lz4:lz4-java:1.7.1
+org.mockito:mockito-core:2.23.0
+org.objenesis:objenesis:2.6
+org.opentest4j:opentest4j:1.2.0
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons-server:0.13.0
+org.radarbase:radar-commons-testing:0.13.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/integrationTestCompileOnlyDependenciesMetadata.lockfile b/gradle/dependency-locks/integrationTestCompileOnlyDependenciesMetadata.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestCompileOnlyDependenciesMetadata.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/integrationTestImplementationDependenciesMetadata.lockfile b/gradle/dependency-locks/integrationTestImplementationDependenciesMetadata.lockfile
new file mode 100644
index 0000000..d864292
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestImplementationDependenciesMetadata.lockfile
@@ -0,0 +1,71 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0
+com.squareup.okhttp3:mockwebserver:4.9.1
+com.squareup.okhttp3:okhttp:4.9.1
+com.squareup.okio:okio-metadata:2.8.0
+com.squareup.okio:okio:2.8.0
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.swagger:swagger-annotations:1.6.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+junit:junit:4.13
+net.bytebuddy:byte-buddy-agent:1.9.0
+net.bytebuddy:byte-buddy:1.9.0
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-pool2:2.6.2
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.apiguardian:apiguardian-api:1.1.0
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.hamcrest:hamcrest-core:1.3
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.junit.jupiter:junit-jupiter-api:5.7.0
+org.junit.platform:junit-platform-commons:1.7.0
+org.junit:junit-bom:5.7.0
+org.lz4:lz4-java:1.7.1
+org.mockito:mockito-core:2.23.0
+org.objenesis:objenesis:2.6
+org.opentest4j:opentest4j:1.2.0
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons-server:0.13.0
+org.radarbase:radar-commons-testing:0.13.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/integrationTestKotlinScriptDef.lockfile b/gradle/dependency-locks/integrationTestKotlinScriptDef.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestKotlinScriptDef.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/integrationTestKotlinScriptDefExtensions.lockfile b/gradle/dependency-locks/integrationTestKotlinScriptDefExtensions.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestKotlinScriptDefExtensions.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/integrationTestRuntimeClasspath.lockfile b/gradle/dependency-locks/integrationTestRuntimeClasspath.lockfile
new file mode 100644
index 0000000..ba33be2
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestRuntimeClasspath.lockfile
@@ -0,0 +1,120 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+ch.qos.logback:logback-classic:1.2.3
+ch.qos.logback:logback-core:1.2.3
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.jaxrs:jackson-jaxrs-base:2.11.1
+com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:2.11.1
+com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.11.3
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.github.spullara.mustache.java:compiler:0.9.6
+com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0
+com.opencsv:opencsv:5.2
+com.squareup.okhttp3:mockwebserver:4.9.1
+com.squareup.okhttp3:okhttp:4.10.0-RC1
+com.squareup.okio:okio:2.9.0
+com.sun.activation:jakarta.activation:1.2.2
+com.sun.istack:istack-commons-runtime:3.0.11
+commons-beanutils:commons-beanutils:1.9.4
+commons-codec:commons-codec:1.15
+commons-collections:commons-collections:3.2.2
+commons-logging:commons-logging:1.2
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.github.classgraph:classgraph:4.8.65
+io.swagger.core.v3:swagger-annotations:2.1.6
+io.swagger.core.v3:swagger-core:2.1.6
+io.swagger.core.v3:swagger-integration:2.1.6
+io.swagger.core.v3:swagger-jaxrs2:2.1.6
+io.swagger.core.v3:swagger-models:2.1.6
+io.swagger:swagger-annotations:1.6.2
+jakarta.activation:jakarta.activation-api:1.2.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+jakarta.xml.bind:jakarta.xml.bind-api:2.3.3
+javax.activation:activation:1.1.1
+javax.activation:javax.activation-api:1.2.0
+javax.xml.bind:jaxb-api:2.3.1
+junit:junit:4.13
+net.bytebuddy:byte-buddy-agent:1.9.0
+net.bytebuddy:byte-buddy:1.9.0
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-collections4:4.4
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-lang3:3.10
+org.apache.commons:commons-pool2:2.6.2
+org.apache.commons:commons-text:1.8
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.apiguardian:apiguardian-api:1.1.0
+org.glassfish.external:management-api:3.2.1
+org.glassfish.gmbal:gmbal:4.0.0
+org.glassfish.grizzly:grizzly-framework-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-framework:2.4.4
+org.glassfish.grizzly:grizzly-http-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server:2.4.4
+org.glassfish.grizzly:grizzly-http:2.4.4
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jaxb:jaxb-core:2.3.0.1
+org.glassfish.jaxb:jaxb-runtime:2.3.3
+org.glassfish.jaxb:txw2:2.3.3
+org.glassfish.jersey.containers:jersey-container-grizzly2-http:2.33
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.ext:jersey-entity-filtering:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.glassfish.jersey.media:jersey-media-json-jackson:2.33
+org.glassfish.pfl:pfl-asm:4.0.1
+org.glassfish.pfl:pfl-basic-tools:4.0.1
+org.glassfish.pfl:pfl-basic:4.0.1
+org.glassfish.pfl:pfl-dynamic:4.0.1
+org.glassfish.pfl:pfl-tf-tools:4.0.1
+org.glassfish.pfl:pfl-tf:4.0.1
+org.hamcrest:hamcrest-core:1.3
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.junit.jupiter:junit-jupiter-api:5.7.0
+org.junit.jupiter:junit-jupiter-engine:5.7.0
+org.junit.platform:junit-platform-commons:1.7.0
+org.junit.platform:junit-platform-engine:1.7.0
+org.junit:junit-bom:5.7.0
+org.lz4:lz4-java:1.7.1
+org.mockito:mockito-core:2.23.0
+org.objenesis:objenesis:2.6
+org.opentest4j:opentest4j:1.2.0
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons-server:0.13.0
+org.radarbase:radar-commons-testing:0.13.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/integrationTestRuntimeOnlyDependenciesMetadata.lockfile b/gradle/dependency-locks/integrationTestRuntimeOnlyDependenciesMetadata.lockfile
new file mode 100644
index 0000000..2a28d2b
--- /dev/null
+++ b/gradle/dependency-locks/integrationTestRuntimeOnlyDependenciesMetadata.lockfile
@@ -0,0 +1,27 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+ch.qos.logback:logback-classic:1.2.3
+ch.qos.logback:logback-core:1.2.3
+org.apiguardian:apiguardian-api:1.1.0
+org.glassfish.external:management-api:3.2.1
+org.glassfish.gmbal:gmbal:4.0.0
+org.glassfish.grizzly:grizzly-framework-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-framework:2.4.4
+org.glassfish.grizzly:grizzly-http-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server:2.4.4
+org.glassfish.grizzly:grizzly-http:2.4.4
+org.glassfish.pfl:pfl-asm:4.0.1
+org.glassfish.pfl:pfl-basic-tools:4.0.1
+org.glassfish.pfl:pfl-basic:4.0.1
+org.glassfish.pfl:pfl-dynamic:4.0.1
+org.glassfish.pfl:pfl-tf-tools:4.0.1
+org.glassfish.pfl:pfl-tf:4.0.1
+org.junit.jupiter:junit-jupiter-api:5.7.0
+org.junit.jupiter:junit-jupiter-engine:5.7.0
+org.junit.platform:junit-platform-commons:1.7.0
+org.junit.platform:junit-platform-engine:1.7.0
+org.junit:junit-bom:5.7.0
+org.opentest4j:opentest4j:1.2.0
+org.slf4j:slf4j-api:1.7.25
diff --git a/gradle/dependency-locks/kotlinCompilerClasspath.lockfile b/gradle/dependency-locks/kotlinCompilerClasspath.lockfile
new file mode 100644
index 0000000..20a87c9
--- /dev/null
+++ b/gradle/dependency-locks/kotlinCompilerClasspath.lockfile
@@ -0,0 +1,11 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+org.jetbrains.intellij.deps:trove4j:1.0.20181211
+org.jetbrains.kotlin:kotlin-compiler-embeddable:1.4.10
+org.jetbrains.kotlin:kotlin-daemon-embeddable:1.4.10
+org.jetbrains.kotlin:kotlin-reflect:1.4.10
+org.jetbrains.kotlin:kotlin-script-runtime:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib:1.4.10
+org.jetbrains:annotations:13.0
diff --git a/gradle/dependency-locks/kotlinCompilerPluginClasspath.lockfile b/gradle/dependency-locks/kotlinCompilerPluginClasspath.lockfile
new file mode 100644
index 0000000..7bc8ec6
--- /dev/null
+++ b/gradle/dependency-locks/kotlinCompilerPluginClasspath.lockfile
@@ -0,0 +1,12 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+org.jetbrains.kotlin:kotlin-script-runtime:1.4.10
+org.jetbrains.kotlin:kotlin-scripting-common:1.4.10
+org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.4.10
+org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.4.10
+org.jetbrains.kotlin:kotlin-scripting-jvm:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib:1.4.10
+org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7
+org.jetbrains:annotations:13.0
diff --git a/gradle/dependency-locks/kotlinKlibCommonizerClasspath.lockfile b/gradle/dependency-locks/kotlinKlibCommonizerClasspath.lockfile
new file mode 100644
index 0000000..e444639
--- /dev/null
+++ b/gradle/dependency-locks/kotlinKlibCommonizerClasspath.lockfile
@@ -0,0 +1,12 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+org.jetbrains.intellij.deps:trove4j:1.0.20181211
+org.jetbrains.kotlin:kotlin-compiler-embeddable:1.4.10
+org.jetbrains.kotlin:kotlin-daemon-embeddable:1.4.10
+org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:1.4.10
+org.jetbrains.kotlin:kotlin-reflect:1.4.10
+org.jetbrains.kotlin:kotlin-script-runtime:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib:1.4.10
+org.jetbrains:annotations:13.0
diff --git a/gradle/dependency-locks/kotlinNativeCompilerPluginClasspath.lockfile b/gradle/dependency-locks/kotlinNativeCompilerPluginClasspath.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/kotlinNativeCompilerPluginClasspath.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/kotlinScriptDef.lockfile b/gradle/dependency-locks/kotlinScriptDef.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/kotlinScriptDef.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/kotlinScriptDefExtensions.lockfile b/gradle/dependency-locks/kotlinScriptDefExtensions.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/kotlinScriptDefExtensions.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/runtimeClasspath.lockfile b/gradle/dependency-locks/runtimeClasspath.lockfile
new file mode 100644
index 0000000..5e243a9
--- /dev/null
+++ b/gradle/dependency-locks/runtimeClasspath.lockfile
@@ -0,0 +1,97 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+ch.qos.logback:logback-classic:1.2.3
+ch.qos.logback:logback-core:1.2.3
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.jaxrs:jackson-jaxrs-base:2.11.1
+com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:2.11.1
+com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.11.3
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.github.spullara.mustache.java:compiler:0.9.6
+com.squareup.okhttp3:okhttp:4.10.0-RC1
+com.squareup.okio:okio:2.9.0
+com.sun.activation:jakarta.activation:1.2.2
+com.sun.istack:istack-commons-runtime:3.0.11
+commons-codec:commons-codec:1.15
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.github.classgraph:classgraph:4.8.65
+io.swagger.core.v3:swagger-annotations:2.1.6
+io.swagger.core.v3:swagger-core:2.1.6
+io.swagger.core.v3:swagger-integration:2.1.6
+io.swagger.core.v3:swagger-jaxrs2:2.1.6
+io.swagger.core.v3:swagger-models:2.1.6
+io.swagger:swagger-annotations:1.6.2
+jakarta.activation:jakarta.activation-api:1.2.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+jakarta.xml.bind:jakarta.xml.bind-api:2.3.3
+javax.activation:activation:1.1.1
+javax.activation:javax.activation-api:1.2.0
+javax.xml.bind:jaxb-api:2.3.1
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-lang3:3.7
+org.apache.commons:commons-pool2:2.6.2
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.glassfish.external:management-api:3.2.1
+org.glassfish.gmbal:gmbal:4.0.0
+org.glassfish.grizzly:grizzly-framework-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-framework:2.4.4
+org.glassfish.grizzly:grizzly-http-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server:2.4.4
+org.glassfish.grizzly:grizzly-http:2.4.4
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jaxb:jaxb-core:2.3.0.1
+org.glassfish.jaxb:jaxb-runtime:2.3.3
+org.glassfish.jaxb:txw2:2.3.3
+org.glassfish.jersey.containers:jersey-container-grizzly2-http:2.33
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.ext:jersey-entity-filtering:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.glassfish.jersey.media:jersey-media-json-jackson:2.33
+org.glassfish.pfl:pfl-asm:4.0.1
+org.glassfish.pfl:pfl-basic-tools:4.0.1
+org.glassfish.pfl:pfl-basic:4.0.1
+org.glassfish.pfl:pfl-dynamic:4.0.1
+org.glassfish.pfl:pfl-tf-tools:4.0.1
+org.glassfish.pfl:pfl-tf:4.0.1
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.lz4:lz4-java:1.7.1
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/runtimeOnlyDependenciesMetadata.lockfile b/gradle/dependency-locks/runtimeOnlyDependenciesMetadata.lockfile
new file mode 100644
index 0000000..2e68e82
--- /dev/null
+++ b/gradle/dependency-locks/runtimeOnlyDependenciesMetadata.lockfile
@@ -0,0 +1,20 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+ch.qos.logback:logback-classic:1.2.3
+ch.qos.logback:logback-core:1.2.3
+org.glassfish.external:management-api:3.2.1
+org.glassfish.gmbal:gmbal:4.0.0
+org.glassfish.grizzly:grizzly-framework-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-framework:2.4.4
+org.glassfish.grizzly:grizzly-http-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server:2.4.4
+org.glassfish.grizzly:grizzly-http:2.4.4
+org.glassfish.pfl:pfl-asm:4.0.1
+org.glassfish.pfl:pfl-basic-tools:4.0.1
+org.glassfish.pfl:pfl-basic:4.0.1
+org.glassfish.pfl:pfl-dynamic:4.0.1
+org.glassfish.pfl:pfl-tf-tools:4.0.1
+org.glassfish.pfl:pfl-tf:4.0.1
+org.slf4j:slf4j-api:1.7.25
diff --git a/gradle/dependency-locks/testAnnotationProcessor.lockfile b/gradle/dependency-locks/testAnnotationProcessor.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/testAnnotationProcessor.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/testApiDependenciesMetadata.lockfile b/gradle/dependency-locks/testApiDependenciesMetadata.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/testApiDependenciesMetadata.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/testCompileClasspath.lockfile b/gradle/dependency-locks/testCompileClasspath.lockfile
new file mode 100644
index 0000000..78a895e
--- /dev/null
+++ b/gradle/dependency-locks/testCompileClasspath.lockfile
@@ -0,0 +1,68 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0
+com.squareup.okhttp3:mockwebserver:4.9.1
+com.squareup.okhttp3:okhttp:4.9.1
+com.squareup.okio:okio:2.8.0
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.swagger:swagger-annotations:1.6.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+junit:junit:4.13
+net.bytebuddy:byte-buddy-agent:1.9.0
+net.bytebuddy:byte-buddy:1.9.0
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-pool2:2.6.2
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.apiguardian:apiguardian-api:1.1.0
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.hamcrest:hamcrest-core:1.3
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.junit.jupiter:junit-jupiter-api:5.7.0
+org.junit.platform:junit-platform-commons:1.7.0
+org.junit:junit-bom:5.7.0
+org.lz4:lz4-java:1.7.1
+org.mockito:mockito-core:2.23.0
+org.objenesis:objenesis:2.6
+org.opentest4j:opentest4j:1.2.0
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/testCompileOnlyDependenciesMetadata.lockfile b/gradle/dependency-locks/testCompileOnlyDependenciesMetadata.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/testCompileOnlyDependenciesMetadata.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/testImplementationDependenciesMetadata.lockfile b/gradle/dependency-locks/testImplementationDependenciesMetadata.lockfile
new file mode 100644
index 0000000..f437169
--- /dev/null
+++ b/gradle/dependency-locks/testImplementationDependenciesMetadata.lockfile
@@ -0,0 +1,69 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0
+com.squareup.okhttp3:mockwebserver:4.9.1
+com.squareup.okhttp3:okhttp:4.9.1
+com.squareup.okio:okio-metadata:2.8.0
+com.squareup.okio:okio:2.8.0
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.swagger:swagger-annotations:1.6.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+junit:junit:4.13
+net.bytebuddy:byte-buddy-agent:1.9.0
+net.bytebuddy:byte-buddy:1.9.0
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-pool2:2.6.2
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.apiguardian:apiguardian-api:1.1.0
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.hamcrest:hamcrest-core:1.3
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.10
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.junit.jupiter:junit-jupiter-api:5.7.0
+org.junit.platform:junit-platform-commons:1.7.0
+org.junit:junit-bom:5.7.0
+org.lz4:lz4-java:1.7.1
+org.mockito:mockito-core:2.23.0
+org.objenesis:objenesis:2.6
+org.opentest4j:opentest4j:1.2.0
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/testKotlinScriptDef.lockfile b/gradle/dependency-locks/testKotlinScriptDef.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/testKotlinScriptDef.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/testKotlinScriptDefExtensions.lockfile b/gradle/dependency-locks/testKotlinScriptDefExtensions.lockfile
new file mode 100644
index 0000000..656c5db
--- /dev/null
+++ b/gradle/dependency-locks/testKotlinScriptDefExtensions.lockfile
@@ -0,0 +1,3 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
diff --git a/gradle/dependency-locks/testRuntimeClasspath.lockfile b/gradle/dependency-locks/testRuntimeClasspath.lockfile
new file mode 100644
index 0000000..413c448
--- /dev/null
+++ b/gradle/dependency-locks/testRuntimeClasspath.lockfile
@@ -0,0 +1,112 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+ch.qos.logback:logback-classic:1.2.3
+ch.qos.logback:logback-core:1.2.3
+com.auth0:java-jwt:3.12.0
+com.fasterxml.jackson.core:jackson-annotations:2.11.4
+com.fasterxml.jackson.core:jackson-core:2.11.4
+com.fasterxml.jackson.core:jackson-databind:2.11.4
+com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.11.4
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.4
+com.fasterxml.jackson.jaxrs:jackson-jaxrs-base:2.11.1
+com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:2.11.1
+com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.11.3
+com.fasterxml.jackson.module:jackson-module-kotlin:2.11.4
+com.github.luben:zstd-jni:1.4.4-7
+com.github.spullara.mustache.java:compiler:0.9.6
+com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0
+com.squareup.okhttp3:mockwebserver:4.9.1
+com.squareup.okhttp3:okhttp:4.10.0-RC1
+com.squareup.okio:okio:2.9.0
+com.sun.activation:jakarta.activation:1.2.2
+com.sun.istack:istack-commons-runtime:3.0.11
+commons-codec:commons-codec:1.15
+io.confluent:common-config:5.5.3
+io.confluent:common-utils:5.5.3
+io.confluent:kafka-avro-serializer:5.5.3
+io.confluent:kafka-schema-registry-client:5.5.3
+io.confluent:kafka-schema-serializer:5.5.3
+io.github.classgraph:classgraph:4.8.65
+io.swagger.core.v3:swagger-annotations:2.1.6
+io.swagger.core.v3:swagger-core:2.1.6
+io.swagger.core.v3:swagger-integration:2.1.6
+io.swagger.core.v3:swagger-jaxrs2:2.1.6
+io.swagger.core.v3:swagger-models:2.1.6
+io.swagger:swagger-annotations:1.6.2
+jakarta.activation:jakarta.activation-api:1.2.2
+jakarta.annotation:jakarta.annotation-api:1.3.5
+jakarta.validation:jakarta.validation-api:2.0.2
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6
+jakarta.xml.bind:jakarta.xml.bind-api:2.3.3
+javax.activation:activation:1.1.1
+javax.activation:javax.activation-api:1.2.0
+javax.xml.bind:jaxb-api:2.3.1
+junit:junit:4.13
+net.bytebuddy:byte-buddy-agent:1.9.0
+net.bytebuddy:byte-buddy:1.9.0
+org.apache.avro:avro:1.9.2
+org.apache.commons:commons-compress:1.19
+org.apache.commons:commons-lang3:3.7
+org.apache.commons:commons-pool2:2.6.2
+org.apache.kafka:kafka-clients:5.5.3-ccs
+org.apiguardian:apiguardian-api:1.1.0
+org.glassfish.external:management-api:3.2.1
+org.glassfish.gmbal:gmbal:4.0.0
+org.glassfish.grizzly:grizzly-framework-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-framework:2.4.4
+org.glassfish.grizzly:grizzly-http-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server:2.4.4
+org.glassfish.grizzly:grizzly-http:2.4.4
+org.glassfish.hk2.external:aopalliance-repackaged:2.6.1
+org.glassfish.hk2.external:jakarta.inject:2.6.1
+org.glassfish.hk2:hk2-api:2.6.1
+org.glassfish.hk2:hk2-locator:2.6.1
+org.glassfish.hk2:hk2-utils:2.6.1
+org.glassfish.hk2:osgi-resource-locator:1.0.3
+org.glassfish.jaxb:jaxb-core:2.3.0.1
+org.glassfish.jaxb:jaxb-runtime:2.3.3
+org.glassfish.jaxb:txw2:2.3.3
+org.glassfish.jersey.containers:jersey-container-grizzly2-http:2.33
+org.glassfish.jersey.core:jersey-client:2.33
+org.glassfish.jersey.core:jersey-common:2.33
+org.glassfish.jersey.core:jersey-server:2.33
+org.glassfish.jersey.ext:jersey-entity-filtering:2.33
+org.glassfish.jersey.inject:jersey-hk2:2.33
+org.glassfish.jersey.media:jersey-media-json-jackson:2.33
+org.glassfish.pfl:pfl-asm:4.0.1
+org.glassfish.pfl:pfl-basic-tools:4.0.1
+org.glassfish.pfl:pfl-basic:4.0.1
+org.glassfish.pfl:pfl-dynamic:4.0.1
+org.glassfish.pfl:pfl-tf-tools:4.0.1
+org.glassfish.pfl:pfl-tf:4.0.1
+org.hamcrest:hamcrest-core:1.3
+org.javassist:javassist:3.25.0-GA
+org.jetbrains.kotlin:kotlin-reflect:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21
+org.jetbrains.kotlin:kotlin-stdlib:1.4.21
+org.jetbrains:annotations:13.0
+org.json:json:20200518
+org.junit.jupiter:junit-jupiter-api:5.7.0
+org.junit.jupiter:junit-jupiter-engine:5.7.0
+org.junit.platform:junit-platform-commons:1.7.0
+org.junit.platform:junit-platform-engine:1.7.0
+org.junit:junit-bom:5.7.0
+org.lz4:lz4-java:1.7.1
+org.mockito:mockito-core:2.23.0
+org.objenesis:objenesis:2.6
+org.opentest4j:opentest4j:1.2.0
+org.radarbase:lzfse-decode:0.1.0
+org.radarbase:radar-commons:0.13.0
+org.radarbase:radar-jersey:0.4.3
+org.radarcns:oauth-client-util:0.5.8
+org.radarcns:radar-auth:0.6.3
+org.radarcns:radar-schemas-commons:0.5.15
+org.slf4j:slf4j-api:1.7.30
+org.xerial.snappy:snappy-java:1.1.7.3
+org.yaml:snakeyaml:1.26
+redis.clients:jedis:3.3.0
diff --git a/gradle/dependency-locks/testRuntimeOnlyDependenciesMetadata.lockfile b/gradle/dependency-locks/testRuntimeOnlyDependenciesMetadata.lockfile
new file mode 100644
index 0000000..2a28d2b
--- /dev/null
+++ b/gradle/dependency-locks/testRuntimeOnlyDependenciesMetadata.lockfile
@@ -0,0 +1,27 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+ch.qos.logback:logback-classic:1.2.3
+ch.qos.logback:logback-core:1.2.3
+org.apiguardian:apiguardian-api:1.1.0
+org.glassfish.external:management-api:3.2.1
+org.glassfish.gmbal:gmbal:4.0.0
+org.glassfish.grizzly:grizzly-framework-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-framework:2.4.4
+org.glassfish.grizzly:grizzly-http-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server-monitoring:2.4.4
+org.glassfish.grizzly:grizzly-http-server:2.4.4
+org.glassfish.grizzly:grizzly-http:2.4.4
+org.glassfish.pfl:pfl-asm:4.0.1
+org.glassfish.pfl:pfl-basic-tools:4.0.1
+org.glassfish.pfl:pfl-basic:4.0.1
+org.glassfish.pfl:pfl-dynamic:4.0.1
+org.glassfish.pfl:pfl-tf-tools:4.0.1
+org.glassfish.pfl:pfl-tf:4.0.1
+org.junit.jupiter:junit-jupiter-api:5.7.0
+org.junit.jupiter:junit-jupiter-engine:5.7.0
+org.junit.platform:junit-platform-commons:1.7.0
+org.junit.platform:junit-platform-engine:1.7.0
+org.junit:junit-bom:5.7.0
+org.opentest4j:opentest4j:1.2.0
+org.slf4j:slf4j-api:1.7.25
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1931a54..d32145b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,4 +1,4 @@
-rootProject.name = "radar-gateway"
+rootProject.name = "radar-push-endpoint"
pluginManagement {
val kotlinVersion: String by settings
diff --git a/src/main/kotlin/org/radarbase/gateway/Config.kt b/src/main/kotlin/org/radarbase/gateway/Config.kt
index b62d47a..8f05f9a 100644
--- a/src/main/kotlin/org/radarbase/gateway/Config.kt
+++ b/src/main/kotlin/org/radarbase/gateway/Config.kt
@@ -3,21 +3,22 @@ package org.radarbase.gateway
import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.MAX_SCHEMAS_PER_SUBJECT_CONFIG
import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.SCHEMA_REGISTRY_URL_CONFIG
import org.apache.kafka.clients.CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG
-import org.radarbase.gateway.inject.ManagementPortalEnhancerFactory
+import org.radarbase.gateway.inject.PushIntegrationEnhancerFactory
import org.radarbase.jersey.config.EnhancerFactory
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
import java.net.URI
-import java.nio.file.Files
-import java.nio.file.Path
+import java.time.Instant
data class Config(
- /** Radar-jersey resource configuration class. */
- val resourceConfig: Class = ManagementPortalEnhancerFactory::class.java,
- /** Authorization configurations. */
- val auth: AuthConfig = AuthConfig(),
- /** Kafka configurations. */
- val kafka: KafkaConfig = KafkaConfig(),
- /** Server configurations. */
- val server: GatewayServerConfig = GatewayServerConfig()) {
+ /** Radar-jersey resource configuration class. */
+ val resourceConfig: Class = PushIntegrationEnhancerFactory::class.java,
+ /** Kafka configurations. */
+ val kafka: KafkaConfig = KafkaConfig(),
+ /** Server configurations. */
+ val server: GatewayServerConfig = GatewayServerConfig(),
+ /** Push integration configs **/
+ val pushIntegration: PushIntegrationConfig = PushIntegrationConfig()
+) {
/** Fill in some default values for the configuration. */
fun withDefaults(): Config = copy(kafka = kafka.withDefaults())
@@ -27,44 +28,115 @@ data class Config(
*/
fun validate() {
kafka.validate()
- auth.validate()
+ pushIntegration.validate()
+ }
+}
+
+data class PushIntegrationConfig(
+ val garmin: GarminConfig = GarminConfig()
+) {
+ fun validate() {
+ garmin.validate()
+ // Add more validations as services are added
+ }
+}
+
+data class GarminConfig(
+ val enabled: Boolean = false,
+ val consumerKey: String = "",
+ val consumerSecret: String = "",
+ val backfill: BackfillConfig = BackfillConfig(),
+ val userRepositoryClass: String =
+ "org.radarbase.push.integration.garmin.user.GarminServiceUserRepository",
+ val userRepositoryUrl: String = "http://localhost:8080/",
+ val userRepositoryClientId: String = "radar_pushendpoint",
+ val userRepositoryClientSecret: String = "",
+ val userRepositoryTokenUrl: String = "http://localhost:8080/token/",
+ val dailiesTopicName: String = "push_garmin_daily_summary",
+ val activitiesTopicName: String = "push_garmin_activity_summary",
+ val activityDetailsTopicName: String = "push_garmin_activity_detail",
+ val epochSummariesTopicName: String = "push_garmin_epoch_summary",
+ val sleepsTopicName: String = "push_garmin_sleep_summary",
+ val bodyCompositionsTopicName: String = "push_garmin_body_composition",
+ val stressTopicName: String = "push_garmin_stress_detail_summary",
+ val userMetricsTopicName: String = "push_garmin_user_metrics",
+ val moveIQTopicName: String = "push_garmin_move_iq_summary",
+ val pulseOXTopicName: String = "push_garmin_pulse_ox",
+ val respirationTopicName: String = "push_garmin_respiration",
+ val activityDetailsSampleTopicName: String = "push_garmin_activity_detail_sample",
+ val bodyBatterySampleTopicName: String = "push_garmin_body_battery_sample",
+ val heartRateSampleConverter: String = "push_garmin_heart_rate_sample",
+ val sleepLevelTopicName: String = "push_garmin_sleep_level",
+ val stressLevelTopicName: String = "push_garmin_stress_level"
+) {
+ val userRepository: Class<*> = Class.forName(userRepositoryClass)
+
+ fun validate() {
+ if (enabled) {
+ check(GarminUserRepository::class.java.isAssignableFrom(userRepository)) {
+ "$userRepositoryClass is not valid. Please specify a class that is a subclass of" +
+ " `org.radarbase.push.integration.garmin.user.GarminUserRepository`"
+ }
+ }
}
}
+data class BackfillConfig(
+ val enabled: Boolean = false,
+ val redis: RedisConfig = RedisConfig(),
+ val maxThreads: Int = 4,
+ val defaultEndDate: Instant = Instant.MAX,
+ val userBackfill: List = emptyList()
+)
+
+data class RedisConfig(
+ val uri: String = "redis://localhost:6379",
+ val lockPrefix: String = "radar-push-garmin/lock"
+)
+
+data class UserBackfillConfig(
+ val userId: String,
+ val startDate: Instant,
+ val endDate: Instant
+)
+
data class GatewayServerConfig(
- /** Base URL to serve data with. This will determine the base path and the port. */
- val baseUri: URI = URI.create("http://0.0.0.0:8090/radar-gateway/"),
- /** Maximum number of simultaneous requests. */
- val maxRequests: Int = 200,
- /**
- * Maximum request content length, also when decompressed.
- * This protects against memory overflows.
- */
- val maxRequestSize: Long = 24*1024*1024,
- /**
- * Whether JMX should be enabled. Disable if not needed, for higher performance.
- */
- val isJmxEnabled: Boolean = true)
+ /** Base URL to serve data with. This will determine the base path and the port. */
+ val baseUri: URI = URI.create("http://0.0.0.0:8090/push/integrations/"),
+ /** Maximum number of simultaneous requests. */
+ val maxRequests: Int = 200,
+ /**
+ * Maximum request content length, also when decompressed.
+ * This protects against memory overflows.
+ */
+ val maxRequestSize: Long = 24 * 1024 * 1024,
+ /**
+ * Whether JMX should be enabled. Disable if not needed, for higher performance.
+ */
+ val isJmxEnabled: Boolean = true
+)
data class KafkaConfig(
- /** Number of Kafka brokers to keep in a pool for reuse in multiple requests. */
- val poolSize: Int = 20,
- /** Kafka producer settings. Read from https://kafka.apache.org/documentation/#producerconfigs. */
- val producer: Map = mapOf(),
- /** Kafka Admin Client settings. Read from https://kafka.apache.org/documentation/#adminclientconfigs. */
- val admin: Map = mapOf(),
- /** Kafka serialization settings, used in KafkaAvroSerializer. Read from [io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig]. */
- val serialization: Map = mapOf()) {
+ /** Number of Kafka brokers to keep in a pool for reuse in multiple requests. */
+ val poolSize: Int = 20,
+ /** Kafka producer settings. Read from https://kafka.apache.org/documentation/#producerconfigs. */
+ val producer: Map = mapOf(),
+ /** Kafka Admin Client settings. Read from https://kafka.apache.org/documentation/#adminclientconfigs. */
+ val admin: Map = mapOf(),
+ /** Kafka serialization settings, used in KafkaAvroSerializer. Read from [io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig]. */
+ val serialization: Map = mapOf()
+) {
fun withDefaults(): KafkaConfig = copy(
- producer = producerDefaults + producer,
- admin = mutableMapOf().apply {
- producer[BOOTSTRAP_SERVERS_CONFIG]?.let {
- this[BOOTSTRAP_SERVERS_CONFIG] = it
- }
- this += adminDefaults
- this += admin
- },
- serialization = serializationDefaults + serialization)
+ producer = producerDefaults + producer,
+ admin = mutableMapOf().apply {
+ producer[BOOTSTRAP_SERVERS_CONFIG]?.let {
+ this[BOOTSTRAP_SERVERS_CONFIG] = it
+ }
+ this += adminDefaults
+ this += admin
+ },
+ serialization = serializationDefaults + serialization
+ )
fun validate() {
check(producer[BOOTSTRAP_SERVERS_CONFIG] is String) { "$BOOTSTRAP_SERVERS_CONFIG missing in kafka: producer: {} configuration" }
@@ -77,72 +149,21 @@ data class KafkaConfig(
companion object {
private val producerDefaults = mapOf(
- "request.timeout.ms" to 3000,
- "max.block.ms" to 6000,
- "linger.ms" to 10,
- "retries" to 5,
- "acks" to "all",
- "delivery.timeout.ms" to 6000)
+ "request.timeout.ms" to 3000,
+ "max.block.ms" to 6000,
+ "linger.ms" to 10,
+ "retries" to 5,
+ "acks" to "all",
+ "delivery.timeout.ms" to 6000
+ )
private val adminDefaults = mapOf(
- "default.api.timeout.ms" to 6000,
- "request.timeout.ms" to 3000,
- "retries" to 5)
+ "default.api.timeout.ms" to 6000,
+ "request.timeout.ms" to 3000,
+ "retries" to 5
+ )
private val serializationDefaults = mapOf(
- MAX_SCHEMAS_PER_SUBJECT_CONFIG to 10_000)
- }
-}
-
-data class AuthConfig(
- /** OAuth 2.0 resource name. */
- val resourceName: String = "res_gateway",
- /**
- * Whether to check that the user that submits data has the reported source ID registered
- * in the ManagementPortal.
- */
- val checkSourceId: Boolean = true,
- /** OAuth 2.0 token issuer. If null, this is not checked. */
- val issuer: String? = null,
- /**
- * ManagementPortal URL. If available, this is used to read the public key from
- * ManagementPortal directly. This is the recommended method of getting public key.
- */
- val managementPortalUrl: String? = null,
- /** Key store for checking the digital signature of OAuth 2.0 JWTs. */
- val keyStore: KeyStoreConfig = KeyStoreConfig(),
- /** Public keys for checking the digital signature of OAuth 2.0 JWTs. */
- val publicKeys: KeyConfig = KeyConfig()
-) {
- fun validate() {
- keyStore.validate()
- check(managementPortalUrl != null || keyStore.isConfigured || publicKeys.isConfigured) {
- "At least one of auth.keyStore, auth.publicKeys or auth.managementPortalUrl must be configured"
- }
+ MAX_SCHEMAS_PER_SUBJECT_CONFIG to 10_000
+ )
}
}
-
-data class KeyStoreConfig(
- /** Path to the p12 key store. */
- val path: Path? = null,
- /** Key alias in the key store. */
- val alias: String? = null,
- /** Password of the key store. */
- val password: String? = null) {
- fun validate() {
- if (path != null) {
- check(Files.exists(path)) { "KeyStore configured in auth.keyStore.path does not exist" }
- checkNotNull(alias) { "KeyStore is configured without auth.keyStore.alias" }
- checkNotNull(password) { "KeyStore is configured without auth.keyStore.password" }
- }
- }
-
- val isConfigured: Boolean = path != null
-}
-
-data class KeyConfig(
- /** List of ECDSA public key signatures in PEM format. */
- val ecdsa: List? = null,
- /** List of RSA public key signatures in PEM format. */
- val rsa: List? = null) {
- val isConfigured: Boolean = !ecdsa.isNullOrEmpty() || !rsa.isNullOrEmpty()
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/filter/KafkaTopicFilter.kt b/src/main/kotlin/org/radarbase/gateway/filter/KafkaTopicFilter.kt
deleted file mode 100644
index 2499341..0000000
--- a/src/main/kotlin/org/radarbase/gateway/filter/KafkaTopicFilter.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package org.radarbase.gateway.filter
-
-import org.radarbase.gateway.inject.ProcessAvro
-import org.radarbase.gateway.kafka.KafkaAdminService
-import org.radarbase.jersey.exception.HttpApplicationException
-import org.radarbase.jersey.exception.HttpNotFoundException
-import org.slf4j.LoggerFactory
-import java.util.concurrent.ExecutionException
-import javax.annotation.Priority
-import javax.inject.Singleton
-import javax.ws.rs.Priorities
-import javax.ws.rs.container.ContainerRequestContext
-import javax.ws.rs.container.ContainerRequestFilter
-import javax.ws.rs.core.Context
-import javax.ws.rs.core.Response
-import javax.ws.rs.ext.Provider
-
-/**
- * Asserts that data is only submitted to Kafka topics that already exist.
- */
-@Provider
-@ProcessAvro
-@Priority(Priorities.USER)
-@Singleton
-class KafkaTopicFilter constructor(
- @Context private val kafkaAdmin: KafkaAdminService
-) : ContainerRequestFilter {
- override fun filter(requestContext: ContainerRequestContext) {
- val topic = requestContext.uriInfo.pathParameters.getFirst("topic_name")
-
- try {
- // topic exists or exists after an update
- if (!kafkaAdmin.containsTopic(topic)) {
- throw HttpNotFoundException("not_found", "Topic $topic not present in Kafka.")
- }
- } catch (ex: ExecutionException) {
- logger.error("Failed to list topics", ex)
- throw HttpApplicationException(Response.Status.SERVICE_UNAVAILABLE, "Cannot complete topic list operation")
- }
- }
-
- companion object {
- private val logger = LoggerFactory.getLogger(KafkaTopicFilter::class.java)
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/inject/EcdsaJwtEnhancerFactory.kt b/src/main/kotlin/org/radarbase/gateway/inject/EcdsaJwtEnhancerFactory.kt
deleted file mode 100644
index 8ab1f6b..0000000
--- a/src/main/kotlin/org/radarbase/gateway/inject/EcdsaJwtEnhancerFactory.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package org.radarbase.gateway.inject
-
-import org.radarbase.gateway.Config
-import org.radarbase.jersey.auth.AuthConfig
-import org.radarbase.jersey.auth.MPConfig
-import org.radarbase.jersey.config.*
-
-
-/** This binder needs to register all non-Jersey classes, otherwise initialization fails. */
-class EcdsaJwtEnhancerFactory(private val config: Config) : EnhancerFactory {
- override fun createEnhancers(): List {
- val authConfig = AuthConfig(
- managementPortal = MPConfig(url = config.auth.managementPortalUrl),
- jwtResourceName = config.auth.resourceName,
- jwtIssuer = config.auth.issuer,
- jwtECPublicKeys = config.auth.publicKeys.ecdsa,
- jwtRSAPublicKeys = config.auth.publicKeys.rsa,
- jwtKeystoreAlias = config.auth.keyStore.alias,
- jwtKeystorePassword = config.auth.keyStore.password,
- jwtKeystorePath = config.auth.keyStore.path.toString())
- return listOf(
- GatewayResourceEnhancer(config),
- ConfigLoader.Enhancers.radar(authConfig),
- ConfigLoader.Enhancers.ecdsa,
- ConfigLoader.Enhancers.httpException,
- ConfigLoader.Enhancers.generalException)
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/inject/GatewayResourceEnhancer.kt b/src/main/kotlin/org/radarbase/gateway/inject/GatewayResourceEnhancer.kt
index 6cd1c1b..b70724f 100644
--- a/src/main/kotlin/org/radarbase/gateway/inject/GatewayResourceEnhancer.kt
+++ b/src/main/kotlin/org/radarbase/gateway/inject/GatewayResourceEnhancer.kt
@@ -1,59 +1,34 @@
package org.radarbase.gateway.inject
import org.glassfish.jersey.internal.inject.AbstractBinder
-import org.glassfish.jersey.internal.inject.PerThread
import org.glassfish.jersey.message.DeflateEncoder
import org.glassfish.jersey.message.GZipEncoder
import org.glassfish.jersey.server.filter.EncodingFilter
import org.radarbase.gateway.Config
-import org.radarbase.gateway.io.AvroProcessor
-import org.radarbase.gateway.io.AvroProcessorFactory
-import org.radarbase.gateway.io.BinaryToAvroConverter
-import org.radarbase.gateway.io.LzfseEncoder
import org.radarbase.gateway.kafka.*
-import org.radarbase.gateway.service.SchedulingService
-import org.radarbase.gateway.service.SchedulingServiceFactory
import org.radarbase.jersey.config.ConfigLoader
import org.radarbase.jersey.config.JerseyResourceEnhancer
import org.radarbase.jersey.service.HealthService
-import org.radarbase.jersey.service.ProjectService
import org.radarbase.producer.rest.SchemaRetriever
import javax.inject.Singleton
class GatewayResourceEnhancer(private val config: Config): JerseyResourceEnhancer {
- override val packages: Array = arrayOf(
- "org.radarbase.gateway.filter",
- "org.radarbase.gateway.io",
- "org.radarbase.gateway.resource")
override val classes: Array> = arrayOf(
EncodingFilter::class.java,
GZipEncoder::class.java,
DeflateEncoder::class.java,
- LzfseEncoder::class.java,
ConfigLoader.Filters.logResponse)
override fun AbstractBinder.enhance() {
bind(config)
.to(Config::class.java)
- bindFactory(AvroProcessorFactory::class.java)
- .to(AvroProcessor::class.java)
- .`in`(Singleton::class.java)
-
- bind(BinaryToAvroConverter::class.java)
- .to(BinaryToAvroConverter::class.java)
- .`in`(PerThread::class.java)
-
// Bind factories.
bindFactory(SchemaRetrieverFactory::class.java)
.to(SchemaRetriever::class.java)
.`in`(Singleton::class.java)
- bindFactory(SchedulingServiceFactory::class.java)
- .to(SchedulingService::class.java)
- .`in`(Singleton::class.java)
-
bindFactory(ProducerPoolFactory::class.java)
.to(ProducerPool::class.java)
.`in`(Singleton::class.java)
@@ -62,18 +37,9 @@ class GatewayResourceEnhancer(private val config: Config): JerseyResourceEnhance
.to(KafkaAdminService::class.java)
.`in`(Singleton::class.java)
- bind(UnverifiedProjectService::class.java)
- .to(ProjectService::class.java)
- .`in`(Singleton::class.java)
-
bind(KafkaHealthMetric::class.java)
.named("kafka")
.to(HealthService.Metric::class.java)
.`in`(Singleton::class.java)
}
-
- /** Project service without validation of the project's existence. */
- class UnverifiedProjectService: ProjectService {
- override fun ensureProject(projectId: String) = Unit
- }
}
diff --git a/src/main/kotlin/org/radarbase/gateway/inject/ManagementPortalEnhancerFactory.kt b/src/main/kotlin/org/radarbase/gateway/inject/ManagementPortalEnhancerFactory.kt
deleted file mode 100644
index e525b99..0000000
--- a/src/main/kotlin/org/radarbase/gateway/inject/ManagementPortalEnhancerFactory.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package org.radarbase.gateway.inject
-
-import org.radarbase.gateway.Config
-import org.radarbase.jersey.auth.AuthConfig
-import org.radarbase.jersey.auth.MPConfig
-import org.radarbase.jersey.config.*
-
-/** This binder needs to register all non-Jersey classes, otherwise initialization fails. */
-class ManagementPortalEnhancerFactory(private val config: Config) : EnhancerFactory {
- override fun createEnhancers(): List {
- val authConfig = AuthConfig(
- managementPortal = MPConfig(
- url = config.auth.managementPortalUrl),
- jwtResourceName = config.auth.resourceName,
- jwtIssuer = config.auth.issuer)
- return listOf(
- GatewayResourceEnhancer(config),
- ConfigLoader.Enhancers.radar(authConfig),
- ConfigLoader.Enhancers.managementPortal(authConfig),
- ConfigLoader.Enhancers.health,
- ConfigLoader.Enhancers.httpException,
- ConfigLoader.Enhancers.generalException)
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/inject/ProcessAvro.kt b/src/main/kotlin/org/radarbase/gateway/inject/ProcessAvro.kt
deleted file mode 100644
index 6fc02a2..0000000
--- a/src/main/kotlin/org/radarbase/gateway/inject/ProcessAvro.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package org.radarbase.gateway.inject
-
-import javax.ws.rs.NameBinding
-
-/** Tag for additional processing required for incoming Avro data. */
-@NameBinding
-@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION,
- AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER)
-@Retention(AnnotationRetention.RUNTIME)
-annotation class ProcessAvro
diff --git a/src/main/kotlin/org/radarbase/gateway/inject/PushIntegrationEnhancerFactory.kt b/src/main/kotlin/org/radarbase/gateway/inject/PushIntegrationEnhancerFactory.kt
new file mode 100644
index 0000000..f44bd3e
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/gateway/inject/PushIntegrationEnhancerFactory.kt
@@ -0,0 +1,31 @@
+package org.radarbase.gateway.inject
+
+import okhttp3.internal.toImmutableList
+import org.radarbase.gateway.Config
+import org.radarbase.jersey.config.ConfigLoader
+import org.radarbase.jersey.config.EnhancerFactory
+import org.radarbase.jersey.config.JerseyResourceEnhancer
+import org.radarbase.push.integration.GarminPushIntegrationResourceEnhancer
+import org.radarbase.push.integration.common.inject.PushIntegrationResourceEnhancer
+
+class PushIntegrationEnhancerFactory(private val config: Config) : EnhancerFactory {
+
+ override fun createEnhancers(): List {
+
+ val enhancersList = mutableListOf(
+ GatewayResourceEnhancer(config),
+ ConfigLoader.Enhancers.health,
+ ConfigLoader.Enhancers.httpException,
+ ConfigLoader.Enhancers.generalException,
+ RadarResourceEnhancer(),
+ PushIntegrationResourceEnhancer()
+ )
+
+ if (config.pushIntegration.garmin.enabled) {
+ enhancersList.add(GarminPushIntegrationResourceEnhancer(config))
+ }
+ // Add more enhancers as services are added
+
+ return enhancersList.toImmutableList()
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/gateway/inject/RadarResourceEnhancer.kt b/src/main/kotlin/org/radarbase/gateway/inject/RadarResourceEnhancer.kt
new file mode 100644
index 0000000..140943f
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/gateway/inject/RadarResourceEnhancer.kt
@@ -0,0 +1,49 @@
+package org.radarbase.gateway.inject
+
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import okhttp3.OkHttpClient
+import org.glassfish.jersey.internal.inject.AbstractBinder
+import org.glassfish.jersey.server.ResourceConfig
+import org.radarbase.jersey.auth.filter.AuthenticationFilter
+import org.radarbase.jersey.auth.filter.AuthorizationFeature
+import org.radarbase.jersey.config.JerseyResourceEnhancer
+import java.util.concurrent.TimeUnit
+import javax.ws.rs.ext.ContextResolver
+
+class RadarResourceEnhancer: JerseyResourceEnhancer {
+
+ var mapper: ObjectMapper = ObjectMapper()
+ .setSerializationInclusion(JsonInclude.Include.NON_NULL)
+ .registerModule(JavaTimeModule())
+ .registerModule(KotlinModule())
+ .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+
+ var client: OkHttpClient = OkHttpClient().newBuilder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .writeTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ override val classes = arrayOf(
+ AuthenticationFilter::class.java,
+ AuthorizationFeature::class.java)
+
+ override fun ResourceConfig.enhance() {
+ register(ContextResolver { mapper })
+ }
+
+ override fun AbstractBinder.enhance() {
+
+ bind(client)
+ .to(OkHttpClient::class.java)
+
+ bind(mapper)
+ .to(ObjectMapper::class.java)
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/gateway/io/AvroJsonReader.kt b/src/main/kotlin/org/radarbase/gateway/io/AvroJsonReader.kt
deleted file mode 100644
index 19af4c1..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/AvroJsonReader.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.radarbase.gateway.io
-
-import com.fasterxml.jackson.core.JsonParseException
-import com.fasterxml.jackson.databind.JsonNode
-import com.fasterxml.jackson.databind.ObjectMapper
-import org.radarbase.jersey.exception.HttpBadRequestException
-import java.io.InputStream
-import java.lang.reflect.Type
-import javax.ws.rs.Consumes
-import javax.ws.rs.core.Context
-import javax.ws.rs.core.MediaType
-import javax.ws.rs.core.MultivaluedMap
-import javax.ws.rs.ext.MessageBodyReader
-import javax.ws.rs.ext.Provider
-
-/** Reads in JSON Avro. */
-@Provider
-@Consumes("application/vnd.kafka.avro.v1+json", "application/vnd.kafka.avro.v2+json")
-class AvroJsonReader(
- @Context objectMapper: ObjectMapper,
-) : MessageBodyReader {
- private val objectFactory = objectMapper.factory
-
- override fun readFrom(type: Class?, genericType: Type?,
- annotations: Array?, mediaType: MediaType?,
- httpHeaders: MultivaluedMap?, entityStream: InputStream?): JsonNode {
-
- val parser = objectFactory.createParser(entityStream)
- return try {
- parser.readValueAsTree()
- } catch (ex: JsonParseException) {
- throw HttpBadRequestException("malformed_json", ex.message ?: ex.toString())
- } ?: throw HttpBadRequestException("malformed_json", "No content given")
- }
-
- override fun isReadable(type: Class<*>?, genericType: Type?,
- annotations: Array?, mediaType: MediaType?) = true
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/io/AvroProcessingResult.kt b/src/main/kotlin/org/radarbase/gateway/io/AvroProcessingResult.kt
deleted file mode 100644
index ee768a2..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/AvroProcessingResult.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package org.radarbase.gateway.io
-
-import org.apache.avro.generic.GenericRecord
-
-data class AvroProcessingResult(
- val keySchemaId: Int,
- val valueSchemaId: Int,
- val records: List>)
diff --git a/src/main/kotlin/org/radarbase/gateway/io/AvroProcessor.kt b/src/main/kotlin/org/radarbase/gateway/io/AvroProcessor.kt
deleted file mode 100644
index 753780b..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/AvroProcessor.kt
+++ /dev/null
@@ -1,386 +0,0 @@
-package org.radarbase.gateway.io
-
-import com.fasterxml.jackson.databind.JsonNode
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.node.ArrayNode
-import com.fasterxml.jackson.databind.node.ObjectNode
-import org.apache.avro.Schema
-import org.apache.avro.generic.GenericData
-import org.apache.avro.generic.GenericFixed
-import org.apache.avro.generic.GenericRecord
-import org.apache.avro.generic.GenericRecordBuilder
-import org.apache.avro.jsonDefaultValue
-import org.radarbase.gateway.Config
-import org.radarbase.gateway.service.SchedulingService
-import org.radarbase.jersey.auth.Auth
-import org.radarbase.jersey.exception.HttpApplicationException
-import org.radarbase.jersey.exception.HttpBadGatewayException
-import org.radarbase.jersey.exception.HttpInvalidContentException
-import org.radarbase.jersey.util.CacheConfig
-import org.radarbase.jersey.util.CachedValue
-import org.radarbase.producer.rest.AvroDataMapper
-import org.radarbase.producer.rest.AvroDataMapperFactory
-import org.radarbase.producer.rest.RestException
-import org.radarbase.producer.rest.SchemaRetriever
-import org.radarcns.auth.authorization.Permission
-import java.io.Closeable
-import java.io.IOException
-import java.nio.ByteBuffer
-import java.nio.charset.StandardCharsets
-import java.text.ParseException
-import java.time.Duration
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.ConcurrentMap
-
-/**
- * Reads messages as semantically valid and authenticated Avro for the RADAR platform. Amends
- * unfilled security metadata as necessary.
- */
-class AvroProcessor(
- private val config: Config,
- private val auth: Auth,
- private val schemaRetriever: SchemaRetriever,
- private val objectMapper: ObjectMapper,
- schedulingService: SchedulingService
-): Closeable {
- private val idMapping: ConcurrentMap, CachedValue> = ConcurrentHashMap()
- private val schemaMapping: ConcurrentMap, CachedValue> = ConcurrentHashMap()
- private val cleanReference: SchedulingService.RepeatReference
-
- init {
- // Clean stale caches regularly. This reduces memory use for caches that aren't being used.
- cleanReference = schedulingService.repeat(SCHEMA_CLEAN, SCHEMA_CLEAN) {
- idMapping.values.removeIf { it.isStale }
- schemaMapping.values.removeIf { it.isStale }
- }
- }
-
- /**
- * Validates given data with given access token and returns a modified output array.
- * The Avro content validation consists of testing whether both keys and values are being sent,
- * both with Avro schema. The authentication validation checks that all records contain a key
- * with a project ID, user ID and source ID that is also listed in the access token. If no
- * project ID is given in the key, it will be set to the first project ID where the user has
- * role {@code ROLE_PARTICIPANT}.
- *
- * @throws ParseException if the data does not contain valid JSON
- * @throws HttpInvalidContentException if the data does not contain semantically correct Kafka Avro data.
- * @throws IOException if the data cannot be read
- */
- fun process(topic: String, root: JsonNode): AvroProcessingResult {
- if (!root.isObject) {
- throw HttpInvalidContentException("Expecting JSON object in payload")
- }
- if (root["key_schema_id"].isMissing && root["key_schema"].isMissing) {
- throw HttpInvalidContentException("Missing key schema")
- }
- if (root["value_schema_id"].isMissing && root["value_schema"].isMissing) {
- throw HttpInvalidContentException("Missing value schema")
- }
-
- val keyMapping = schemaMapping(topic, false, root["key_schema_id"], root["key_schema"])
- val valueMapping = schemaMapping(topic, true, root["value_schema_id"], root["value_schema"])
-
- val records = root["records"] ?: throw HttpInvalidContentException("Missing records")
- return AvroProcessingResult(
- keyMapping.targetSchemaId,
- valueMapping.targetSchemaId,
- processRecords(records, keyMapping, valueMapping))
- }
-
-
- @Throws(IOException::class)
- private fun processRecords(records: JsonNode, keyMapping: JsonToObjectMapping, valueMapping: JsonToObjectMapping): List> {
- if (!records.isArray) {
- throw HttpInvalidContentException("Records should be an array")
- }
-
- return records.map { record ->
- val key = record["key"] ?: throw HttpInvalidContentException("Missing key field in record")
- val value = record["value"] ?: throw HttpInvalidContentException("Missing value field in record")
- Pair(processKey(key, keyMapping), processValue(value, valueMapping))
- }
- }
-
- /** Parse single record key. */
- @Throws(IOException::class)
- private fun processKey(key: JsonNode, keyMapping: JsonToObjectMapping): GenericRecord {
- if (!key.isObject) {
- throw HttpInvalidContentException("Field key must be a JSON object")
- }
-
- val projectId = key["projectId"]?.let { project ->
- if (project.isNull) {
- // no project ID was provided, fill it in for the sender
- val newProject = objectMapper.createObjectNode()
- newProject.put("string", auth.defaultProject)
- (key as ObjectNode).set("projectId", newProject) as JsonNode?
- auth.defaultProject
- } else {
- // project ID was provided, it should match one of the validated project IDs.
- project["string"]?.asText() ?: throw HttpInvalidContentException(
- "Project ID should be wrapped in string union type")
- }
- } ?: auth.defaultProject
-
- if (config.auth.checkSourceId) {
- auth.checkPermissionOnSource(Permission.MEASUREMENT_CREATE,
- projectId, key["userId"]?.asText(), key["sourceId"]?.asText())
- } else {
- auth.checkPermissionOnSubject(Permission.MEASUREMENT_CREATE,
- projectId, key["userId"]?.asText())
- }
-
- return keyMapping.jsonToAvro(key)
- }
-
-
- /** Parse single record key. */
- @Throws(IOException::class)
- private fun processValue(value: JsonNode, valueMapping: JsonToObjectMapping): GenericRecord {
- if (!value.isObject) {
- throw HttpInvalidContentException("Field value must be a JSON object")
- }
-
- return valueMapping.jsonToAvro(value)
- }
-
- private fun createMapping(topic: String, ofValue: Boolean, sourceSchema: Schema): JsonToObjectMapping {
- val latestSchema = schemaRetriever.getBySubjectAndVersion(topic, ofValue, -1)
- val schemaMapper = AvroDataMapperFactory.get().createMapper(sourceSchema, latestSchema.schema, null)
- return JsonToObjectMapping(sourceSchema, latestSchema.schema, latestSchema.id, schemaMapper)
- }
-
- private fun schemaMapping(topic: String, ofValue: Boolean, id: JsonNode?, schema: JsonNode?): JsonToObjectMapping {
- val subject = "$topic-${if (ofValue) "value" else "key"}"
- return when {
- id?.isNumber == true -> {
- idMapping.computeIfAbsent(Pair(subject, id.asInt())) {
- CachedValue(cacheConfig, {
- val parsedSchema = try {
- schemaRetriever.getBySubjectAndId(topic, ofValue, id.asInt())
- } catch (ex: RestException) {
- if (ex.statusCode == 404) {
- throw HttpApplicationException(422, "schema_not_found", "Schema ID not found in subject")
- } else {
- throw HttpBadGatewayException("cannot get data from schema registry: ${ex.javaClass.simpleName}")
- }
- }
- createMapping(topic, ofValue, parsedSchema.schema)
- })
- }.get()
- }
- schema?.isTextual == true -> {
- schemaMapping.computeIfAbsent(Pair(subject, schema.asText())) {
- CachedValue(cacheConfig, {
- try {
- val parsedSchema = Schema.Parser().parse(schema.textValue())
- createMapping(topic, ofValue, parsedSchema)
- } catch (ex: Exception) {
- throw throw HttpApplicationException(422, "schema_not_found", "Schema ID not found in subject")
- }
- })
- }.get()
- }
- else -> throw HttpInvalidContentException("No schema provided")
- }
- }
-
- private fun JsonNode.toAvro(to: Schema, defaultVal: JsonNode? = null): Any? {
- val useNode = if (isNull) {
- if (to.type == Schema.Type.NULL) return null
- if (defaultVal.isMissing) throw HttpInvalidContentException("No value given to field without default.")
- return defaultVal!!.toAvro(to)
- } else this
-
- return when (to.type!!) {
- Schema.Type.RECORD -> useNode.toAvroObject(to)
- Schema.Type.LONG, Schema.Type.FLOAT, Schema.Type.DOUBLE, Schema.Type.INT -> useNode.toAvroNumber(to.type)
- Schema.Type.BOOLEAN -> useNode.toAvroBoolean()
- Schema.Type.ARRAY -> useNode.toAvroArray(to.elementType)
- Schema.Type.NULL -> null
- Schema.Type.BYTES -> useNode.toAvroBytes()
- Schema.Type.FIXED -> useNode.toAvroFixed(to)
- Schema.Type.ENUM -> useNode.toAvroEnum(to, defaultVal)
- Schema.Type.MAP -> useNode.toAvroMap(to.valueType)
- Schema.Type.STRING -> useNode.toAvroString()
- Schema.Type.UNION -> useNode.toAvroUnion(to, defaultVal)
- }
- }
-
- private fun JsonNode.toAvroString(): String {
- return if (isTextual || isNumber || isBoolean) asText()
- else throw HttpInvalidContentException("Cannot map non-simple types to string: $this")
- }
-
- private fun JsonNode.toAvroUnion(to: Schema, defaultVal: JsonNode?): Any? {
- return when {
- this is ObjectNode -> {
- val fieldName = fieldNames().asSequence().firstOrNull()
- ?: throw HttpInvalidContentException("Cannot union without a value")
- val type = to.types.firstOrNull { unionType ->
- fieldName == unionType.name || unionType.fullName == fieldName
- } ?: throw HttpInvalidContentException("Cannot find any matching union types")
-
- this[fieldName].toAvro(type)
- }
- isNumber -> {
- val type = to.types.firstOrNull { unionType ->
- unionType.type == Schema.Type.LONG
- || unionType.type == Schema.Type.INT
- || unionType.type == Schema.Type.FLOAT
- || unionType.type == Schema.Type.DOUBLE
- } ?: throw HttpInvalidContentException("Cannot map number to non-number union")
- toAvroNumber(type.type)
- }
- isTextual -> {
- val type = to.types.firstOrNull { unionType ->
- unionType.type == Schema.Type.STRING
- || unionType.type == Schema.Type.FIXED
- || unionType.type == Schema.Type.BYTES
- || unionType.type == Schema.Type.ENUM
- } ?: throw HttpInvalidContentException("Cannot map number to non-number union")
- toAvro(type)
- }
- isBoolean -> {
- if (to.types.none { unionType -> unionType.type == Schema.Type.BOOLEAN }) {
- throw HttpInvalidContentException("Cannot map boolean to non-boolean union")
- }
- toAvroBoolean()
- }
- isArray -> {
- val type = to.types.firstOrNull { unionType ->
- unionType.type == Schema.Type.ARRAY
- } ?: throw HttpInvalidContentException("Cannot map array to non-array union")
- return toAvroArray(type.elementType)
- }
- isObject -> {
- val type = to.types.firstOrNull { unionType ->
- unionType.type == Schema.Type.MAP
- || unionType.type == Schema.Type.RECORD
- } ?: throw HttpInvalidContentException("Cannot map object to non-object union")
- return toAvro(type, defaultVal)
- }
- else -> throw HttpInvalidContentException("Cannot map unknown JSON node type")
- }
- }
-
- private fun JsonNode.toAvroEnum(schema: Schema, defaultVal: JsonNode?): GenericData.EnumSymbol {
- return if (isTextual) {
- val textValue = asText()!!
- if (schema.hasEnumSymbol(textValue)) {
- GenericData.EnumSymbol(schema, textValue)
- } else if (defaultVal != null && defaultVal.isTextual) {
- val defaultText = defaultVal.asText()
- if (schema.hasEnumSymbol(defaultText)) {
- GenericData.EnumSymbol(schema, defaultText)
- } else throw HttpInvalidContentException("Enum symbol default cannot be found")
- } else throw HttpInvalidContentException("Enum symbol without default cannot be found")
- } else throw HttpInvalidContentException("Can only convert strings to enum")
- }
-
- private fun JsonNode.toAvroMap(schema: Schema): Map {
- return if (this is ObjectNode) {
- fieldNames().asSequence()
- .associateWithTo(LinkedHashMap()) { key -> get(key).toAvro(schema) }
- } else throw HttpInvalidContentException("Can only convert objects to map")
- }
-
- private fun JsonNode.toAvroBytes(): ByteBuffer {
- return if (isTextual) {
- val fromArray = textValue()!!.toByteArray(StandardCharsets.ISO_8859_1)
- ByteBuffer.wrap(fromArray)
- } else throw HttpInvalidContentException("Can only convert strings to byte arrays")
- }
-
- private fun JsonNode.toAvroFixed(schema: Schema): GenericFixed {
- return if (isTextual) {
- val bytes = textValue()!!.toByteArray(StandardCharsets.ISO_8859_1)
- if (bytes.size != schema.fixedSize) {
- throw HttpInvalidContentException("Cannot use a different Fixed size")
- }
- GenericData.Fixed(schema, bytes)
- } else throw HttpInvalidContentException("Can only convert strings to byte arrays")
- }
-
- private fun JsonNode.toAvroObject(schema: Schema): GenericRecord {
- this as? ObjectNode ?: throw HttpInvalidContentException("Cannot map non-object to object")
- val builder = GenericRecordBuilder(schema)
- for (field in schema.fields) {
- get(field.name())?.let { node ->
- builder[field] = node.toAvro(field.schema(), field.jsonDefaultValue)
- }
- }
- return builder.build()
- }
-
- private fun JsonNode.toAvroArray(schema: Schema): Any {
- return when {
- isArray -> GenericData.Array(schema, (this as ArrayNode).toList())
- else -> throw HttpInvalidContentException("Cannot map non-array to array")
- }
- }
-
- private fun JsonNode.toAvroNumber(schemaType: Schema.Type): Number {
- return when {
- isNumber -> when (schemaType) {
- Schema.Type.LONG -> asLong()
- Schema.Type.FLOAT -> asDouble().toFloat()
- Schema.Type.DOUBLE -> asDouble()
- Schema.Type.INT -> asInt()
- else -> throw HttpInvalidContentException("Non-number type used for numbers")
- }
- isTextual -> when (schemaType) {
- Schema.Type.LONG -> asText().toLong()
- Schema.Type.FLOAT -> asText().toFloat()
- Schema.Type.DOUBLE -> asText().toDouble()
- Schema.Type.INT -> asText().toInt()
- else -> throw HttpInvalidContentException("Non-number type used for numbers")
- }
- else -> throw HttpInvalidContentException("Cannot map non-number to number")
- }
- }
-
- private fun JsonNode.toAvroBoolean(): Boolean {
- return when {
- isBoolean -> asBoolean()
- isTextual -> when (asText()!!) {
- "true" -> true
- "false" -> false
- else -> throw HttpInvalidContentException("Cannot map non-boolean string to boolean")
- }
- isNumber -> asDouble() != 0.0
- else -> throw HttpInvalidContentException("Cannot map non-boolean to boolean")
- }
- }
-
- override fun close() {
- cleanReference.close()
- }
-
- companion object {
- private val SCHEMA_CLEAN = Duration.ofHours(2)
-
- private val cacheConfig = CacheConfig(
- refreshDuration = Duration.ofHours(1),
- retryDuration = Duration.ofMinutes(2),
- staleThresholdDuration = Duration.ofMinutes(30),
- maxSimultaneousCompute = 3,
- )
-
- val JsonNode?.isMissing: Boolean
- get() = this == null || this.isNull
- }
-
-
- data class JsonToObjectMapping(
- val sourceSchema: Schema,
- val targetSchema: Schema,
- val targetSchemaId: Int,
- val mapper: AvroDataMapper)
-
- private fun JsonToObjectMapping.jsonToAvro(node: JsonNode): GenericRecord {
- val originalRecord = node.toAvroObject(sourceSchema)
- return mapper.convertAvro(originalRecord) as GenericRecord
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/io/AvroProcessorFactory.kt b/src/main/kotlin/org/radarbase/gateway/io/AvroProcessorFactory.kt
deleted file mode 100644
index 0183b46..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/AvroProcessorFactory.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.radarbase.gateway.io
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import org.glassfish.jersey.internal.inject.DisposableSupplier
-import org.radarbase.gateway.Config
-import org.radarbase.gateway.service.SchedulingService
-import org.radarbase.jersey.auth.Auth
-import org.radarbase.producer.rest.SchemaRetriever
-import javax.ws.rs.core.Context
-
-class AvroProcessorFactory(
- @Context private val config: Config,
- @Context private val auth: Auth,
- @Context private val objectMapper: ObjectMapper,
- @Context private val schedulingService: SchedulingService,
- @Context private val schemaRetriever: SchemaRetriever
-): DisposableSupplier {
- override fun get() = AvroProcessor(config, auth, schemaRetriever, objectMapper, schedulingService)
-
- override fun dispose(instance: AvroProcessor?) {
- instance?.close()
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/io/BinaryToAvroConverter.kt b/src/main/kotlin/org/radarbase/gateway/io/BinaryToAvroConverter.kt
deleted file mode 100644
index 4f4b274..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/BinaryToAvroConverter.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-package org.radarbase.gateway.io
-
-import com.fasterxml.jackson.databind.util.ByteBufferBackedInputStream
-import org.apache.avro.Schema
-import org.apache.avro.generic.GenericData
-import org.apache.avro.generic.GenericDatumReader
-import org.apache.avro.generic.GenericRecord
-import org.apache.avro.io.BinaryDecoder
-import org.apache.avro.io.Decoder
-import org.apache.avro.io.DecoderFactory
-import org.radarbase.gateway.Config
-import org.radarbase.jersey.auth.Auth
-import org.radarbase.jersey.exception.HttpInvalidContentException
-import org.radarbase.producer.rest.SchemaRetriever
-import java.io.IOException
-import java.io.InputStream
-import java.nio.ByteBuffer
-import javax.ws.rs.core.Context
-
-/** Converts binary input from a RecordSet to Kafka JSON. */
-class BinaryToAvroConverter(
- @Context private val schemaRetriever: SchemaRetriever,
- @Context private val auth: Auth,
- @Context private val config: Config) {
-
- private var binaryDecoder: BinaryDecoder? = null
- private val readContext = ReadContext()
-
- fun process(topic: String, input: InputStream): AvroProcessingResult {
- val decoder = DecoderFactory.get().binaryDecoder(input, binaryDecoder)
-
- binaryDecoder = decoder
-
- val recordData = DecodedRecordData(topic, decoder, schemaRetriever, auth, readContext, config.auth.checkSourceId)
-
- return AvroProcessingResult(
- recordData.keySchemaMetadata.id,
- recordData.valueSchemaMetadata.id,
- recordData.map { value ->
- Pair(recordData.key, value)
- })
- }
-
- class ReadContext {
- private val genericData = GenericData().apply {
- isFastReaderEnabled = true
- }
- private var buffer: ByteBuffer? = null
- private var valueDecoder : BinaryDecoder? = null
- private var valueReader : GenericDatumReader? = null
-
- fun init(schema: Schema) {
- if (valueReader?.schema != schema) {
- @Suppress("UNCHECKED_CAST")
- valueReader = genericData.createDatumReader(schema) as GenericDatumReader
- }
- }
-
- fun decodeValue(decoder: Decoder): GenericRecord {
- return try {
- buffer = decoder.readBytes(buffer)
- valueDecoder = DecoderFactory.get().binaryDecoder(ByteBufferBackedInputStream(buffer), valueDecoder)
- val reader = valueReader ?: throw IllegalStateException("Value reader is not yet set")
- reader.read(null, valueDecoder)
- ?: throw HttpInvalidContentException("No record in data")
- } catch (ex: IOException) {
- throw HttpInvalidContentException("Malformed record contents: ${ex.message}")
- }
- }
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/io/DecodedRecordData.kt b/src/main/kotlin/org/radarbase/gateway/io/DecodedRecordData.kt
deleted file mode 100644
index bbe70f6..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/DecodedRecordData.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-package org.radarbase.gateway.io
-
-import org.apache.avro.Schema
-import org.apache.avro.generic.GenericRecord
-import org.apache.avro.generic.GenericRecordBuilder
-import org.apache.avro.io.BinaryDecoder
-import org.radarbase.data.RecordData
-import org.radarbase.jersey.auth.Auth
-import org.radarbase.producer.rest.ParsedSchemaMetadata
-import org.radarbase.producer.rest.SchemaRetriever
-import org.radarbase.topic.AvroTopic
-import org.radarcns.auth.authorization.Permission
-
-class DecodedRecordData(
- topicName: String,
- private val decoder: BinaryDecoder,
- schemaRetriever: SchemaRetriever,
- auth: Auth,
- private val readContext: BinaryToAvroConverter.ReadContext,
- checkSources: Boolean) : RecordData {
-
- private val key: GenericRecord
- private var size: Int
- private var remaining: Int
- private val topic: AvroTopic
-
- val keySchemaMetadata: ParsedSchemaMetadata
- val valueSchemaMetadata: ParsedSchemaMetadata
-
- init {
- val keyVersion = decoder.readInt()
- val valueVersion = decoder.readInt()
- val projectId = if (decoder.readIndex() == 1) decoder.readString() else auth.defaultProject
- val userId = if (decoder.readIndex() == 1) decoder.readString() else auth.userId
- val sourceId = decoder.readString()
-
- if (checkSources) {
- auth.checkPermissionOnSource(Permission.MEASUREMENT_CREATE, projectId, userId, sourceId)
- } else {
- auth.checkPermissionOnSubject(Permission.MEASUREMENT_CREATE, projectId, userId)
- }
-
- remaining = decoder.readArrayStart().toInt()
- size = remaining
-
- keySchemaMetadata = schemaRetriever.getBySubjectAndVersion(topicName, false, keyVersion)
- valueSchemaMetadata = schemaRetriever.getBySubjectAndVersion(topicName, true, valueVersion)
-
- topic = AvroTopic(topicName, keySchemaMetadata.schema, valueSchemaMetadata.schema,
- GenericRecord::class.java, GenericRecord::class.java)
-
- key = createKey(keySchemaMetadata.schema, projectId!!, userId!!, sourceId)
- readContext.init(valueSchemaMetadata.schema)
- }
-
- private fun createKey(schema: Schema, projectId: String, userId: String, sourceId: String):
- GenericRecord {
- val keyBuilder = GenericRecordBuilder(schema)
- schema.getField("projectId")?.let { keyBuilder.set(it, projectId) }
- schema.getField("userId")?.let { keyBuilder.set(it, userId) }
- schema.getField("sourceId")?.let { keyBuilder.set(it, sourceId) }
- return keyBuilder.build()
- }
-
- override fun getKey() = key
-
- override fun isEmpty() = size == 0
-
- override fun iterator(): MutableIterator {
- check(remaining != 0) { "Cannot read decoded record data twice." }
-
- return object : MutableIterator {
- override fun hasNext() = remaining > 0
-
- override fun next(): GenericRecord {
- if (!hasNext()) throw NoSuchElementException()
-
- val result = readContext.decodeValue(decoder)
- remaining--
- if (remaining == 0) {
- remaining = decoder.arrayNext().toInt()
- size += remaining
- }
- return result
- }
-
- override fun remove() {
- throw NotImplementedError()
- }
- }
- }
-
- override fun size() = size
-
- override fun getTopic() = topic
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/io/LimitedInputStream.kt b/src/main/kotlin/org/radarbase/gateway/io/LimitedInputStream.kt
deleted file mode 100644
index ec13d7f..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/LimitedInputStream.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package org.radarbase.gateway.io
-
-import org.radarbase.jersey.exception.HttpRequestEntityTooLarge
-import java.io.InputStream
-import kotlin.math.max
-import kotlin.math.min
-
-internal class LimitedInputStream(private val subStream: InputStream, private val limit: Long): InputStream() {
- private var count: Long = 0
-
- override fun available() = min(subStream.available(), (limit - count).toInt())
-
- override fun read(): Int {
- return subStream.read().also {
- if (it != -1) count++
- if (count > limit) throw HttpRequestEntityTooLarge("Stream size exceeds limit $limit")
- }
- }
-
- override fun read(b: ByteArray, off: Int, len: Int): Int {
- if (len == 0) return 0
- return subStream.read(b, off, min(max(limit - count, 1L), len.toLong()).toInt()).also {
- if (it != -1) count += it
- if (count > limit) throw HttpRequestEntityTooLarge("Stream size exceeds limit $limit")
- }
- }
-
- override fun close() = subStream.close()
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/io/LzfseEncoder.kt b/src/main/kotlin/org/radarbase/gateway/io/LzfseEncoder.kt
deleted file mode 100644
index e28621f..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/LzfseEncoder.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.radarbase.gateway.io
-
-import org.glassfish.jersey.spi.ContentEncoder
-import org.radarbase.io.lzfse.LZFSEInputStream
-import org.radarbase.jersey.exception.HttpApplicationException
-import java.io.InputStream
-import java.io.OutputStream
-import javax.ws.rs.core.Response
-
-class LzfseEncoder : ContentEncoder("lzfse", "x-lzfse") {
- override fun encode(
- contentEncoding: String,
- entityStream: OutputStream
- ): OutputStream = throw HttpApplicationException(Response.Status.NOT_ACCEPTABLE, "LZFSE encoding not implemented")
-
- override fun decode(
- contentEncoding: String,
- encodedStream: InputStream
- ): InputStream = LZFSEInputStream(encodedStream)
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/io/SizeLimitInterceptor.kt b/src/main/kotlin/org/radarbase/gateway/io/SizeLimitInterceptor.kt
deleted file mode 100644
index 726d448..0000000
--- a/src/main/kotlin/org/radarbase/gateway/io/SizeLimitInterceptor.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package org.radarbase.gateway.io
-
-import org.radarbase.gateway.Config
-import org.radarbase.gateway.inject.ProcessAvro
-import javax.annotation.Priority
-import javax.inject.Singleton
-import javax.ws.rs.Priorities
-import javax.ws.rs.core.Context
-import javax.ws.rs.ext.Provider
-import javax.ws.rs.ext.ReaderInterceptor
-import javax.ws.rs.ext.ReaderInterceptorContext
-
-/**
- * Limits data stream to a maximum request size.
- */
-@Provider
-@ProcessAvro
-@Singleton
-@Priority(Priorities.ENTITY_CODER + 100)
-class SizeLimitInterceptor(@Context private val config: Config) : ReaderInterceptor {
- override fun aroundReadFrom(context: ReaderInterceptorContext): Any {
- context.inputStream = LimitedInputStream(context.inputStream, config.server.maxRequestSize)
- return context.proceed()
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/kafka/KafkaAvroProducer.kt b/src/main/kotlin/org/radarbase/gateway/kafka/KafkaAvroProducer.kt
index a00b27d..388d439 100644
--- a/src/main/kotlin/org/radarbase/gateway/kafka/KafkaAvroProducer.kt
+++ b/src/main/kotlin/org/radarbase/gateway/kafka/KafkaAvroProducer.kt
@@ -2,7 +2,7 @@ package org.radarbase.gateway.kafka
import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient
import io.confluent.kafka.serializers.KafkaAvroSerializer
-import org.apache.avro.generic.GenericRecord
+import org.apache.avro.generic.IndexedRecord
import org.apache.kafka.clients.producer.KafkaProducer
import org.apache.kafka.clients.producer.Producer
import org.apache.kafka.clients.producer.ProducerRecord
@@ -13,9 +13,9 @@ import java.io.Closeable
import java.util.concurrent.ExecutionException
class KafkaAvroProducer(
- config: Config,
- schemaRegistryClient: SchemaRegistryClient
-): Closeable {
+ config: Config,
+ schemaRegistryClient: SchemaRegistryClient
+) : Closeable {
private val producer: Producer
init {
@@ -32,16 +32,16 @@ class KafkaAvroProducer(
}
@Throws(KafkaException::class)
- fun produce(topic: String, records: List>) {
+ fun produce(topic: String, records: List>) {
records
- .map { (key, value) -> producer.send(ProducerRecord(topic, key, value)) }
- .forEach {
- try {
- it.get() // asserts that the send completed and was successful
- } catch (ex: ExecutionException) {
- throw ex.cause!!
- }
+ .map { (key, value) -> producer.send(ProducerRecord(topic, key, value)) }
+ .forEach {
+ try {
+ it.get() // asserts that the send completed and was successful
+ } catch (ex: ExecutionException) {
+ throw ex.cause!!
}
+ }
}
override fun close() {
diff --git a/src/main/kotlin/org/radarbase/gateway/kafka/ProducerPool.kt b/src/main/kotlin/org/radarbase/gateway/kafka/ProducerPool.kt
index 501cd99..c8d8c01 100644
--- a/src/main/kotlin/org/radarbase/gateway/kafka/ProducerPool.kt
+++ b/src/main/kotlin/org/radarbase/gateway/kafka/ProducerPool.kt
@@ -6,6 +6,7 @@ import io.confluent.kafka.schemaregistry.client.rest.RestService
import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig
import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.MAX_SCHEMAS_PER_SUBJECT_CONFIG
import org.apache.avro.generic.GenericRecord
+import org.apache.avro.generic.IndexedRecord
import org.apache.kafka.common.KafkaException
import org.apache.kafka.common.errors.*
import org.radarbase.gateway.Config
@@ -39,7 +40,7 @@ class ProducerPool(
schemaRegistryClient = CachedSchemaRegistryClient(restService, config.kafka.serialization[MAX_SCHEMAS_PER_SUBJECT_CONFIG] as Int, null, config.kafka.serialization, null)
}
- fun produce(topic: String, records: List>) {
+ fun produce(topic: String, records: List>) {
if (!semaphore.tryAcquire()) throw HttpApplicationException(Response.Status.SERVICE_UNAVAILABLE, "Too many open Kafka requests")
try {
val producer = pool.poll() ?: KafkaAvroProducer(config, schemaRegistryClient)
diff --git a/src/main/kotlin/org/radarbase/gateway/main.kt b/src/main/kotlin/org/radarbase/gateway/main.kt
index c3dcc5e..6baf837 100644
--- a/src/main/kotlin/org/radarbase/gateway/main.kt
+++ b/src/main/kotlin/org/radarbase/gateway/main.kt
@@ -1,17 +1,27 @@
package org.radarbase.gateway
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
+import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.radarbase.jersey.GrizzlyServer
import org.radarbase.jersey.config.ConfigLoader
import org.slf4j.LoggerFactory
-import java.lang.IllegalStateException
import kotlin.system.exitProcess
fun main(args: Array) {
val config = try {
- ConfigLoader.loadConfig(listOf(
+ ConfigLoader.loadConfig(
+ listOf(
"gateway.yml",
- "/etc/radar-gateway/gateway.yml"), args)
- .withDefaults()
+ "/etc/radar-gateway/gateway.yml"
+ ),
+ args,
+ ObjectMapper(YAMLFactory())
+ .registerModule(KotlinModule())
+ .registerModule(JavaTimeModule())
+ )
+ .withDefaults()
} catch (ex: IllegalArgumentException) {
logger.error("No configuration file was found.")
logger.error("Usage: radar-gateway ")
diff --git a/src/main/kotlin/org/radarbase/gateway/resource/KafkaRoot.kt b/src/main/kotlin/org/radarbase/gateway/resource/KafkaRoot.kt
deleted file mode 100644
index 05e1821..0000000
--- a/src/main/kotlin/org/radarbase/gateway/resource/KafkaRoot.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package org.radarbase.gateway.resource
-
-import org.radarbase.gateway.resource.KafkaTopics.Companion.PRODUCE_AVRO_V1_JSON
-import org.radarbase.gateway.resource.KafkaTopics.Companion.PRODUCE_JSON
-import javax.inject.Singleton
-import javax.ws.rs.GET
-import javax.ws.rs.Path
-import javax.ws.rs.Produces
-
-/** Root path, just forward requests without authentication. */
-@Path("/")
-@Singleton
-class KafkaRoot {
- @GET
- @Produces(PRODUCE_AVRO_V1_JSON, PRODUCE_JSON)
- fun root() = mapOf()
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/resource/KafkaTopics.kt b/src/main/kotlin/org/radarbase/gateway/resource/KafkaTopics.kt
deleted file mode 100644
index 7f4e92d..0000000
--- a/src/main/kotlin/org/radarbase/gateway/resource/KafkaTopics.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-package org.radarbase.gateway.resource
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.fasterxml.jackson.databind.JsonNode
-import org.radarbase.gateway.inject.ProcessAvro
-import org.radarbase.gateway.io.AvroProcessor
-import org.radarbase.gateway.io.BinaryToAvroConverter
-import org.radarbase.gateway.kafka.KafkaAdminService
-import org.radarbase.gateway.kafka.ProducerPool
-import org.radarbase.jersey.auth.Authenticated
-import org.radarbase.jersey.auth.NeedsPermission
-import org.radarbase.jersey.exception.HttpBadRequestException
-import org.radarcns.auth.authorization.Permission.Entity.MEASUREMENT
-import org.radarcns.auth.authorization.Permission.Operation.CREATE
-import org.slf4j.LoggerFactory
-import java.io.IOException
-import java.io.InputStream
-import javax.inject.Singleton
-import javax.ws.rs.*
-import javax.ws.rs.core.Context
-import javax.ws.rs.core.Response
-
-/** Topics submission and listing. Requests need authentication. */
-@Path("/topics")
-@Singleton
-@Authenticated
-class KafkaTopics(
- @Context private val kafkaAdminService: KafkaAdminService,
- @Context private val producerPool: ProducerPool
-) {
- @GET
- @Produces(PRODUCE_AVRO_V1_JSON)
- fun topics() = kafkaAdminService.listTopics()
-
- @Path("/{topic_name}")
- @GET
- fun topic(
- @PathParam("topic_name") topic: String
- ) = kafkaAdminService.topicInfo(topic)
-
- @OPTIONS
- @Path("/{topic_name}")
- fun topicOptions(): Response = Response.noContent()
- .header("Accept", "$ACCEPT_BINARY_V1,$ACCEPT_AVRO_V2_JSON,$ACCEPT_AVRO_V1_JSON")
- .header("Accept-Encoding", "gzip,lzfse")
- .header("Accept-Charset", "utf-8")
- .header("Allow", "HEAD,GET,POST,OPTIONS")
- .build()
-
- @Path("/{topic_name}")
- @POST
- @Consumes(ACCEPT_AVRO_V1_JSON, ACCEPT_AVRO_V2_JSON)
- @Produces(PRODUCE_AVRO_V1_JSON, PRODUCE_JSON)
- @NeedsPermission(MEASUREMENT, CREATE)
- @ProcessAvro
- fun postToTopic(
- tree: JsonNode,
- @PathParam("topic_name") topic: String,
- @Context avroProcessor: AvroProcessor): TopicPostResponse {
-
- val processingResult = avroProcessor.process(topic, tree)
- producerPool.produce(topic, processingResult.records)
- return TopicPostResponse(processingResult.keySchemaId, processingResult.valueSchemaId)
- }
-
- @Path("/{topic_name}")
- @POST
- @ProcessAvro
- @Consumes(ACCEPT_BINARY_V1)
- @Produces(PRODUCE_AVRO_V1_JSON, PRODUCE_JSON)
- @NeedsPermission(MEASUREMENT, CREATE)
- fun postToTopicBinary(
- input: InputStream,
- @Context binaryToAvroConverter: BinaryToAvroConverter,
- @PathParam("topic_name") topic: String): TopicPostResponse {
-
- val processingResult = try {
- binaryToAvroConverter.process(topic, input)
- } catch (ex: IOException) {
- logger.error("Invalid RecordSet content: {}", ex.toString())
- throw HttpBadRequestException("bad_content", "Content is not a valid binary RecordSet")
- }
-
- producerPool.produce(topic, processingResult.records)
-
- return TopicPostResponse(processingResult.keySchemaId, processingResult.valueSchemaId)
- }
-
- data class TopicPostResponse(
- @JsonProperty("key_schema_id")
- val keySchemaId: Int,
- @JsonProperty("value_schema_id")
- val valueSchemaId: Int)
-
- companion object {
- private val logger = LoggerFactory.getLogger(KafkaTopics::class.java)
- const val ACCEPT_AVRO_V1_JSON = "application/vnd.kafka.avro.v1+json"
- const val ACCEPT_AVRO_V2_JSON = "application/vnd.kafka.avro.v2+json"
- const val ACCEPT_BINARY_V1 = "application/vnd.radarbase.avro.v1+binary"
- const val PRODUCE_AVRO_V1_JSON = "application/vnd.kafka.v1+json"
- const val PRODUCE_JSON = "application/json"
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/service/SchedulingService.kt b/src/main/kotlin/org/radarbase/gateway/service/SchedulingService.kt
deleted file mode 100644
index 42de6e3..0000000
--- a/src/main/kotlin/org/radarbase/gateway/service/SchedulingService.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package org.radarbase.gateway.service
-
-import org.slf4j.LoggerFactory
-import java.io.Closeable
-import java.time.Duration
-import java.util.concurrent.CancellationException
-import java.util.concurrent.Executors
-import java.util.concurrent.ScheduledFuture
-import java.util.concurrent.TimeUnit
-
-class SchedulingService: Closeable {
- private val scheduler = Executors.newSingleThreadScheduledExecutor()
-
- fun execute(method: () -> Unit) = scheduler.execute(method)
-
- fun repeat(rate: Duration, initialDelay: Duration, method: () -> Unit): RepeatReference {
- val ref = scheduler.scheduleAtFixedRate(method, initialDelay.toMillis(), rate.toMillis(), TimeUnit.MILLISECONDS)
- return FutureRepeatReference(ref)
- }
-
- interface RepeatReference: Closeable
-
- private inner class FutureRepeatReference(private val ref: ScheduledFuture<*>): RepeatReference {
- override fun close() {
- if (ref.cancel(false)) {
- try {
- ref.get()
- } catch (ex: CancellationException) {
- // this is expected
- } catch (ex: Exception) {
- logger.warn("Failed to get repeating job result", ex)
- }
- }
- }
- }
-
- override fun close() {
- scheduler.shutdown() // Disable new tasks from being submitted
-
- try {
- // Wait a while for existing tasks to terminate
- if (!scheduler.awaitTermination(TERMINATION_INTERVAL_SECONDS, TimeUnit.SECONDS)) {
- scheduler.shutdownNow() // Cancel currently executing tasks
- // Wait a while for tasks to respond to being cancelled
- if (!scheduler.awaitTermination(TERMINATION_INTERVAL_SECONDS, TimeUnit.SECONDS)) {
- logger.error("SchedulingService did not terminate")
- }
- }
- } catch (ie: InterruptedException) {
- // (Re-)Cancel if current thread also interrupted
- scheduler.shutdownNow()
- // Preserve interrupt status
- Thread.currentThread().interrupt()
- }
- }
-
- companion object {
- private val logger = LoggerFactory.getLogger(SchedulingService::class.java)
-
- private const val TERMINATION_INTERVAL_SECONDS = 60L
- }
-}
diff --git a/src/main/kotlin/org/radarbase/gateway/service/SchedulingServiceFactory.kt b/src/main/kotlin/org/radarbase/gateway/service/SchedulingServiceFactory.kt
deleted file mode 100644
index e74bbe1..0000000
--- a/src/main/kotlin/org/radarbase/gateway/service/SchedulingServiceFactory.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package org.radarbase.gateway.service
-
-import org.glassfish.jersey.internal.inject.DisposableSupplier
-
-class SchedulingServiceFactory: DisposableSupplier {
- override fun get() = SchedulingService()
-
- override fun dispose(instance: SchedulingService?) {
- instance?.close()
- }
-}
diff --git a/src/main/kotlin/org/radarbase/push/integration/GarminPushIntegrationResourceEnhancer.kt b/src/main/kotlin/org/radarbase/push/integration/GarminPushIntegrationResourceEnhancer.kt
new file mode 100644
index 0000000..0a5cf0d
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/GarminPushIntegrationResourceEnhancer.kt
@@ -0,0 +1,66 @@
+package org.radarbase.push.integration
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.glassfish.hk2.api.TypeLiteral
+import org.glassfish.jersey.internal.inject.AbstractBinder
+import org.glassfish.jersey.process.internal.RequestScoped
+import org.glassfish.jersey.server.ResourceConfig
+import org.radarbase.gateway.Config
+import org.radarbase.jersey.auth.AuthValidator
+import org.radarbase.jersey.config.JerseyResourceEnhancer
+import org.radarbase.push.integration.common.auth.DelegatedAuthValidator.Companion.GARMIN_QUALIFIER
+import org.radarbase.push.integration.common.user.User
+import org.radarbase.push.integration.garmin.auth.GarminAuthValidator
+import org.radarbase.push.integration.garmin.factory.GarminAuthMetadataFactory
+import org.radarbase.push.integration.garmin.factory.GarminUserTreeMapFactory
+import org.radarbase.push.integration.garmin.service.BackfillService
+import org.radarbase.push.integration.garmin.service.GarminHealthApiService
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+import javax.inject.Singleton
+
+class GarminPushIntegrationResourceEnhancer(private val config: Config) :
+ JerseyResourceEnhancer {
+
+ override fun ResourceConfig.enhance() {
+ packages(
+ "org.radarbase.push.integration.garmin.resource",
+ "org.radarbase.push.integration.common.filter"
+ )
+ }
+
+ override val classes: Array>
+ get() = if (config.pushIntegration.garmin.backfill.enabled) {
+ arrayOf(BackfillService::class.java)
+ } else {
+ emptyArray()
+ }
+
+ override fun AbstractBinder.enhance() {
+
+ bind(config.pushIntegration.garmin.userRepository)
+ .to(GarminUserRepository::class.java)
+ .named(GARMIN_QUALIFIER)
+ .`in`(Singleton::class.java)
+
+ bind(GarminHealthApiService::class.java)
+ .to(GarminHealthApiService::class.java)
+ .`in`(Singleton::class.java)
+
+ bind(GarminAuthValidator::class.java)
+ .to(AuthValidator::class.java)
+ .named(GARMIN_QUALIFIER)
+ .`in`(Singleton::class.java)
+
+ bindFactory(GarminUserTreeMapFactory::class.java)
+ .to(object : TypeLiteral>() {}.type)
+ .proxy(true)
+ .named(GARMIN_QUALIFIER)
+ .`in`(RequestScoped::class.java)
+
+ bindFactory(GarminAuthMetadataFactory::class.java)
+ .to(object : TypeLiteral>() {}.type)
+ .proxy(true)
+ .named(GARMIN_QUALIFIER)
+ .`in`(RequestScoped::class.java)
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/auth/DelegatedAuthValidator.kt b/src/main/kotlin/org/radarbase/push/integration/common/auth/DelegatedAuthValidator.kt
new file mode 100644
index 0000000..f3283ec
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/auth/DelegatedAuthValidator.kt
@@ -0,0 +1,36 @@
+package org.radarbase.push.integration.common.auth
+
+import org.glassfish.hk2.api.IterableProvider
+import org.radarbase.jersey.auth.Auth
+import org.radarbase.jersey.auth.AuthValidator
+import javax.ws.rs.container.ContainerRequestContext
+import javax.ws.rs.core.Context
+import javax.ws.rs.core.UriInfo
+
+class DelegatedAuthValidator(
+ @Context private val uriInfo: UriInfo,
+ @Context private val namedValidators: IterableProvider
+) : AuthValidator {
+
+ private val basePath: String = "push"
+
+ fun delegate(): AuthValidator {
+ return when {
+ uriInfo.matches(GARMIN_QUALIFIER) -> namedValidators.named(GARMIN_QUALIFIER).get()
+ // Add support for more as integrations are added
+ else -> throw IllegalStateException()
+ }
+ }
+
+ private fun UriInfo.matches(name: String): Boolean =
+ this.absolutePath.path.contains("^/$basePath/integrations/$name/.*".toRegex())
+
+ companion object {
+ const val GARMIN_QUALIFIER = "garmin"
+ }
+
+ override fun verify(token: String, request: ContainerRequestContext): Auth? =
+ delegate().verify(token, request)
+
+ override fun getToken(request: ContainerRequestContext): String? = delegate().getToken(request)
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/auth/Oauth1Signing.kt b/src/main/kotlin/org/radarbase/push/integration/common/auth/Oauth1Signing.kt
new file mode 100644
index 0000000..855d70d
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/auth/Oauth1Signing.kt
@@ -0,0 +1,45 @@
+package org.radarbase.push.integration.common.auth
+
+import okhttp3.Request
+import java.io.IOException
+
+class Oauth1Signing(
+ val parameters: Map
+) {
+
+ @Throws(IOException::class)
+ fun signRequest(request: Request): Request {
+ //Create auth header
+ val authHeader = "OAuth ${parameters.toHeaderFormat()}"
+
+ return request.newBuilder().addHeader("Authorization", authHeader).build()
+ }
+
+ private fun Map.toHeaderFormat() =
+ filterKeys { it in baseKeys }
+ .entries
+ .sortedBy { (key, _) -> key }
+ .joinToString(", ") { (key, value) -> "$key=\"$value\"" }
+
+ companion object {
+ const val OAUTH_CONSUMER_KEY = "oauth_consumer_key"
+ const val OAUTH_NONCE = "oauth_nonce"
+ const val OAUTH_SIGNATURE = "oauth_signature"
+ const val OAUTH_SIGNATURE_METHOD = "oauth_signature_method"
+ const val OAUTH_SIGNATURE_METHOD_VALUE = "HMAC-SHA1"
+ const val OAUTH_TIMESTAMP = "oauth_timestamp"
+ const val OAUTH_TOKEN = "oauth_token"
+ const val OAUTH_VERSION = "oauth_version"
+ const val OAUTH_VERSION_VALUE = "1.0"
+
+ private val baseKeys = arrayListOf(
+ OAUTH_CONSUMER_KEY,
+ OAUTH_NONCE,
+ OAUTH_SIGNATURE,
+ OAUTH_SIGNATURE_METHOD,
+ OAUTH_TIMESTAMP,
+ OAUTH_TOKEN,
+ OAUTH_VERSION
+ )
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/auth/OauthKeys.kt b/src/main/kotlin/org/radarbase/push/integration/common/auth/OauthKeys.kt
new file mode 100644
index 0000000..1dd6363
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/auth/OauthKeys.kt
@@ -0,0 +1,6 @@
+package org.radarbase.push.integration.common.auth
+
+data class OauthKeys(
+ val consumerKey: String,
+ val accessToken: String? = null,
+)
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/auth/SignRequestParams.kt b/src/main/kotlin/org/radarbase/push/integration/common/auth/SignRequestParams.kt
new file mode 100644
index 0000000..f7200eb
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/auth/SignRequestParams.kt
@@ -0,0 +1,9 @@
+package org.radarbase.push.integration.common.auth
+
+import com.fasterxml.jackson.annotation.JsonProperty
+
+data class SignRequestParams(
+ @JsonProperty("url") var url: String,
+ @JsonProperty("method") var method: String,
+ @JsonProperty("parameters") val parameters: Map,
+)
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/converter/AvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/common/converter/AvroConverter.kt
new file mode 100644
index 0000000..6191326
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/converter/AvroConverter.kt
@@ -0,0 +1,17 @@
+package org.radarbase.push.integration.common.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import java.io.IOException
+
+interface AvroConverter {
+
+ val topic: String
+
+ @Throws(IOException::class)
+ fun convert(
+ tree: JsonNode,
+ user: User
+ ): List>
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/filter/CorsFilter.kt b/src/main/kotlin/org/radarbase/push/integration/common/filter/CorsFilter.kt
new file mode 100644
index 0000000..9a12a6f
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/filter/CorsFilter.kt
@@ -0,0 +1,22 @@
+package org.radarbase.push.integration.common.filter
+
+import java.io.IOException
+import javax.ws.rs.container.ContainerRequestContext
+import javax.ws.rs.container.ContainerResponseContext
+import javax.ws.rs.container.ContainerResponseFilter
+import javax.ws.rs.ext.Provider
+
+@Provider
+class CorsFilter : ContainerResponseFilter {
+ @Throws(IOException::class)
+ override fun filter(
+ requestContext: ContainerRequestContext, cres: ContainerResponseContext
+ ) {
+ cres.headers.add("Access-Control-Allow-Origin", "*")
+ cres.headers
+ .add("Access-Control-Allow-Headers", "origin, content-type, accept, authorization")
+ cres.headers.add("Access-Control-Allow-Credentials", "true")
+ cres.headers.add("Access-Control-Allow-Methods", "POST, OPTIONS")
+ cres.headers.add("Access-Control-Max-Age", "1209600")
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/inject/PushIntegrationResourceEnhancer.kt b/src/main/kotlin/org/radarbase/push/integration/common/inject/PushIntegrationResourceEnhancer.kt
new file mode 100644
index 0000000..0969cce
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/inject/PushIntegrationResourceEnhancer.kt
@@ -0,0 +1,14 @@
+package org.radarbase.push.integration.common.inject
+
+import org.glassfish.jersey.internal.inject.AbstractBinder
+import org.radarbase.jersey.auth.AuthValidator
+import org.radarbase.jersey.config.JerseyResourceEnhancer
+import org.radarbase.push.integration.common.auth.DelegatedAuthValidator
+
+class PushIntegrationResourceEnhancer : JerseyResourceEnhancer {
+
+ override fun AbstractBinder.enhance() {
+ bind(DelegatedAuthValidator::class.java)
+ .to(AuthValidator::class.java)
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/user/User.kt b/src/main/kotlin/org/radarbase/push/integration/common/user/User.kt
new file mode 100644
index 0000000..caa9f83
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/user/User.kt
@@ -0,0 +1,21 @@
+package org.radarbase.push.integration.common.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/src/main/kotlin/org/radarbase/push/integration/common/user/UserRepository.kt b/src/main/kotlin/org/radarbase/push/integration/common/user/UserRepository.kt
new file mode 100644
index 0000000..9cf7185
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/user/UserRepository.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2018 The Hyve
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package org.radarbase.push.integration.common.user
+
+import java.io.IOException
+import java.util.stream.Stream
+import javax.ws.rs.NotAuthorizedException
+
+/**
+ * User repository for Garmin users.
+ */
+interface UserRepository {
+ /**
+ * Get specified Garmin user.
+ *
+ * @throws IOException if the user cannot be retrieved from the repository.
+ */
+ @Throws(IOException::class)
+ operator fun get(key: String): User?
+
+ /**
+ * Get all relevant Garmin users.
+ *
+ * @throws IOException if the list cannot be retrieved from the repository.
+ */
+ @Throws(IOException::class)
+ fun stream(): Stream
+
+ /**
+ * Get the current access token of given user.
+ *
+ * @throws IOException if the new access token cannot be retrieved from the repository.
+ * @throws NotAuthorizedException if the refresh token is no longer valid. Manual action
+ * should be taken to get a new refresh token.
+ * @throws NoSuchElementException if the user does not exists in this repository.
+ */
+ @Throws(IOException::class, NotAuthorizedException::class)
+ fun getAccessToken(user: User): String
+
+ /**
+ * Get the current refresh token of given user.
+ *
+ * @throws IOException if the new access token secret cannot be retrieved from the repository.
+ * @throws NotAuthorizedException if the token is no longer valid. Manual action
+ * should be taken to get a new token.
+ * @throws NoSuchElementException if the user does not exists in this repository.
+ */
+ @Throws(IOException::class, NotAuthorizedException::class)
+ fun getRefreshToken(user: User): String
+
+ /**
+ * Finds [User] using [User.externalUserId]
+ *
+ * @throws IOException if there was an error when finding the user.
+ * @throws NoSuchElementException if the user does not exists in this repository.
+ */
+ @Throws(NoSuchElementException::class, IOException::class)
+ fun findByExternalId(externalId: String): User {
+ return stream()
+ .filter { user -> user.serviceUserId == externalId }
+ .findFirst()
+ .orElseGet { throw NoSuchElementException("User not found in the User repository") }
+ }
+
+ /**
+ * The functions allows the repository to supply when there are pending updates.
+ * This gives more control to the user repository in updating and caching users.
+ * @return `true` if there are new updates available, `false` otherwise.
+ */
+ fun hasPendingUpdates(): Boolean
+
+ /**
+ * Apply any pending updates to users. This could include, for instance, refreshing a cache
+ * of users with latest information.
+ * This is called when [.hasPendingUpdates] is `true`.
+ * @throws IOException if there was an error when applying updates.
+ */
+ @Throws(IOException::class)
+ fun applyPendingUpdates()
+
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/common/user/Users.kt b/src/main/kotlin/org/radarbase/push/integration/common/user/Users.kt
new file mode 100644
index 0000000..465d74c
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/common/user/Users.kt
@@ -0,0 +1,9 @@
+package org.radarbase.push.integration.common.user
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import org.radarbase.push.integration.garmin.user.GarminUser
+
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class Users(@JsonProperty("users") val users: List)
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/auth/GarminAuthValidator.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/auth/GarminAuthValidator.kt
new file mode 100644
index 0000000..39051f1
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/auth/GarminAuthValidator.kt
@@ -0,0 +1,111 @@
+package org.radarbase.push.integration.garmin.auth
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.radarbase.jersey.auth.Auth
+import org.radarbase.jersey.auth.AuthValidator
+import org.radarbase.jersey.auth.disabled.DisabledAuth
+import org.radarbase.jersey.exception.HttpUnauthorizedException
+import org.radarbase.push.integration.common.auth.DelegatedAuthValidator.Companion.GARMIN_QUALIFIER
+import org.radarbase.push.integration.common.user.User
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+import org.slf4j.LoggerFactory
+import javax.inject.Named
+import javax.ws.rs.container.ContainerRequestContext
+import javax.ws.rs.core.Context
+
+
+class GarminAuthValidator(
+ @Context private val objectMapper: ObjectMapper,
+ @Named(GARMIN_QUALIFIER) private val userRepository: GarminUserRepository
+) :
+ AuthValidator {
+
+ override fun verify(token: String, request: ContainerRequestContext): Auth {
+ return if (token.isBlank()) {
+ throw HttpUnauthorizedException("invalid_token", "The token was empty")
+ } else {
+ var isAnyUnauthorised = false
+ // Enrich the request by adding the User
+ // the data format in Garmin's post is { : [ {}, {} ] }
+ val tree = request.getProperty("tree") as JsonNode
+
+ val userTreeMap: Map =
+ // group by user ID since request can contain data from multiple users
+ tree[tree.fieldNames().next()].groupBy { node ->
+ node[USER_ID_KEY].asText()
+ }.filter { (userId, userData) ->
+ val accessToken = userData[0][USER_ACCESS_TOKEN_KEY].asText()
+ if (checkIsAuthorised(userId, accessToken)) true else {
+ isAnyUnauthorised = true
+ userRepository.deregisterUser(userId, accessToken)
+ false
+ }
+ }.entries.associate { (userId, userData) ->
+ userRepository.findByExternalId(userId) to
+ // Map the List back to : [ {}, {} ]
+ // so it can be processed in the services without much refactoring
+ objectMapper.createObjectNode()
+ .set(tree.fieldNames().next(), objectMapper.valueToTree(userData))
+ }
+
+ request.setProperty("user_tree_map", userTreeMap)
+ request.setProperty(
+ "auth_metadata",
+ mapOf("isAnyUnauthorised" to isAnyUnauthorised.toString())
+ )
+ request.removeProperty("tree")
+
+ // Disable auth since we don't have proper auth support
+ DisabledAuth("res_gateway")
+ }
+ }
+
+ override fun getToken(request: ContainerRequestContext): String? {
+ return if (request.hasEntity()) {
+ // We put the json tree in the request because the entity stream will be closed here
+ val tree = objectMapper.readTree(request.entityStream)
+ request.setProperty("tree", tree)
+ val userAccessToken = tree[tree.fieldNames().next()][0][USER_ACCESS_TOKEN_KEY]
+ ?: throw HttpUnauthorizedException("invalid_token", "No user access token provided")
+ userAccessToken.asText().also {
+ request.setProperty(USER_ACCESS_TOKEN_KEY, it)
+ }
+ } else {
+ null
+ }
+ }
+
+ private fun checkIsAuthorised(userId: String, accessToken: String): Boolean {
+ val user = try {
+ userRepository.findByExternalId(userId)
+ } catch (exc: NoSuchElementException) {
+ logger.warn(
+ "no_user: The user {} could not be found in the " +
+ "user repository.", userId
+ )
+ return false
+ }
+ if (!user.isAuthorized) {
+ logger.warn(
+ "invalid_user: The user {} does not seem to be authorized.", userId
+ )
+ return false
+ }
+ if (userRepository.getAccessToken(user) != accessToken) {
+ logger.warn(
+ "invalid_token: The token for user {} does not" +
+ " match with the records on the system.", userId
+ )
+ return false
+ }
+ return true
+ }
+
+ companion object {
+ const val USER_ID_KEY = "userId"
+ const val USER_ACCESS_TOKEN_KEY = "userAccessToken"
+
+ private val logger = LoggerFactory.getLogger(GarminAuthValidator::class.java)
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/GarminRequestGenerator.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/GarminRequestGenerator.kt
new file mode 100644
index 0000000..0c48630
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/GarminRequestGenerator.kt
@@ -0,0 +1,115 @@
+package org.radarbase.push.integration.garmin.backfill
+
+import okhttp3.Response
+import org.radarbase.gateway.Config
+import org.radarbase.push.integration.common.user.User
+import org.radarbase.push.integration.garmin.backfill.route.*
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+import org.radarbase.push.integration.garmin.util.RedisHolder
+import org.radarbase.push.integration.garmin.util.offset.*
+import org.slf4j.LoggerFactory
+import redis.clients.jedis.JedisPool
+import java.nio.file.Path
+import java.time.Duration
+import java.time.Instant
+
+class GarminRequestGenerator(
+ val config: Config,
+ private val userRepository: GarminUserRepository,
+ private val redisHolder: RedisHolder =
+ RedisHolder(JedisPool(config.pushIntegration.garmin.backfill.redis.uri)),
+ private val offsetPersistenceFactory: OffsetPersistenceFactory =
+ OffsetRedisPersistence(redisHolder),
+ private val defaultQueryRange: Duration = Duration.ofDays(15),
+) :
+ RequestGenerator {
+
+ private val routes: List = listOf(
+ GarminActivitiesRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminDailiesRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminActivityDetailsRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminBodyCompsRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminEpochsRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminMoveIQRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminPulseOxRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminRespirationRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminSleepsRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminStressDetailsRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ ),
+ GarminUserMetricsRoute(
+ config.pushIntegration.garmin.consumerKey,
+ userRepository
+ )
+ )
+
+ override fun requests(user: User, max: Int): List {
+ return routes.map { route ->
+ val offsets: Offsets? = offsetPersistenceFactory.read(user.versionedId)
+ val startDate = userRepository.getBackfillStartDate(user)
+ val startOffset: Instant = if (offsets == null) {
+ logger.debug("No offsets found for $user, using the start date.")
+ startDate
+ } else {
+ logger.debug("Offsets found in persistence.")
+ offsets.offsetsMap.getOrDefault(
+ UserRoute(user.versionedId, route.toString()), startDate
+ ).takeIf { it >= startDate } ?: startDate
+ }
+ val endDate = userRepository.getBackfillEndDate(user)
+ val endTime = when {
+ endDate <= startOffset -> return@map emptyList() // Already at end. No further requests
+ endDate < startOffset.plus(defaultQueryRange) -> endDate
+ else -> startOffset.plus(defaultQueryRange)
+ }
+ route.generateRequests(user, startOffset, endTime, max / routes.size)
+ }.flatten()
+ }
+
+ override fun requestSuccessful(request: RestRequest, response: Response) {
+ logger.debug("Request successful: {}. Writing to offsets...", request.request)
+ offsetPersistenceFactory.add(
+ Path.of(request.user.versionedId), UserRouteOffset(
+ request.user.versionedId,
+ request.route.toString(),
+ request.endDate
+ )
+ )
+ }
+
+ override fun requestFailed(request: RestRequest, response: Response) {
+ logger.warn("Request Failed: {}, {}", request, response)
+ }
+
+ companion object {
+ private val logger = LoggerFactory.getLogger(GarminRequestGenerator::class.java)
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/RequestGenerator.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/RequestGenerator.kt
new file mode 100644
index 0000000..faf035d
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/RequestGenerator.kt
@@ -0,0 +1,14 @@
+package org.radarbase.push.integration.garmin.backfill
+
+import okhttp3.Request
+import okhttp3.Response
+import org.radarbase.push.integration.common.user.User
+
+interface RequestGenerator {
+
+ fun requests(user: User, max: Int): List
+
+ fun requestSuccessful(request: RestRequest, response: Response)
+
+ fun requestFailed(request: RestRequest, response: Response)
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/RestRequest.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/RestRequest.kt
new file mode 100644
index 0000000..4c1e9ab
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/RestRequest.kt
@@ -0,0 +1,11 @@
+package org.radarbase.push.integration.garmin.backfill
+
+import okhttp3.Request
+import org.radarbase.push.integration.common.user.User
+import java.time.Instant
+
+data class RestRequest(val request: Request,
+ val user: User,
+ val route: Route,
+ val startDate: Instant,
+ val endDate: Instant)
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/Route.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/Route.kt
new file mode 100644
index 0000000..7f84b81
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/Route.kt
@@ -0,0 +1,19 @@
+package org.radarbase.push.integration.garmin.backfill
+
+import org.radarbase.push.integration.common.user.User
+import java.time.Instant
+
+interface Route {
+
+ /**
+ * The number of days to request in a single request of this route.
+ */
+ val maxDaysPerRequest: Int
+
+ fun generateRequests(user: User, start: Instant, end: Instant, max: Int): List
+
+ /**
+ * This is how it would appear in the offsets
+ */
+ override fun toString(): String
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminActivitiesRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminActivitiesRoute.kt
new file mode 100644
index 0000000..fb9cae2
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminActivitiesRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminActivitiesRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "activities"
+
+ override fun toString(): String = "garmin_activities"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminActivityDetailsRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminActivityDetailsRoute.kt
new file mode 100644
index 0000000..5f5e8db
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminActivityDetailsRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminActivityDetailsRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "activityDetails"
+
+ override fun toString(): String = "garmin_activity_details"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminBodyCompsRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminBodyCompsRoute.kt
new file mode 100644
index 0000000..0c75110
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminBodyCompsRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminBodyCompsRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "bodyComps"
+
+ override fun toString(): String = "garmin_body_comps"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminDailiesRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminDailiesRoute.kt
new file mode 100644
index 0000000..2c3fcaa
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminDailiesRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminDailiesRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "dailies"
+
+ override fun toString(): String = "garmin_dailies"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminEpochsRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminEpochsRoute.kt
new file mode 100644
index 0000000..8010a2e
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminEpochsRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminEpochsRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "epochs"
+
+ override fun toString(): String = "garmin_epochs"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminMoveIQRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminMoveIQRoute.kt
new file mode 100644
index 0000000..2dd89cb
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminMoveIQRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminMoveIQRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "moveiq"
+
+ override fun toString(): String = "garmin_move_iq"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminPulseOxRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminPulseOxRoute.kt
new file mode 100644
index 0000000..c839632
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminPulseOxRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminPulseOxRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "pulseOx"
+
+ override fun toString(): String = "garmin_pulse_ox"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminRespirationRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminRespirationRoute.kt
new file mode 100644
index 0000000..09f053f
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminRespirationRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminRespirationRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "respiration"
+
+ override fun toString(): String = "garmin_respiration"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminRoute.kt
new file mode 100644
index 0000000..ca9093e
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminRoute.kt
@@ -0,0 +1,82 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import okhttp3.HttpUrl
+import okhttp3.Request
+import org.radarbase.push.integration.common.auth.Oauth1Signing
+import org.radarbase.push.integration.common.auth.Oauth1Signing.Companion.OAUTH_CONSUMER_KEY
+import org.radarbase.push.integration.common.auth.Oauth1Signing.Companion.OAUTH_VERSION
+import org.radarbase.push.integration.common.auth.Oauth1Signing.Companion.OAUTH_VERSION_VALUE
+import org.radarbase.push.integration.common.auth.Oauth1Signing.Companion.OAUTH_NONCE
+import org.radarbase.push.integration.common.auth.Oauth1Signing.Companion.OAUTH_TIMESTAMP
+import org.radarbase.push.integration.common.auth.SignRequestParams
+import org.radarbase.push.integration.common.user.User
+import org.radarbase.push.integration.garmin.backfill.RestRequest
+import org.radarbase.push.integration.garmin.backfill.Route
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+import java.time.Duration
+import java.time.Instant
+
+abstract class GarminRoute(
+ private val consumerKey: String,
+ private val userRepository: GarminUserRepository
+) : Route {
+ override val maxDaysPerRequest: Int
+ get() = 5
+
+ fun createRequest(user: User, baseUrl: String, queryParams: String): Request {
+ val request = Request.Builder()
+ .url(baseUrl + queryParams)
+ .get()
+ .build()
+
+ val parameters = getParams(request.url)
+ val requestParams = SignRequestParams(baseUrl, ROUTE_METHOD, parameters)
+ val signedRequest = userRepository.getSignedRequest(user, requestParams)
+
+ return Oauth1Signing(signedRequest.parameters).signRequest(request)
+ }
+
+ fun getParams(url: HttpUrl): Map {
+ return (
+ mapOf(
+ OAUTH_CONSUMER_KEY to consumerKey,
+ OAUTH_NONCE to java.util.UUID.randomUUID().toString(),
+ OAUTH_TIMESTAMP to (System.currentTimeMillis() / 1000L).toString(),
+ OAUTH_VERSION to OAUTH_VERSION_VALUE,
+ )
+ + (0 until url.querySize).associate { Pair(url.queryParameterName(it), url.queryParameterValue(it) ?: "") }
+ )
+ }
+
+
+ override fun generateRequests(
+ user: User,
+ start: Instant,
+ end: Instant,
+ max: Int
+ ): List {
+ var startRange = Instant.from(start)
+ val requests = mutableListOf()
+
+ while (startRange < end && requests.size < max) {
+ val endRange = startRange.plus(Duration.ofDays(maxDaysPerRequest.toLong()))
+ val request = createRequest(
+ user, "$GARMIN_BACKFILL_BASE_URL/${subPath()}",
+ "?summaryStartTimeInSeconds=${startRange.epochSecond}" +
+ "&summaryEndTimeInSeconds=${endRange.epochSecond}"
+ )
+ requests.add(RestRequest(request, user, this, startRange, endRange))
+ startRange = endRange
+ }
+ return requests.toMutableList()
+ }
+
+ abstract fun subPath(): String
+
+ companion object {
+
+ const val GARMIN_BACKFILL_BASE_URL =
+ "https://healthapi.garmin.com/wellness-api/rest/backfill"
+ const val ROUTE_METHOD = "GET"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminSleepsRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminSleepsRoute.kt
new file mode 100644
index 0000000..8d6c6a1
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminSleepsRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminSleepsRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "sleeps"
+
+ override fun toString(): String = "garmin_sleeps"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminStressDetailsRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminStressDetailsRoute.kt
new file mode 100644
index 0000000..305f6ee
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminStressDetailsRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminStressDetailsRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "stressDetails"
+
+ override fun toString(): String = "garmin_stress_details"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminUserMetricsRoute.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminUserMetricsRoute.kt
new file mode 100644
index 0000000..b3a0f9f
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/backfill/route/GarminUserMetricsRoute.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.backfill.route
+
+import org.radarbase.push.integration.garmin.user.GarminUserRepository
+
+class GarminUserMetricsRoute(
+ consumerKey: String,
+ userRepository: GarminUserRepository
+) : GarminRoute(consumerKey, userRepository) {
+
+ override fun subPath(): String = "userMetrics"
+
+ override fun toString(): String = "garmin_user_metrics"
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivitiesGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivitiesGarminAvroConverter.kt
new file mode 100644
index 0000000..58d73a3
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivitiesGarminAvroConverter.kt
@@ -0,0 +1,62 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminActivitySummary
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class ActivitiesGarminAvroConverter(topic: String = "push_integration_garmin_activity") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val activities = tree[ROOT]
+ if (activities == null || !activities.isArray) {
+ throw BadRequestException("The activities data was invalid.")
+ }
+ }
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): GarminActivitySummary {
+ return GarminActivitySummary.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = node["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ startTimeOffset = node["startTimeOffsetInSeconds"]?.asInt()
+ activityType = node["activityType"]?.asText()
+ duration = node["durationInSeconds"]?.asInt()
+ averageBikeCadence = node["averageBikeCadenceInRoundsPerMinute"]?.floatValue()
+ averageHeartRate = node["averageHeartRateInBeatsPerMinute"]?.asInt()
+ averageRunCadence = node["averageRunCadenceInStepsPerMinute"]?.floatValue()
+ averageSpeed = node["averageSpeedInMetersPerSecond"]?.floatValue()
+ averageSwimCadence = node["averageSwimCadenceInStrokesPerMinute"]?.floatValue()
+ averagePace = node["averagePaceInMinutesPerKilometer"]?.floatValue()
+ activeKilocalories = node["activeKilocalories"]?.asInt()
+ deviceName = node["deviceName"]?.asText()
+ distance = node["distanceInMeters"]?.floatValue()
+ maxBikeCadence = node["maxBikeCadenceInRoundsPerMinute"]?.floatValue()
+ maxHeartRate = node["maxHeartRateInBeatsPerMinute"]?.asInt()
+ maxPace = node["maxPaceInMinutesPerKilometer"]?.floatValue()
+ maxRunCadence = node["maxRunCadenceInStepsPerMinute"]?.floatValue()
+ maxSpeed = node["maxSpeedInMetersPerSecond"]?.floatValue()
+ numberOfActiveLengths = node["numberOfActiveLengths"]?.asInt()
+ startingLatitude = node["startingLatitudeInDegree"]?.floatValue()
+ startingLongitude = node["startingLongitudeInDegree"]?.floatValue()
+ steps = node.get("steps")?.asInt()
+ totalElevationGain = node["totalElevationGainInMeters"]?.floatValue()
+ totalElevationLoss = node["totalElevationLossInMeters"]?.floatValue()
+ isParent = node["isParent"]?.asBoolean()
+ parentSummaryId = node["parentSummaryId"]?.asText()
+ manual = node["manual"]?.asBoolean()
+ }.build()
+ }
+
+ companion object {
+ const val ROOT = "activities"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivityDetailsGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivityDetailsGarminAvroConverter.kt
new file mode 100644
index 0000000..88528b4
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivityDetailsGarminAvroConverter.kt
@@ -0,0 +1,63 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminActivityDetails
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class ActivityDetailsGarminAvroConverter(topic: String = "push_integration_garmin_activity_detail") :
+ GarminAvroConverter(topic) {
+ override fun validate(tree: JsonNode) {
+ val activityDetails = tree[ROOT]
+ if (activityDetails == null || !activityDetails.isArray) {
+ throw BadRequestException("The Activity Details Data was invalid")
+ }
+ }
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): SpecificRecord {
+ val summary = node[SUB_NODE]
+ return GarminActivityDetails.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = summary["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ startTimeOffset = summary["startTimeOffsetInSeconds"]?.asInt()
+ activityType = summary["activityType"]?.asText()
+ duration = summary["durationInSeconds"]?.asInt()
+ averageBikeCadence = summary["averageBikeCadenceInRoundsPerMinute"]?.floatValue()
+ averageHeartRate = summary["averageHeartRateInBeatsPerMinute"]?.asInt()
+ averageRunCadence = summary["averageRunCadenceInStepsPerMinute"]?.floatValue()
+ averageSpeed = summary["averageSpeedInMetersPerSecond"]?.floatValue()
+ averageSwimCadence = summary["averageSwimCadenceInStrokesPerMinute"]?.floatValue()
+ averagePace = summary["averagePaceInMinutesPerKilometer"]?.floatValue()
+ activeKilocalories = summary["activeKilocalories"]?.asInt()
+ deviceName = summary["deviceName"]?.asText()
+ distance = summary["distanceInMeters"]?.floatValue()
+ maxBikeCadence = summary["maxBikeCadenceInRoundsPerMinute"]?.floatValue()
+ maxHeartRate = summary["maxHeartRateInBeatsPerMinute"]?.asInt()
+ maxPace = summary["maxPaceInMinutesPerKilometer"]?.floatValue()
+ maxRunCadence = summary["maxRunCadenceInStepsPerMinute"]?.floatValue()
+ maxSpeed = summary["maxSpeedInMetersPerSecond"]?.floatValue()
+ numberOfActiveLengths = summary["numberOfActiveLengths"]?.asInt()
+ startingLatitude = summary["startingLatitudeInDegree"]?.floatValue()
+ startingLongitude = summary["startingLongitudeInDegree"]?.floatValue()
+ steps = summary["steps"]?.asInt()
+ totalElevationGain = summary["totalElevationGainInMeters"]?.floatValue()
+ totalElevationLoss = summary["totalElevationLossInMeters"]?.floatValue()
+ isParent = summary["isParent"]?.asBoolean()
+ parentSummaryId = summary["parentSummaryId"]?.asText()
+ manual = summary["manual"]?.asBoolean()
+ }.build()
+ }
+
+ companion object {
+ const val ROOT = "activityDetails"
+ const val SUB_NODE = "summary"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivityDetailsSampleGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivityDetailsSampleGarminAvroConverter.kt
new file mode 100644
index 0000000..7a310f7
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/ActivityDetailsSampleGarminAvroConverter.kt
@@ -0,0 +1,63 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminActivityDetailsSample
+import java.time.Instant
+
+class ActivityDetailsSampleGarminAvroConverter(
+ topic: String = "push_integration_garmin_activity_detail_sample"
+) :
+ GarminAvroConverter(topic) {
+ override fun validate(tree: JsonNode) = Unit
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT]
+ .map { node ->
+ createSamples(
+ node[SUB_NODE], node["summaryId"].asText(), user.observationKey
+ )
+ }.flatten()
+ }
+
+ private fun createSamples(
+ samples: JsonNode?, summaryId: String, observationKey:
+ ObservationKey
+ ): List> {
+ if (samples == null) {
+ return emptyList()
+ }
+ return samples.map { sample ->
+ Pair(
+ observationKey,
+ GarminActivityDetailsSample.newBuilder().apply {
+ this.summaryId = summaryId
+ time = sample["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ airTemperature = sample["airTemperatureCelcius"]?.floatValue()
+ heartRate = sample["heartRate"]?.asInt()
+ speed = sample["speedMetersPerSecond"]?.floatValue()
+ stepsPerMinute = sample["stepsPerMinute"]?.floatValue()
+ totalDistance = sample["totalDistanceInMeters"]?.floatValue()
+ timerDuration = sample["timerDurationInSeconds"]?.asInt()
+ clockDuration = sample["clockDurationInSeconds"]?.asInt()
+ movingDuration = sample["movingDurationInSeconds"]?.asInt()
+ power = sample["powerInWatts"]?.floatValue()
+ bikeCadence = sample["bikeCadenceInRPM"]?.asInt()
+ swimCadence = sample["swimCadenceInStrokesPerMinute"]?.asInt()
+ latitude = sample["latitudeInDegree"]?.floatValue()
+ longitude = sample["longitudeInDegree"]?.floatValue()
+ elevation = sample["elevationInMeters"]?.floatValue()
+ }.build()
+ )
+ }
+
+ }
+
+ companion object {
+ const val ROOT = "activityDetails"
+ const val SUB_NODE = "samples"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/BodyCompGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/BodyCompGarminAvroConverter.kt
new file mode 100644
index 0000000..0d7583d
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/BodyCompGarminAvroConverter.kt
@@ -0,0 +1,44 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminBodyComposition
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class BodyCompGarminAvroConverter(topic: String = "push_integration_garmin_body_composition") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val bodyComps = tree[ROOT]
+ if (bodyComps == null || !bodyComps.isArray) {
+ throw BadRequestException("The Body Composition data was invalid.")
+ }
+ }
+
+ override fun convert(tree: JsonNode, user: User): List> {
+
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): GarminBodyComposition {
+ return GarminBodyComposition.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = node["measurementTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ measurementTimeOffset = node["measurementTimeOffsetInSeconds"]?.asInt()
+ muscleMass = node["muscleMassInGrams"]?.asInt()
+ boneMass = node["boneMassInGrams"]?.asInt()
+ bodyWater = node["bodyWaterInPercent"]?.floatValue()
+ bodyFat = node["bodyFatInPercent"]?.floatValue()
+ bodyMassIndex = node["bodyMassIndex"]?.floatValue()
+ weight = node["weightInGrams"]?.asInt()
+ }.build()
+ }
+
+ companion object {
+ const val ROOT = "bodyComps"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/DailiesGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/DailiesGarminAvroConverter.kt
new file mode 100644
index 0000000..d35c68c
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/DailiesGarminAvroConverter.kt
@@ -0,0 +1,70 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminDailySummary
+import java.io.IOException
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class DailiesGarminAvroConverter(topic: String = "push_integration_garmin_daily") :
+ GarminAvroConverter(topic) {
+
+ @Throws(IOException::class)
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): SpecificRecord {
+ return GarminDailySummary.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = node["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ date = node["calendarDate"]?.asText()
+ startTimeOffset = node["startTimeOffsetInSeconds"]?.asInt()
+ activityType = node["activityType"]?.asText()
+ duration = node["durationInSeconds"]?.asInt()
+ steps = node["steps"]?.asInt()
+ distance = node["distanceInMeters"]?.floatValue()
+ activeTime = node["activeTimeInSeconds"]?.asInt()
+ activeKilocalories = node["activeKilocalories"]?.asInt()
+ bmrKilocalories = node["bmrKilocalories"]?.asInt()
+ consumedCalories = node["consumedCalories"]?.asInt()
+ moderateIntensityDuration = node["moderateIntensityDurationInSeconds"]?.asInt()
+ vigorousIntensityDuration = node["vigorousIntensityDurationInSeconds"]?.asInt()
+ floorsClimbed = node["floorsClimbed"]?.asInt()
+ minHeartRate = node["minHeartRateInBeatsPerMinute"]?.asInt()
+ averageHeartRate = node["averageHeartRateInBeatsPerMinute"]?.asInt()
+ maxHeartRate = node["maxHeartRateInBeatsPerMinute"]?.asInt()
+ restingHeartRate = node["restingHeartRateInBeatsPerMinute"]?.asInt()
+ averageStressLevel = node["averageStressLevel"]?.asInt()
+ maxStressLevel = node["maxStressLevel"]?.asInt()
+ stressDuration = node["stressDurationInSeconds"]?.asInt()
+ restStressDuration = node["restStressDurationInSeconds"]?.asInt()
+ activityStressDuration = node["activityStressDurationInSeconds"]?.asInt()
+ lowStressDuration = node["lowStressDurationInSeconds"]?.asInt()
+ mediumStressDuration = node["mediumStressDurationInSeconds"]?.asInt()
+ highStressDuration = node["highStressDurationInSeconds"]?.asInt()
+ stressQualifier = node["stressQualifier"]?.asText()
+ stepsGoal = node["stepsGoal"]?.asInt()
+ netKilocaloriesGoal = node["netKilocaloriesGoal"]?.asInt()
+ intensityDurationGoal = node["intensityDurationGoalInSeconds"]?.asInt()
+ floorsClimbedGoal = node["floorsClimbedGoal"]?.asInt()
+ source = node["source"]?.asText()
+ }.build()
+ }
+
+ @Throws(BadRequestException::class)
+ override fun validate(tree: JsonNode) {
+ val dailies = tree[ROOT]
+ if (dailies == null || !dailies.isArray) {
+ throw BadRequestException("The Dailies Data was invalid")
+ }
+ }
+
+ companion object {
+ const val ROOT = "dailies"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/EpochsGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/EpochsGarminAvroConverter.kt
new file mode 100644
index 0000000..37b7cd0
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/EpochsGarminAvroConverter.kt
@@ -0,0 +1,51 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminEpochSummary
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class EpochsGarminAvroConverter(topic: String = "push_integration_garmin_epoch") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val epochs = tree[ROOT]
+ if (epochs == null || !epochs.isArray) {
+ throw BadRequestException("The epochs data was invalid.")
+ }
+ }
+
+ override fun convert(
+ tree: JsonNode,
+ user: User
+ ): List> {
+
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): GarminEpochSummary {
+ return GarminEpochSummary.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = node["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ startTimeOffset = node["startTimeOffsetInSeconds"]?.asInt()
+ activityType = node["activityType"]?.asText()
+ duration = node["durationInSeconds"]?.asInt()
+ steps = node["steps"]?.asInt()
+ distance = node["distanceInMeters"]?.floatValue()
+ activeTime = node["activeTimeInSeconds"]?.asInt()
+ activeKilocalories = node["activeKilocalories"]?.asInt()
+ metabolicEquivalentOfTask = node["met"]?.floatValue()
+ intensity = node["intensity"]?.asText()
+ meanMotionIntensity = node["meanMotionIntensity"]?.floatValue()
+ maxMotionIntensity = node["maxMotionIntensity"]?.floatValue()
+ }.build()
+ }
+
+ companion object {
+ const val ROOT = "epochs"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/GarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/GarminAvroConverter.kt
new file mode 100644
index 0000000..510b808
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/GarminAvroConverter.kt
@@ -0,0 +1,19 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.converter.AvroConverter
+import org.radarbase.push.integration.common.user.User
+import javax.ws.rs.BadRequestException
+
+abstract class GarminAvroConverter(override val topic: String) : AvroConverter {
+
+ @Throws(BadRequestException::class)
+ abstract fun validate(tree: JsonNode)
+
+ fun validateAndConvert(tree: JsonNode, user: User):
+ List> {
+ validate(tree)
+ return convert(tree, user)
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/HeartRateSampleGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/HeartRateSampleGarminAvroConverter.kt
new file mode 100644
index 0000000..854e490
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/HeartRateSampleGarminAvroConverter.kt
@@ -0,0 +1,52 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminHeartRateSample
+import java.time.Instant
+
+class HeartRateSampleGarminAvroConverter(
+ topic: String = "push_integration_garmin_heart_rate_sample"
+) :
+ GarminAvroConverter(topic) {
+ override fun validate(tree: JsonNode) = Unit
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT].map { node ->
+ getSamples(
+ node[SUB_NODE], node["summaryId"].asText(),
+ user.observationKey, node["startTimeInSeconds"].asDouble()
+ )
+ }.flatten()
+ }
+
+ private fun getSamples(
+ node: JsonNode?,
+ summaryId: String,
+ observationKey: ObservationKey,
+ startTime: Double
+ ): List> {
+ if (node == null) {
+ return emptyList()
+ }
+
+ return node.fields().asSequence().map { (key, value) ->
+ Pair(
+ observationKey,
+ GarminHeartRateSample.newBuilder().apply {
+ this.summaryId = summaryId
+ this.time = startTime + key.toDouble()
+ this.timeReceived = Instant.now().toEpochMilli() / 1000.0
+ this.heartRate = value?.floatValue()
+ }.build()
+ )
+ }.toList()
+ }
+
+ companion object {
+ const val ROOT = "dailies"
+ const val SUB_NODE = "timeOffsetHeartRateSamples"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/MoveIQGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/MoveIQGarminAvroConverter.kt
new file mode 100644
index 0000000..8c07acb
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/MoveIQGarminAvroConverter.kt
@@ -0,0 +1,42 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminMoveIQSummary
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class MoveIQGarminAvroConverter(topic: String = "push_integration_garmin_move_iq") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val moveIQs = tree[ROOT]
+ if (moveIQs == null || !moveIQs.isArray) {
+ throw BadRequestException("The Move IQ data was invalid.")
+ }
+ }
+
+ override fun convert(tree: JsonNode, user: User): List> {
+
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): GarminMoveIQSummary {
+ return GarminMoveIQSummary.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = node["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ offset = node["offsetInSeconds"]?.asInt()
+ activityType = node["activityType"]?.asText()
+ duration = node["durationInSeconds"]?.asInt()
+ date = node["calendarDate"]?.asText()
+ activitySubType = node["activitySubType"]?.asText()
+ }.build()
+ }
+
+ companion object {
+ const val ROOT = "moveIQActivities"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/PulseOxGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/PulseOxGarminAvroConverter.kt
new file mode 100644
index 0000000..e63f2da
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/PulseOxGarminAvroConverter.kt
@@ -0,0 +1,54 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminPulseOx
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class PulseOxGarminAvroConverter(topic: String = "push_integration_garmin_pulse_ox") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val pulseOx = tree[ROOT]
+ if (pulseOx == null || !pulseOx.isArray) {
+ throw BadRequestException("The Pulse Ox data was invalid.")
+ }
+ }
+
+ override fun convert(tree: JsonNode, user: User): List> {
+
+ return tree[ROOT]
+ .map { node ->
+ getRecords(node, user.observationKey)
+ }.flatten()
+ }
+
+ private fun getRecords(node: JsonNode, observationKey: ObservationKey):
+ List> {
+ val startTime = node["startTimeInSeconds"].asDouble()
+ return node[SUB_NODE].fields().asSequence().map { (key, value) ->
+ Pair(
+ observationKey,
+ GarminPulseOx.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = startTime + key.toDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ startTimeOffset = node["startTimeOffsetInSeconds"]?.asInt()
+ duration = node["durationInSeconds"]?.asInt()
+ date = node["calendarDate"]?.asText()
+ spo2Value = value?.floatValue()
+ onDemand = node["onDemand"]?.asBoolean()
+ }.build()
+ )
+ }.toList()
+
+ }
+
+ companion object {
+ const val ROOT = "pulseox"
+ const val SUB_NODE = "timeOffsetSpo2Values"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/RespirationGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/RespirationGarminAvroConverter.kt
new file mode 100644
index 0000000..d02b348
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/RespirationGarminAvroConverter.kt
@@ -0,0 +1,51 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminRespiration
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class RespirationGarminAvroConverter(topic: String = "push_integration_garmin_respiration") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val respiration = tree[ROOT]
+ if (respiration == null || !respiration.isArray) {
+ throw BadRequestException("The Respiration data was invalid.")
+ }
+ }
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT]
+ .map { node -> getRecord(node, user.observationKey) }
+ .flatten()
+ }
+
+ private fun getRecord(
+ node: JsonNode,
+ observationKey: ObservationKey
+ ): List> {
+ val startTime = node["startTimeInSeconds"].asDouble()
+ return node[SUB_NODE].fields().asSequence().map { (key, value) ->
+ Pair(
+ observationKey,
+ GarminRespiration.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = startTime + key.toDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ startTimeOffset = node["startTimeOffsetInSeconds"]?.asInt()
+ respiration = value?.floatValue()
+ duration = node["durationInSeconds"]?.asInt()
+ }.build()
+ )
+ }.toList()
+ }
+
+ companion object {
+ const val ROOT = "allDayRespiration"
+ const val SUB_NODE = "timeOffsetEpochToBreaths"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepLevelGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepLevelGarminAvroConverter.kt
new file mode 100644
index 0000000..bff02e4
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepLevelGarminAvroConverter.kt
@@ -0,0 +1,52 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminSleepLevel
+import java.time.Instant
+
+class SleepLevelGarminAvroConverter(
+ topic: String = "push_integration_garmin_sleep_level"
+) :
+ GarminAvroConverter(topic) {
+ override fun validate(tree: JsonNode) = Unit
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT].map { node ->
+ getSamples(node[SUB_NODE], node["summaryId"].asText(), user.observationKey)
+ }.flatten()
+ }
+
+ private fun getSamples(
+ node: JsonNode?,
+ summaryId: String,
+ observationKey: ObservationKey
+ ): List> {
+ if (node == null) {
+ return emptyList()
+ }
+
+ return node.fields().asSequence().map { (key, value) ->
+ value.map {
+ Pair(
+ observationKey,
+ GarminSleepLevel.newBuilder().apply {
+ time = it["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ this.summaryId = summaryId
+ sleepLevel = key
+ startTime = it["startTimeInSeconds"].asDouble()
+ endTime = it["endTimeInSeconds"].asDouble()
+ }.build()
+ )
+ }
+ }.flatten().toList()
+ }
+
+ companion object {
+ const val ROOT = "sleeps"
+ const val SUB_NODE = "sleepLevelsMap"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepPulseOxGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepPulseOxGarminAvroConverter.kt
new file mode 100644
index 0000000..741546f
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepPulseOxGarminAvroConverter.kt
@@ -0,0 +1,56 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminPulseOx
+import java.time.Instant
+
+class SleepPulseOxGarminAvroConverter(
+ topic: String = "push_integration_garmin_pulse_ox"
+) :
+ GarminAvroConverter(topic) {
+ override fun validate(tree: JsonNode) = Unit
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT].map { node ->
+ getSamples(
+ node[SUB_NODE], node["summaryId"].asText(),
+ user.observationKey, node["startTimeInSeconds"].asDouble(),
+ node["calendarDate"]?.asText(), node["startTimeOffsetInSeconds"]?.intValue()
+ )
+ }.flatten()
+ }
+
+ private fun getSamples(
+ node: JsonNode?,
+ summaryId: String,
+ observationKey: ObservationKey,
+ startTime: Double,
+ calendarDate: String?,
+ offset: Int?
+ ): List> {
+ if (node == null) {
+ return emptyList()
+ }
+ return node.fields().asSequence().map { (key, value) ->
+ Pair(
+ observationKey,
+ GarminPulseOx.newBuilder().apply {
+ this.summaryId = summaryId
+ this.time = startTime + key.toDouble()
+ this.timeReceived = Instant.now().toEpochMilli() / 1000.0
+ this.spo2Value = value?.floatValue()
+ this.date = calendarDate
+ this.startTimeOffset = offset
+ }.build()
+ )
+ }.toList()
+ }
+
+ companion object {
+ const val ROOT = "sleeps"
+ const val SUB_NODE = "timeOffsetSleepSpo2"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepRespirationGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepRespirationGarminAvroConverter.kt
new file mode 100644
index 0000000..1476d4e
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepRespirationGarminAvroConverter.kt
@@ -0,0 +1,54 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminRespiration
+import java.time.Instant
+
+class SleepRespirationGarminAvroConverter(
+ topic: String = "push_integration_garmin_respiration"
+) :
+ GarminAvroConverter(topic) {
+ override fun validate(tree: JsonNode) = Unit
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT].map { node ->
+ getSamples(
+ node[SUB_NODE], node["summaryId"].asText(),
+ user.observationKey, node["startTimeInSeconds"].asDouble(),
+ node["startTimeOffsetInSeconds"]?.intValue()
+ )
+ }.flatten()
+ }
+
+ private fun getSamples(
+ node: JsonNode?,
+ summaryId: String,
+ observationKey: ObservationKey,
+ startTime: Double,
+ offset: Int?
+ ): List> {
+ if (node == null) {
+ return emptyList()
+ }
+ return node.fields().asSequence().map { (key, value) ->
+ Pair(
+ observationKey,
+ GarminRespiration.newBuilder().apply {
+ this.summaryId = summaryId
+ this.time = startTime + key.toDouble()
+ this.timeReceived = Instant.now().toEpochMilli() / 1000.0
+ this.respiration = value?.floatValue()
+ this.startTimeOffset = offset
+ }.build()
+ )
+ }.toList()
+ }
+
+ companion object {
+ const val ROOT = "sleeps"
+ const val SUB_NODE = "timeOffsetSleepRespiration"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepsGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepsGarminAvroConverter.kt
new file mode 100644
index 0000000..a30ce36
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/SleepsGarminAvroConverter.kt
@@ -0,0 +1,45 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminSleepSummary
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class SleepsGarminAvroConverter(topic: String = "push_integration_garmin_sleep") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val sleeps = tree[ROOT]
+ if (sleeps == null || !sleeps.isArray) {
+ throw BadRequestException("The sleep data was invalid.")
+ }
+ }
+
+ override fun convert(tree: JsonNode, user: User): List> {
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): GarminSleepSummary {
+ return GarminSleepSummary.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = node["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ date = node["calendarDate"]?.asText()
+ startTimeOffset = node["startTimeOffsetInSeconds"]?.asInt()
+ duration = node["durationInSeconds"]?.asInt()
+ unmeasurableSleepDuration = node["unmeasurableSleepDurationInSeconds"]?.asInt()
+ deepSleepDuration = node["deepSleepDurationInSeconds"]?.asInt()
+ lightSleepDuration = node["lightSleepDurationInSeconds"]?.asInt()
+ remSleepDuration = node["remSleepInSeconds"]?.asInt()
+ awakeDuration = node["awakeDurationInSeconds"]?.asInt()
+ validation = node["validation"]?.asText()
+ }.build()
+ }
+
+ companion object {
+ const val ROOT = "sleeps"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressBodyBatteryGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressBodyBatteryGarminAvroConverter.kt
new file mode 100644
index 0000000..960cce3
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressBodyBatteryGarminAvroConverter.kt
@@ -0,0 +1,53 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminBodyBatterySample
+import java.time.Instant
+
+class StressBodyBatteryGarminAvroConverter(
+ topic: String = "push_integration_garmin_body_battery_sample",
+ val root: String = ROOT
+) :
+ GarminAvroConverter(topic) {
+ override fun validate(tree: JsonNode) = Unit
+
+ override fun convert(tree: JsonNode, user: User): List> {
+
+ return tree[root].map { node ->
+ getSamples(
+ node[SUB_NODE], node["summaryId"].asText(),
+ user.observationKey, node["startTimeInSeconds"].asDouble()
+ )
+ }.flatten()
+ }
+
+ private fun getSamples(
+ node: JsonNode?,
+ summaryId: String,
+ observationKey: ObservationKey,
+ startTime: Double
+ ): List> {
+ if (node == null) {
+ return emptyList()
+ }
+ return node.fields().asSequence().map { (key, value) ->
+ Pair(
+ observationKey,
+ GarminBodyBatterySample.newBuilder().apply {
+ this.summaryId = summaryId
+ this.time = startTime + key.toDouble()
+ this.timeReceived = Instant.now().toEpochMilli() / 1000.0
+ this.bodyBattery = value?.floatValue()
+ }.build()
+ )
+ }.toList()
+ }
+
+ companion object {
+ const val ROOT = "stressDetails"
+ const val SUB_NODE = "timeOffsetBodyBatteryDetails"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressDetailsGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressDetailsGarminAvroConverter.kt
new file mode 100644
index 0000000..268d393
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressDetailsGarminAvroConverter.kt
@@ -0,0 +1,44 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminStressDetailSummary
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+import javax.ws.rs.container.ContainerRequestContext
+
+class StressDetailsGarminAvroConverter(topic: String = "push_integration_garmin_stress") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val stress = tree[ROOT]
+ if (stress == null || !stress.isArray) {
+ throw BadRequestException("The Stress data was invalid.")
+ }
+ }
+
+ override fun convert(
+ tree: JsonNode,
+ user: User
+ ): List> {
+
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): GarminStressDetailSummary {
+ return GarminStressDetailSummary.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = node["startTimeInSeconds"].asDouble()
+ timeReceived = Instant.now().toEpochMilli() / 1000.0
+ startTimeOffset = node["startTimeOffsetInSeconds"]?.asInt()
+ duration = node["durationInSeconds"]?.asInt()
+ date = node["calendarDate"]?.asText()
+ }.build()
+ }
+
+ companion object {
+ const val ROOT = "stressDetails"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressLevelGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressLevelGarminAvroConverter.kt
new file mode 100644
index 0000000..c09c1d1
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/StressLevelGarminAvroConverter.kt
@@ -0,0 +1,57 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.kafka.ObservationKey
+import org.radarcns.push.garmin.GarminStressLevelSample
+import java.time.Instant
+import javax.ws.rs.container.ContainerRequestContext
+
+class StressLevelGarminAvroConverter(
+ topic: String = "push_integration_garmin_stress_level"
+) :
+ GarminAvroConverter(topic) {
+ override fun validate(tree: JsonNode) = Unit
+
+ override fun convert(
+ tree: JsonNode,
+ user: User
+ ): List> {
+
+ return tree[ROOT].map { node ->
+ getSamples(
+ node[SUB_NODE], node["summaryId"].asText(),
+ user.observationKey, node["startTimeInSeconds"].asDouble()
+ )
+ }.flatten()
+ }
+
+ private fun getSamples(
+ node: JsonNode?,
+ summaryId: String,
+ observationKey: ObservationKey,
+ startTime: Double
+ ): List> {
+ if (node == null) {
+ return emptyList()
+ }
+
+ return node.fields().asSequence().map { (key, value) ->
+ Pair(
+ observationKey,
+ GarminStressLevelSample.newBuilder().apply {
+ this.summaryId = summaryId
+ this.time = startTime + key.toDouble()
+ this.timeReceived = Instant.now().toEpochMilli() / 1000.0
+ this.stressLevel = value?.floatValue()
+ }.build()
+ )
+ }.toList()
+ }
+
+ companion object {
+ const val ROOT = "stressDetails"
+ const val SUB_NODE = "timeOffsetStressLevelValues"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/converter/UserMetricsGarminAvroConverter.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/UserMetricsGarminAvroConverter.kt
new file mode 100644
index 0000000..dc6f1b3
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/converter/UserMetricsGarminAvroConverter.kt
@@ -0,0 +1,43 @@
+package org.radarbase.push.integration.garmin.converter
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.apache.avro.specific.SpecificRecord
+import org.radarbase.push.integration.common.user.User
+import org.radarcns.push.garmin.GarminUserMetrics
+import java.time.Instant
+import javax.ws.rs.BadRequestException
+
+class UserMetricsGarminAvroConverter(topic: String = "push_integration_garmin_user_metrics") :
+ GarminAvroConverter(topic) {
+
+ override fun validate(tree: JsonNode) {
+ val userMetrics = tree[ROOT]
+ if (userMetrics == null || !userMetrics.isArray) {
+ throw BadRequestException("The User Metrics data was invalid.")
+ }
+ }
+
+ override fun convert(
+ tree: JsonNode,
+ user: User
+ ): List> {
+
+ return tree[ROOT]
+ .map { node -> Pair(user.observationKey, getRecord(node)) }
+ }
+
+ private fun getRecord(node: JsonNode): GarminUserMetrics {
+ return GarminUserMetrics.newBuilder().apply {
+ summaryId = node["summaryId"]?.asText()
+ time = Instant.now().toEpochMilli() / 1000.0
+ timeReceived = time
+ date = node["calendarDate"]?.asText()
+ vo2Max = node["vo2Max"]?.floatValue()
+ fitnessAge = node["fitnessAge"]?.asInt()
+ }.build()
+ }
+
+ companion object {
+ const val ROOT = "userMetrics"
+ }
+}
diff --git a/src/main/kotlin/org/radarbase/push/integration/garmin/factory/GarminAuthMetadataFactory.kt b/src/main/kotlin/org/radarbase/push/integration/garmin/factory/GarminAuthMetadataFactory.kt
new file mode 100644
index 0000000..8aa1647
--- /dev/null
+++ b/src/main/kotlin/org/radarbase/push/integration/garmin/factory/GarminAuthMetadataFactory.kt
@@ -0,0 +1,13 @@
+package org.radarbase.push.integration.garmin.factory
+
+import java.util.function.Supplier
+import javax.ws.rs.container.ContainerRequestContext
+import javax.ws.rs.core.Context
+
+@Suppress("UNCHECKED_CAST")
+class GarminAuthMetadataFactory(
+ @Context private val requestContext: ContainerRequestContext
+) : Supplier