13 Commits

Author SHA1 Message Date
be61a6dfbc update gradlew 2025-07-29 14:17:01 +02:00
7029074c62 add GitHub action
- update release in README.md
- add action run checks
- add action release
- remove release.sh
2025-07-29 14:17:01 +02:00
5abb6b2204 remove metrics collection 2025-07-29 14:17:01 +02:00
104636199f update project 2025-07-29 14:17:01 +02:00
9124ccb204 add actuator.http 2025-07-27 22:19:03 +02:00
6715354be3 [RELEASE] - bump version 2025-01-02 07:26:44 +01:00
35c7712f85 [RELEASE] - release version: 0.4.0 2025-01-02 07:26:41 +01:00
84d09f6dbb replace local jwt with library version
- update ConfigurationController to handle hlaeja jwt instead of jwtService
- update MeasurementController to handle hlaeja jwt instead of jwtService
- add dependency for hlaeja jwt
- remove dependencies for jjwt
- remove JwtService.kt
- remove PublicKeyProvider.kt
- remove jwt key property explanation from additional-spring-configuration-metadata.json
2025-01-02 06:48:12 +01:00
c5ff6e555a [RELEASE] - bump version 2024-12-28 08:05:01 +01:00
e4a70e0e43 [RELEASE] - release version: 0.3.0 2024-12-28 08:04:58 +01:00
6522809dce add Redis cache and some clean up
- update and cleanup in README.md
- update getIdentity to throw response exception with 401 in JwtService
- update http files to use identity from env
- add cacheable to getIdentityFromDevice in DeviceRegistryService
- add RedisCacheConfiguration
- add CacheProperty
- set up cache property
- set up data redis cache
2024-12-28 05:45:23 +01:00
19f46bd01f add metrics collection for API
- extract webclient call from getConfiguration and add metrics in DeviceConfigurationService
- extract webclient call from getMeasurement, addMeasurement and add metrics in DeviceDataService
- extract webclient call from getIdentityFromDevice and add metrics in DeviceRegistryService
- add MeterRegistry dependency
- fix test application.yml
- update kotlin logging dependency
2024-12-27 23:37:55 +01:00
e48cf674a5 [RELEASE] - bump version 2024-12-10 23:53:59 +01:00
34 changed files with 352 additions and 297 deletions

12
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: Release
on:
workflow_dispatch:
jobs:
release:
uses: swordsteel/hlaeja-common-workflows/.github/workflows/release.yml@master
secrets:
CI_BOT_PAT: ${{ secrets.CI_BOT_PAT }}
with:
TYPE: service

12
.github/workflows/run-checks.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: Pull request validation
on:
pull_request:
paths-ignore:
- '.github/**'
jobs:
validate:
uses: swordsteel/hlaeja-common-workflows/.github/workflows/run-checks.yml@master
secrets:
CI_BOT_PAT: ${{ secrets.CI_BOT_PAT }}

4
.gitignore vendored
View File

@@ -39,5 +39,5 @@ out/
### Kotlin ### ### Kotlin ###
.kotlin .kotlin
### cert ### #### Hlæja ###
cert/ /cert/

View File

