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
### cert ###
cert/
#### Hlæja ###
/cert/

View File

@@ -4,66 +4,61 @@ Classes and endpoints, to shape and to steer, Devices and sensors, their purpose
## Properties for deployment
| name | required | info |
|-------------------------------|----------|--------------------------|
| spring.profiles.active | * | Spring Boot environment |
| server.port | * | HTTP port |
| server.ssl.enabled | * | HTTP Enable SSL |
| server.ssl.key-store | * | HTTP Keystore |
| server.ssl.key-store-type | * | HTTP Cert Type |
| server.ssl.key-store-password | ** | HTTP Cert Pass |
| jwt.public-key | * | JWT public key |
| device-registry.url | * | Device Register URL |
| device-data.url | * | Device Data URL |
| device-configuration.url | * | Device Configuration URL |
| name | required | info |
|-------------------------------|:--------:|------------------------------|
| spring.profiles.active | ✓ | Spring Boot environment |
| server.port | ✓ | HTTP port |
| server.ssl.enabled | ✓ | HTTP Enable SSL |
| server.ssl.key-store | ✓ | HTTP Keystore |
| server.ssl.key-store-type | ✓ | HTTP Cert Type |
| server.ssl.key-store-password | ✗ | HTTP Cert Pass |
| spring.cache.type | | Cache type (redis) |
| spring.data.redis.host | ✓ | Redis host |
| spring.data.redis.port | | Redis port |
| 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
Run `release.sh` script from `master` branch.
Run release pipeline from `master` branch.
## Development Configuration
### Developer Keystore
1. Open `hosts` file:
* 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
```
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.
### 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/`.
- On Windows, this directory is typically `C:\Users\<YourUsername>\.gradle\`.
#### Gradle Properties
2. Add the following lines to the `gradle.properties` file:
```properties
repository.user=your_user
repository.token=your_token_value
```
or use environment variables `REPOSITORY_USER` and `REPOSITORY_TOKEN`
```properties
repository.user=your_user
repository.token=your_token_value
influxdb.token=your_token_value
```
#### Environment Variables
```properties
REPOSITORY_USER=your_user
REPOSITORY_TOKEN=your_token_value
INFLUXDB_TOKEN=your_token_value
```

View File

@@ -1,10 +1,10 @@
plugins {
alias(hlaeja.plugins.kotlin.jvm)
alias(hlaeja.plugins.kotlin.spring)
alias(hlaeja.plugins.ltd.hlaeja.plugin.certificate)
alias(hlaeja.plugins.ltd.hlaeja.plugin.service)
alias(hlaeja.plugins.spring.boot)
alias(hlaeja.plugins.spring.dependency.management)
alias(hlaeja.plugins.springframework.boot)
alias(hlaeja.plugins.certificate)
alias(hlaeja.plugins.service)
}
dependencies {
@@ -13,13 +13,13 @@ dependencies {
implementation(hlaeja.kotlin.logging)
implementation(hlaeja.kotlin.reflect)
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.cache)
implementation(hlaeja.springboot.starter.redis)
implementation(hlaeja.springboot.starter.webflux)
runtimeOnly(hlaeja.jjwt.impl)
runtimeOnly(hlaeja.jjwt.jackson)
testImplementation(hlaeja.kotlin.test.junit5)
testImplementation(hlaeja.kotlinx.coroutines.test)
testImplementation(hlaeja.mockk)

View File

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

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

9
gradlew vendored
View File

@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# 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
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -206,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# 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.
# * 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.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

4
gradlew.bat vendored
View File

@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@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
@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
Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
Identity: {{identity}}

View File

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

View File

@@ -1,11 +1,11 @@
### get measurement
GET {{hostname}}/measurement
Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
Identity: {{identity}}
### add measurement for all
POST {{hostname}}/measurement
Content-Type: application/json
Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
Identity: {{identity}}
{
"button0": 0,
@@ -16,8 +16,8 @@ Identity: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789ABCDEFGHIJKLMNOPQRSTUVW
### add measurement for one
POST {{hostname}}/measurement
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
import ltd.hlaeja.property.CacheProperty
import ltd.hlaeja.property.DeviceConfigurationProperty
import ltd.hlaeja.property.DeviceDataProperty
import ltd.hlaeja.property.DeviceRegistryProperty
@@ -7,8 +8,11 @@ import ltd.hlaeja.property.JwtProperty
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.cache.annotation.EnableCaching
@EnableCaching
@EnableConfigurationProperties(
CacheProperty::class,
DeviceConfigurationProperty::class,
DeviceDataProperty::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
import java.util.UUID
import ltd.hlaeja.jwt.service.PublicJwtService
import ltd.hlaeja.service.DeviceConfigurationService
import ltd.hlaeja.service.JwtService
import ltd.hlaeja.service.DeviceRegistryService
import ltd.hlaeja.util.toDeviceResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestHeader
@@ -12,12 +14,17 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/configuration")
class ConfigurationController(
private val configurationService: DeviceConfigurationService,
private val jwtService: JwtService,
private val deviceRegistry: DeviceRegistryService,
private val publicJwtService: PublicJwtService,
) {
@GetMapping
suspend fun getNodeConfiguration(
@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() }
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
import java.util.UUID
import ltd.hlaeja.jwt.service.PublicJwtService
import ltd.hlaeja.library.deviceData.MeasurementData
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.service.DeviceDataService
import ltd.hlaeja.service.JwtService
import ltd.hlaeja.service.DeviceRegistryService
import org.springframework.http.HttpStatus.CREATED
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
@@ -16,13 +19,14 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/measurement")
class MeasurementController(
private val dataService: DeviceDataService,
private val jwtService: JwtService,
private val deviceRegistry: DeviceRegistryService,
private val publicJwtService: PublicJwtService,
) {
@GetMapping
suspend fun getNodeMeasurement(
@RequestHeader("Identity") identityToken: String,
): Map<String, Number> = jwtService.getIdentity(identityToken)
): Map<String, Number> = readIdentityToken(identityToken)
.let { dataService.getMeasurement(it.client, it.node).fields }
@PostMapping
@@ -31,7 +35,7 @@ class MeasurementController(
@RequestHeader("Identity") identityToken: String,
@RequestBody measurement: Map<String, Number>,
) {
return jwtService.getIdentity(identityToken)
return readIdentityToken(identityToken)
.let {
dataService.addMeasurement(
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
import io.github.oshai.kotlinlogging.KotlinLogging
import java.util.UUID
import ltd.hlaeja.library.deviceConfiguration.Node
import ltd.hlaeja.property.DeviceConfigurationProperty
import ltd.hlaeja.util.logCall
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.http.HttpStatus.NO_CONTENT
import org.springframework.http.HttpStatus.REQUEST_TIMEOUT
import ltd.hlaeja.util.deviceConfigurationGetConfiguration
import org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE
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.awaitBodyOrNull
import org.springframework.web.reactive.function.client.WebClientRequestException
import org.springframework.web.server.ResponseStatusException
private val log = KotlinLogging.logger {}
@Service
class DeviceConfigurationService(
private val webClient: WebClient,
@@ -20,9 +22,12 @@ class DeviceConfigurationService(
suspend fun getConfiguration(
node: UUID,
): Node.Response = webClient.get()
.uri("${deviceConfigurationProperty.url}/node-$node".also(::logCall))
.retrieve()
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NO_CONTENT) }
.awaitBodyOrNull<Node.Response>() ?: throw ResponseStatusException(REQUEST_TIMEOUT)
): Node.Response = try {
webClient.deviceConfigurationGetConfiguration(node, deviceConfigurationProperty)
} catch (e: ErrorResponseException) {
throw e
} catch (e: WebClientRequestException) {
log.error(e) { "Error device registry" }
throw ResponseStatusException(SERVICE_UNAVAILABLE)
}
}

View File

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

View File

@@ -1,28 +1,35 @@
package ltd.hlaeja.service
import io.github.oshai.kotlinlogging.KotlinLogging
import java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.property.DeviceRegistryProperty
import ltd.hlaeja.util.logCall
import org.springframework.http.HttpStatus.NOT_ACCEPTABLE
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.http.HttpStatus.REQUEST_TIMEOUT
import ltd.hlaeja.util.deviceRegistryIdentityDevice
import org.springframework.cache.annotation.Cacheable
import org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE
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.awaitBodyOrNull
import org.springframework.web.reactive.function.client.WebClientRequestException
import org.springframework.web.server.ResponseStatusException
private val log = KotlinLogging.logger {}
@Service
class DeviceRegistryService(
private val webClient: WebClient,
private val deviceRegistryProperty: DeviceRegistryProperty,
) {
@Cacheable(value = ["identity"], key = "#device")
suspend fun getIdentityFromDevice(
device: UUID,
): Identity.Response = webClient.get()
.uri("${deviceRegistryProperty.url}/identity/device-$device".also(::logCall))
.retrieve()
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NOT_ACCEPTABLE) }
.awaitBodyOrNull<Identity.Response>() ?: throw ResponseStatusException(REQUEST_TIMEOUT)
): Identity.Response = try {
webClient.deviceRegistryIdentityDevice(device, deviceRegistryProperty)
} catch (e: ErrorResponseException) {
throw e
} 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
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
private val log = KotlinLogging.logger {}
fun logCall(url: String) {
log.debug("calling: {}", url)
}
fun logCall(url: String) = 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",
"description": "Application build os version."
},
{
"name": "jwt.public-key",
"type": "java.lang.String",
"description": "Jwt public key file."
},
{
"name": "device-registry.url",
"type": "java.lang.String",
@@ -39,6 +34,11 @@
"name": "device-configuration.url",
"type": "java.lang.String",
"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:
name: "%APP_BUILD_OS_NAME%"
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:
public-key: cert/public_key.pem
@@ -21,6 +44,9 @@ spring:
config:
activate:
on-profile: development
data:
redis:
host: localhost
server:
port: 8443
@@ -47,6 +73,9 @@ spring:
config:
activate:
on-profile: docker
data:
redis:
host: Redis
server:
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-----