@@ -4,66 +4,61 @@ Classes and endpoints, to shape and to steer, Devices and sensors, their purpose
## Properties for deployment ## Properties for deployment
| name | required | info | | name | required | info |
|-------------------------------|----------|--------------------------| |-------------------------------|:--------:|------------------------------|
| spring.profiles.active | * | Spring Boot environment | | spring.profiles.active | ✓ | Spring Boot environment |
| server.port | * | HTTP port | | server.port | ✓ | HTTP port |
| server.ssl.enabled | * | HTTP Enable SSL | | server.ssl.enabled | ✓ | HTTP Enable SSL |
| server.ssl.key-store | * | HTTP Keystore | | server.ssl.key-store | ✓ | HTTP Keystore |
| server.ssl.key-store-type | * | HTTP Cert Type | | server.ssl.key-store-type | ✓ | HTTP Cert Type |
| server.ssl.key-store-password | ** | HTTP Cert Pass | | server.ssl.key-store-password | ✗ | HTTP Cert Pass |
| jwt.public-key | * | JWT public key | | spring.cache.type | | Cache type (redis) |
| device-registry.url | * | Device Register URL | | spring.data.redis.host | ✓ | Redis host |
| device-data.url | * | Device Data URL | | spring.data.redis.port | | Redis port |
| device-configuration.url | * | Device Configuration URL | | spring.data.redis.database | ✓ | Redis database |
| spring.data.redis.password | ✗ | Redis password |
| cache.time-to-live | | Cache time to live (minutes) |
| jwt.public-key | ✓ | JWT public key |
| device-registry.url | ✓ | Device Register URL |
| device-data.url | ✓ | Device Data URL |
| device-configuration.url | ✓ | Device Configuration URL |
Required: * can be stored as text, and ** need to be stored as secret. *Required: ✓ can be stored as text, and ✗ need to be stored as secret.*
## Releasing Service ## Releasing Service
Run `release.sh` script from `master` branch. Run release pipeline from `master` branch.
## Development Configuration ## Development Configuration
### Developer Keystore ### Developer Keystore
1. Open `hosts` file: We use a keystore to enable HTTPS for our API. To set up your developer environment for local development, please refer to [generate keystore](https://github.com/swordsteel/hlaeja-development/blob/master/doc/keystore.md) documentation. When generating and exporting the certificate for local development, please store it in the `./cert/keystore.p12` folder at the project root.
* On Unix-like systems (Linux, macOS), this directory is typically `/etc/hosts`.
* On Windows, this directory is typically `%SystemRoot%\System32\drivers\etc\hosts`.
2. Add the following lines to the `hosts` file:
```text
127.0.0.1 deviceapi # Hlæja Device API
```
3. Generate Keystores
```shell
keytool -genkeypair -alias device-api -keyalg RSA -keysize 2048 -validity 3650 -dname "CN=deviceapi" -keypass password -keystore ./cert/keystore.p12 -storetype PKCS12 -storepass password
```
4. Export the public certificate
```shell
keytool -export -alias device-api -keystore ./cert/keystore.p12 -storepass password -file ./cert/device-api.cer -rfc
```
### Public RSA Key ### Public RSA Key
To validate devices, copy file named `public_key.pem` from `./cert` generated for local development in **Hlæja Device Register** in to `./cert`. This service uses the public key from **[Hlæja Device Register](https://github.com/swordsteel/hlaeja-device-registry)** to identify devices. To set up device identification for local development, copy the `public_key.pem` file from the `./cert` directory in **Hlæja Device Register** into the `./cert` directory of this project.
### Global gradle properties *Note: For more information on generating RSA keys, please refer to our [generate RSA key](https://github.com/swordsteel/hlaeja-development/blob/master/doc/rsa_key.md) documentation.*
To authenticate with Gradle to access repositories that require authentication, you can set your user and token in the `gradle.properties` file. ### Global Settings
Here's how you can do it: This services rely on a set of global settings to configure development environments. These settings, managed through Gradle properties or environment variables.
1. Open or create the `gradle.properties` file in your Gradle user home directory: *Note: For more information on global properties, please refer to our [global settings](https://github.com/swordsteel/hlaeja-development/blob/master/doc/global_settings.md) documentation.*
- On Unix-like systems (Linux, macOS), this directory is typically `~/.gradle/`. #### Gradle Properties
- On Windows, this directory is typically `C:\Users\<YourUsername>\.gradle\`.
2. Add the following lines to the `gradle.properties` file: ```properties
```properties repository.user=your_user
repository.user=your_user repository.token=your_token_value
repository.token=your_token_value influxdb.token=your_token_value
``` ```
or use environment variables `REPOSITORY_USER` and `REPOSITORY_TOKEN`
#### Environment Variables
```properties
REPOSITORY_USER=your_user
REPOSITORY_TOKEN=your_token_value
INFLUXDB_TOKEN=your_token_value
```

View File

@@ -1,10 +1,10 @@
plugins { plugins {
alias(hlaeja.plugins.kotlin.jvm) alias(hlaeja.plugins.kotlin.jvm)
alias(hlaeja.plugins.kotlin.spring) alias(hlaeja.plugins.kotlin.spring)
alias(hlaeja.plugins.ltd.hlaeja.plugin.certificate) alias(hlaeja.plugins.spring.boot)
alias(hlaeja.plugins.ltd.hlaeja.plugin.service)
alias(hlaeja.plugins.spring.dependency.management) alias(hlaeja.plugins.spring.dependency.management)
alias(hlaeja.plugins.springframework.boot) alias(hlaeja.plugins.certificate)
alias(hlaeja.plugins.service)
} }
dependencies { dependencies {
@@ -13,13 +13,13 @@ dependencies {
implementation(hlaeja.kotlin.logging) implementation(hlaeja.kotlin.logging)
implementation(hlaeja.kotlin.reflect) implementation(hlaeja.kotlin.reflect)
implementation(hlaeja.kotlinx.coroutines) implementation(hlaeja.kotlinx.coroutines)
implementation(hlaeja.library.hlaeja.common.messages) implementation(hlaeja.library.common.messages)
implementation(hlaeja.library.jwt)
implementation(hlaeja.springboot.starter.actuator) implementation(hlaeja.springboot.starter.actuator)
implementation(hlaeja.springboot.starter.cache)
implementation(hlaeja.springboot.starter.redis)
implementation(hlaeja.springboot.starter.webflux) implementation(hlaeja.springboot.starter.webflux)
runtimeOnly(hlaeja.jjwt.impl)
runtimeOnly(hlaeja.jjwt.jackson)
testImplementation(hlaeja.kotlin.test.junit5) testImplementation(hlaeja.kotlin.test.junit5)
testImplementation(hlaeja.kotlinx.coroutines.test) testImplementation(hlaeja.kotlinx.coroutines.test)
testImplementation(hlaeja.mockk) testImplementation(hlaeja.mockk)

View File

@@ -1,7 +1,7 @@
kotlin.code.style=official kotlin.code.style=official
org.gradle.jvmargs=-Xmx1g org.gradle.jvmargs=-Xmx1g
version=0.2.0 version=0.5.0-SNAPSHOT
catalog=0.6.0 catalog=0.11.0-SNAPSHOT
docker.port.expose=8443 docker.port.expose=8443
container.port.expose=8443 container.port.expose=8443
container.port.host=9000 container.port.host=9000

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

9
gradlew vendored
View File

@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -206,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.

4
gradlew.bat vendored
View File

@@ -70,11 +70,11 @@ goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar set CLASSPATH=
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell

5
http/actuator.http Normal file
View File

@@ -0,0 +1,5 @@
### get actuator
GET {{hostname}}/actuator
### get actuator health
GET {{hostname}}/actuator/health

View File

@@ -1,3 +1,3 @@
### get measurement ### get configuration
GET {{hostname}}/configuration GET {{hostname}}/configuration
Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ Identity: {{identity}}

View File

@@ -1,8 +1,10 @@
{ {
"development": { "development": {
"hostname": "https://localhost:8443" "hostname": "https://localhost:8443",
}, "identity": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"docker": { },
"hostname": "https://localhost:9000" "docker": {
} "hostname": "https://localhost:9000",
"identity": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
}
} }

View File

@@ -1,11 +1,11 @@
### get measurement ### get measurement
GET {{hostname}}/measurement GET {{hostname}}/measurement
Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ Identity: {{identity}}
### add measurement for all ### add measurement for all
POST {{hostname}}/measurement POST {{hostname}}/measurement
Content-Type: application/json Content-Type: application/json
Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ Identity: {{identity}}
{ {
"button0": 0, "button0": 0,
@@ -16,8 +16,8 @@ Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVW
### add measurement for one ### add measurement for one
POST {{hostname}}/measurement POST {{hostname}}/measurement
Content-Type: application/json Content-Type: application/json
Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ Identity: {{identity}}
{ {
"button0": 0 "button0": 1
} }

View File

@@ -1,89 +0,0 @@
#!/bin/sh
### This should be a pipeline, but for this example let use this ###
check_active_branch() {
if [ "$(git rev-parse --abbrev-ref HEAD)" != "$1" ]; then
echo "Error: The current branch is not $1."
exit 1
fi
}
check_uncommitted_changes() {
if [ -n "$(git status --porcelain)" ]; then
echo "Error: There are uncommitted changes in the repository."
exit 1
fi
}
prepare_environment() {
git fetch origin
}
check_last_commit() {
last_commit_message=$(git log -1 --pretty=format:%s)
if [ "$last_commit_message" = "[RELEASE] - bump version" ]; then
echo "Warning: Nothing to release!!!"
exit 1
fi
}
check_differences() {
if ! git diff --quiet origin/"$1" "$1"; then
echo "Error: The branches origin/$1 and $1 have differences."
exit 1
fi
}
un_snapshot_version() {
sed -i "s/\($1\s*=\s*[0-9]\+\.[0-9]\+\.[0-9]\+\).*/\1/" gradle.properties
}
current_version() {
awk -F '=' '/version\s*=\s*[0-9.]*/ {gsub(/^ +| +$/,"",$2); print $2}' gradle.properties
}
stage_files() {
for file in "$@"; do
if git diff --exit-code --quiet -- "$file"; then
echo "No changes in $file"
else
git add "$file"
echo "Changes in $file staged for commit"
fi
done
}
commit_change() {
stage_files gradle.properties
git commit -m "[RELEASE] - $1"
git push --porcelain origin master
}
add_release_tag() {
gitTag="v$(current_version)"
git tag -a "$gitTag" -m "Release version $gitTag"
git push --porcelain origin "$gitTag"
}
snapshot_version() {
new_version="$(current_version | awk -F '.' '{print $1 "." $2+1 ".0"}')"
sed -i "s/\(version\s*=\s*\)[0-9.]*/\1$new_version-SNAPSHOT/" gradle.properties
}
# check and prepare for release
check_active_branch master
check_uncommitted_changes
prepare_environment
check_last_commit
check_differences master
# un-snapshot version for release
un_snapshot_version version
un_snapshot_version catalog
# release changes and prepare for next release
commit_change "release version: $(current_version)"
add_release_tag
snapshot_version
commit_change 'bump version'

View File

@@ -1,5 +1,6 @@
package ltd.hlaeja package ltd.hlaeja
import ltd.hlaeja.property.CacheProperty
import ltd.hlaeja.property.DeviceConfigurationProperty import ltd.hlaeja.property.DeviceConfigurationProperty
import ltd.hlaeja.property.DeviceDataProperty import ltd.hlaeja.property.DeviceDataProperty
import ltd.hlaeja.property.DeviceRegistryProperty import ltd.hlaeja.property.DeviceRegistryProperty
@@ -7,8 +8,11 @@ import ltd.hlaeja.property.JwtProperty
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.cache.annotation.EnableCaching
@EnableCaching
@EnableConfigurationProperties( @EnableConfigurationProperties(
CacheProperty::class,
DeviceConfigurationProperty::class, DeviceConfigurationProperty::class,
DeviceDataProperty::class, DeviceDataProperty::class,
DeviceRegistryProperty::class, DeviceRegistryProperty::class,

View File

@@ -0,0 +1,26 @@
package ltd.hlaeja.configuration
import java.time.Duration
import ltd.hlaeja.exception.CacheException
import ltd.hlaeja.property.CacheProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.cache.RedisCacheConfiguration
import org.springframework.data.redis.cache.RedisCacheManager
import org.springframework.data.redis.core.RedisTemplate
@Configuration
class RedisCacheConfiguration(
private val cacheProperty: CacheProperty,
) {
@Bean
fun cacheManager(
redisTemplate: RedisTemplate<String, String>,
): RedisCacheManager = redisTemplate.connectionFactory
?.let { RedisCacheManager.builder(it).cacheDefaults(getRedisCacheConfiguration()).build() }
?: throw CacheException("Redis connection factory is not set")
private fun getRedisCacheConfiguration(): RedisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(cacheProperty.timeToLive))
}

View File

@@ -1,7 +1,9 @@
package ltd.hlaeja.controller package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.jwt.service.PublicJwtService
import ltd.hlaeja.service.DeviceConfigurationService import ltd.hlaeja.service.DeviceConfigurationService
import ltd.hlaeja.service.JwtService import ltd.hlaeja.service.DeviceRegistryService
import ltd.hlaeja.util.toDeviceResponse import ltd.hlaeja.util.toDeviceResponse
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestHeader
@@ -12,12 +14,17 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/configuration") @RequestMapping("/configuration")
class ConfigurationController( class ConfigurationController(
private val configurationService: DeviceConfigurationService, private val configurationService: DeviceConfigurationService,
private val jwtService: JwtService, private val deviceRegistry: DeviceRegistryService,
private val publicJwtService: PublicJwtService,
) { ) {
@GetMapping @GetMapping
suspend fun getNodeConfiguration( suspend fun getNodeConfiguration(
@RequestHeader("Identity") identityToken: String, @RequestHeader("Identity") identityToken: String,
): Map<String, String> = jwtService.getIdentity(identityToken) ): Map<String, String> = readIdentityToken(identityToken)
.let { deviceRegistry.getIdentityFromDevice(it) }
.let { configurationService.getConfiguration(it.node).toDeviceResponse() } .let { configurationService.getConfiguration(it.node).toDeviceResponse() }
private fun readIdentityToken(identityToken: String): UUID = publicJwtService
.verify(identityToken) { claims -> UUID.fromString(claims.payload["device"] as String) }
} }

View File

@@ -1,8 +1,11 @@
package ltd.hlaeja.controller package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.jwt.service.PublicJwtService
import ltd.hlaeja.library.deviceData.MeasurementData import ltd.hlaeja.library.deviceData.MeasurementData
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.service.DeviceDataService import ltd.hlaeja.service.DeviceDataService
import ltd.hlaeja.service.JwtService import ltd.hlaeja.service.DeviceRegistryService
import org.springframework.http.HttpStatus.CREATED import org.springframework.http.HttpStatus.CREATED
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@@ -16,13 +19,14 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/measurement") @RequestMapping("/measurement")
class MeasurementController( class MeasurementController(
private val dataService: DeviceDataService, private val dataService: DeviceDataService,
private val jwtService: JwtService, private val deviceRegistry: DeviceRegistryService,
private val publicJwtService: PublicJwtService,
) { ) {
@GetMapping @GetMapping
suspend fun getNodeMeasurement( suspend fun getNodeMeasurement(
@RequestHeader("Identity") identityToken: String, @RequestHeader("Identity") identityToken: String,
): Map<String, Number> = jwtService.getIdentity(identityToken) ): Map<String, Number> = readIdentityToken(identityToken)
.let { dataService.getMeasurement(it.client, it.node).fields } .let { dataService.getMeasurement(it.client, it.node).fields }
@PostMapping @PostMapping
@@ -31,7 +35,7 @@ class MeasurementController(
@RequestHeader("Identity") identityToken: String, @RequestHeader("Identity") identityToken: String,
@RequestBody measurement: Map<String, Number>, @RequestBody measurement: Map<String, Number>,
) { ) {
return jwtService.getIdentity(identityToken) return readIdentityToken(identityToken)
.let { .let {
dataService.addMeasurement( dataService.addMeasurement(
it.client, it.client,
@@ -45,4 +49,8 @@ class MeasurementController(
) )
} }
} }
private suspend fun readIdentityToken(identityToken: String): Identity.Response = publicJwtService
.verify(identityToken) { claims -> UUID.fromString(claims.payload["device"] as String) }
.let { deviceRegistry.getIdentityFromDevice(it) }
} }

View File

@@ -0,0 +1,23 @@
package ltd.hlaeja.exception
@Suppress("unused")
class CacheException : Exception {
constructor() : super()
constructor(message: String) : super(message)
constructor(cause: Throwable) : super(cause)
constructor(
message: String,
cause: Throwable,
) : super(message, cause)
constructor(
message: String,
cause: Throwable,
enableSuppression: Boolean,
writableStackTrace: Boolean,
) : super(message, cause, enableSuppression, writableStackTrace)
}

View File

@@ -0,0 +1,8 @@
package ltd.hlaeja.property
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "cache")
data class CacheProperty(
val timeToLive: Long,
)

View File

@@ -1,17 +1,19 @@
package ltd.hlaeja.service package ltd.hlaeja.service
import io.github.oshai.kotlinlogging.KotlinLogging
import java.util.UUID import java.util.UUID
import ltd.hlaeja.library.deviceConfiguration.Node import ltd.hlaeja.library.deviceConfiguration.Node
import ltd.hlaeja.property.DeviceConfigurationProperty import ltd.hlaeja.property.DeviceConfigurationProperty
import ltd.hlaeja.util.logCall import ltd.hlaeja.util.deviceConfigurationGetConfiguration
import org.springframework.http.HttpStatus.NOT_FOUND import org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE
import org.springframework.http.HttpStatus.NO_CONTENT
import org.springframework.http.HttpStatus.REQUEST_TIMEOUT
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.ErrorResponseException
import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBodyOrNull import org.springframework.web.reactive.function.client.WebClientRequestException
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
private val log = KotlinLogging.logger {}
@Service @Service
class DeviceConfigurationService( class DeviceConfigurationService(
private val webClient: WebClient, private val webClient: WebClient,
@@ -20,9 +22,12 @@ class DeviceConfigurationService(
suspend fun getConfiguration( suspend fun getConfiguration(
node: UUID, node: UUID,
): Node.Response = webClient.get() ): Node.Response = try {
.uri("${deviceConfigurationProperty.url}/node-$node".also(::logCall)) webClient.deviceConfigurationGetConfiguration(node, deviceConfigurationProperty)
.retrieve() } catch (e: ErrorResponseException) {
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NO_CONTENT) } throw e
.awaitBodyOrNull<Node.Response>() ?: throw ResponseStatusException(REQUEST_TIMEOUT) } catch (e: WebClientRequestException) {
log.error(e) { "Error device registry" }
throw ResponseStatusException(SERVICE_UNAVAILABLE)
}
} }

View File

@@ -1,18 +1,21 @@
package ltd.hlaeja.service package ltd.hlaeja.service
import io.github.oshai.kotlinlogging.KotlinLogging
import java.util.UUID import java.util.UUID
import ltd.hlaeja.library.deviceData.MeasurementData import ltd.hlaeja.library.deviceData.MeasurementData
import ltd.hlaeja.property.DeviceDataProperty import ltd.hlaeja.property.DeviceDataProperty
import ltd.hlaeja.util.logCall import ltd.hlaeja.util.deviceDataAddMeasurement
import org.springframework.http.HttpStatus.NOT_FOUND import ltd.hlaeja.util.deviceDataGetMeasurement
import org.springframework.http.HttpStatus.NO_CONTENT import org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE
import org.springframework.http.HttpStatus.REQUEST_TIMEOUT import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.ErrorResponseException
import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBodilessEntity import org.springframework.web.reactive.function.client.WebClientRequestException
import org.springframework.web.reactive.function.client.awaitBodyOrNull
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
private val log = KotlinLogging.logger {}
@Service @Service
class DeviceDataService( class DeviceDataService(
private val webClient: WebClient, private val webClient: WebClient,
@@ -22,18 +25,24 @@ class DeviceDataService(
suspend fun getMeasurement( suspend fun getMeasurement(
client: UUID, client: UUID,
node: UUID, node: UUID,
): MeasurementData.Response = webClient.get() ): MeasurementData.Response = try {
.uri("${deviceDataProperty.url}/client-$client/node-$node".also(::logCall)) webClient.deviceDataGetMeasurement(client, node, deviceDataProperty)
.retrieve() } catch (e: ErrorResponseException) {
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NO_CONTENT) } throw e
.awaitBodyOrNull<MeasurementData.Response>() ?: throw ResponseStatusException(REQUEST_TIMEOUT) } catch (e: WebClientRequestException) {
log.error(e) { "Error device registry" }
throw ResponseStatusException(SERVICE_UNAVAILABLE)
}
suspend fun addMeasurement( suspend fun addMeasurement(
client: UUID, client: UUID,
request: MeasurementData.Request, request: MeasurementData.Request,
) = webClient.post() ): ResponseEntity<Void> = try {
.uri("${deviceDataProperty.url}/client-$client".also(::logCall)) webClient.deviceDataAddMeasurement(client, request, deviceDataProperty)
.bodyValue(request) } catch (e: ErrorResponseException) {
.retrieve() throw e
.awaitBodilessEntity() } catch (e: WebClientRequestException) {
log.error(e) { "Error device registry" }
throw ResponseStatusException(SERVICE_UNAVAILABLE)
}
} }

View File

@@ -1,28 +1,35 @@
package ltd.hlaeja.service package ltd.hlaeja.service
import io.github.oshai.kotlinlogging.KotlinLogging
import java.util.UUID import java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Identity import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.property.DeviceRegistryProperty import ltd.hlaeja.property.DeviceRegistryProperty
import ltd.hlaeja.util.logCall import ltd.hlaeja.util.deviceRegistryIdentityDevice
import org.springframework.http.HttpStatus.NOT_ACCEPTABLE import org.springframework.cache.annotation.Cacheable
import org.springframework.http.HttpStatus.NOT_FOUND import org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE
import org.springframework.http.HttpStatus.REQUEST_TIMEOUT
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.ErrorResponseException
import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBodyOrNull import org.springframework.web.reactive.function.client.WebClientRequestException
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
private val log = KotlinLogging.logger {}
@Service @Service
class DeviceRegistryService( class DeviceRegistryService(
private val webClient: WebClient, private val webClient: WebClient,
private val deviceRegistryProperty: DeviceRegistryProperty, private val deviceRegistryProperty: DeviceRegistryProperty,
) { ) {
@Cacheable(value = ["identity"], key = "#device")
suspend fun getIdentityFromDevice( suspend fun getIdentityFromDevice(
device: UUID, device: UUID,
): Identity.Response = webClient.get() ): Identity.Response = try {
.uri("${deviceRegistryProperty.url}/identity/device-$device".also(::logCall)) webClient.deviceRegistryIdentityDevice(device, deviceRegistryProperty)
.retrieve() } catch (e: ErrorResponseException) {
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NOT_ACCEPTABLE) } throw e
.awaitBodyOrNull<Identity.Response>() ?: throw ResponseStatusException(REQUEST_TIMEOUT) } catch (e: WebClientRequestException) {
log.error(e) { "Error device identity" }
throw ResponseStatusException(SERVICE_UNAVAILABLE)
}
} }

View File

@@ -1,34 +0,0 @@
package ltd.hlaeja.service
import io.jsonwebtoken.JwtParser
import io.jsonwebtoken.Jwts
import java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.property.JwtProperty
import ltd.hlaeja.util.PublicKeyProvider
import mu.KotlinLogging
import org.springframework.stereotype.Service
private val log = KotlinLogging.logger {}
@Service
class JwtService(
jwtProperty: JwtProperty,
private val deviceRegistry: DeviceRegistryService,
) {
private val parser: JwtParser = Jwts.parser()
.verifyWith(PublicKeyProvider.load(jwtProperty.publicKey))
.build()
suspend fun getIdentity(
identityToken: String,
): Identity.Response = readIdentity(identityToken)
.let { deviceRegistry.getIdentityFromDevice(it) }
private suspend fun readIdentity(
identity: String,
): UUID = parser.parseSignedClaims(identity)
.let { UUID.fromString(it.payload["device"] as String) }
.also { log.debug("Identified client device: {}", it) }
}

View File

@@ -1,9 +1,7 @@
package ltd.hlaeja.util package ltd.hlaeja.util
import mu.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
fun logCall(url: String) { fun logCall(url: String) = log.debug { "calling: $url" }
log.debug("calling: {}", url)
}

View File

@@ -1,35 +0,0 @@
package ltd.hlaeja.util
import java.security.KeyFactory
import java.security.interfaces.RSAPublicKey
import java.security.spec.X509EncodedKeySpec
import java.util.Base64.getDecoder
import ltd.hlaeja.exception.KeyProviderException
object PublicKeyProvider {
fun load(
pemFile: String,
): RSAPublicKey = readPublicPemFile(pemFile)
.let(::makePublicKey)
private fun makePublicKey(
publicKeyBytes: ByteArray,
): RSAPublicKey = KeyFactory.getInstance("RSA")
.generatePublic(X509EncodedKeySpec(publicKeyBytes)) as RSAPublicKey
private fun readPublicPemFile(
publicKey: String,
): ByteArray = javaClass.classLoader
.getResource(publicKey)
?.readText()
?.let(::getPublicKeyByteArray)
?: throw KeyProviderException("Could not load public key")
private fun getPublicKeyByteArray(
keyText: String,
): ByteArray = keyText.replace(Regex("[\r\n]+"), "")
.removePrefix("-----BEGIN PUBLIC KEY-----")
.removeSuffix("-----END PUBLIC KEY-----")
.let { getDecoder().decode(it) }
}

View File

@@ -0,0 +1,56 @@
package ltd.hlaeja.util
import java.util.UUID
import ltd.hlaeja.library.deviceConfiguration.Node
import ltd.hlaeja.library.deviceData.MeasurementData
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.property.DeviceConfigurationProperty
import ltd.hlaeja.property.DeviceDataProperty
import ltd.hlaeja.property.DeviceRegistryProperty
import org.springframework.http.HttpStatus.NOT_ACCEPTABLE
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.http.HttpStatus.NO_CONTENT
import org.springframework.http.HttpStatus.REQUEST_TIMEOUT
import org.springframework.http.ResponseEntity
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBodilessEntity
import org.springframework.web.reactive.function.client.awaitBodyOrNull
import org.springframework.web.server.ResponseStatusException
suspend fun WebClient.deviceRegistryIdentityDevice(
device: UUID,
property: DeviceRegistryProperty,
): Identity.Response = get()
.uri("${property.url}/identity/device-$device".also(::logCall))
.retrieve()
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NOT_ACCEPTABLE) }
.awaitBodyOrNull<Identity.Response>() ?: throw ResponseStatusException(REQUEST_TIMEOUT)
suspend fun WebClient.deviceDataGetMeasurement(
client: UUID,
node: UUID,
property: DeviceDataProperty,
): MeasurementData.Response = get()
.uri("${property.url}/client-$client/node-$node".also(::logCall))
.retrieve()
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NO_CONTENT) }
.awaitBodyOrNull<MeasurementData.Response>() ?: throw ResponseStatusException(REQUEST_TIMEOUT)
suspend fun WebClient.deviceDataAddMeasurement(
client: UUID,
request: MeasurementData.Request,
property: DeviceDataProperty,
): ResponseEntity<Void> = post()
.uri("${property.url}/client-$client".also(::logCall))
.bodyValue(request)
.retrieve()
.awaitBodilessEntity()
suspend fun WebClient.deviceConfigurationGetConfiguration(
node: UUID,
property: DeviceConfigurationProperty,
): Node.Response = get()
.uri("${property.url}/node-$node".also(::logCall))
.retrieve()
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NO_CONTENT) }
.awaitBodyOrNull<Node.Response>() ?: throw ResponseStatusException(REQUEST_TIMEOUT)

View File

@@ -20,11 +20,6 @@
"type": "java.lang.String", "type": "java.lang.String",
"description": "Application build os version." "description": "Application build os version."
}, },
{
"name": "jwt.public-key",
"type": "java.lang.String",
"description": "Jwt public key file."
},
{ {
"name": "device-registry.url", "name": "device-registry.url",
"type": "java.lang.String", "type": "java.lang.String",
@@ -39,6 +34,11 @@
"name": "device-configuration.url", "name": "device-configuration.url",
"type": "java.lang.String", "type": "java.lang.String",
"description": "Url for device configuration service." "description": "Url for device configuration service."
},
{
"name": "cache.time-to-live",
"type": "java.lang.Long",
"description": "Cache expiration in minutes."
} }
] ]
} }

View File

@@ -9,6 +9,29 @@ spring:
os: os:
name: "%APP_BUILD_OS_NAME%" name: "%APP_BUILD_OS_NAME%"
version: "%APP_BUILD_OS_VERSION%" version: "%APP_BUILD_OS_VERSION%"
cache:
type: redis
data:
redis:
port: 6379
database: 1
management:
endpoints:
access:
default: none
web:
exposure:
include: "health,info"
endpoint:
health:
show-details: always
access: read_only
info:
access: read_only
cache:
time-to-live: 10
jwt: jwt:
public-key: cert/public_key.pem public-key: cert/public_key.pem
@@ -21,6 +44,9 @@ spring:
config: config:
activate: activate:
on-profile: development on-profile: development
data:
redis:
host: localhost
server: server:
port: 8443 port: 8443
@@ -47,6 +73,9 @@ spring:
config: config:
activate: activate:
on-profile: docker on-profile: docker
data:
redis:
host: Redis
server: server:
port: 8443 port: 8443

View File

@@ -1,11 +0,0 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger level="DEBUG" name="ltd.hlaeja"/>
</configuration>

View File

@@ -0,0 +1,10 @@
jwt:
public-key: cert/valid-public-key.pem
device-registry:
url: http://localhost:9010
device-data:
url: http://localhost:9020
device-configuration:
url: http://localhost:9030
cache:
time-to-live: 10

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3ZdlbISX729m5Ur1pVhg
XIvazcgUt0T0G32ML0tfwQ4aWTfwPII0SQRThaN6eiiBMRa0V8JMih1LT8JmGgst
dEx2nhMbVs/Osu8MhmP86c+HB/jPa1+0IR1TZKXoZoF52D2ZtoVf+mOWggAcm1R+
V0Fj2cR/pgLkVt3GKUE2OokFC1iFUQFjThd1EzKcOv53TUek8FY8t66npQ4t3unD
bXZKoGXMuXCqZVykMbGTUQFRuT3NAOXRrJP+UDeY2uM2Yk98J+8FtLDYD6jpmyi0
ghv6k8pK1w1n5NI3atVv5ZMUeQZ36AXL8SZi1105mamhLVQ0e0JixoMOPh7ziFyv
uwIDAQAB
-----END PUBLIC KEY-----