From e0ddd998caac02ce5f1fa37c9fee689e6223031b Mon Sep 17 00:00:00 2001 From: george_dimitropoulos <67735908+gdimitropoulos-sotec@users.noreply.github.com> Date: Tue, 2 May 2023 07:05:08 +0200 Subject: [PATCH] [#19] Support REST Api for sending configs and commands to devices (Device Communication API) (#20) Signed-off-by: g.dimitropoulos --- README.md | 10 +- device-communication/.dockerignore | 6 + device-communication/.gitignore | 40 ++ device-communication/.mvn/wrapper/.gitignore | 1 + .../.mvn/wrapper/MavenWrapperDownloader.java | 121 ++++++ .../.mvn/wrapper/maven-wrapper.properties | 18 + device-communication/README.md | 104 +++++ device-communication/img.png | Bin 0 -> 110144 bytes device-communication/mvnw | 317 ++++++++++++++ device-communication/mvnw.cmd | 188 +++++++++ device-communication/pom.xml | 227 ++++++++++ .../src/main/docker/Dockerfile.jvm | 93 ++++ .../src/main/docker/Dockerfile.legacy-jar | 93 ++++ .../src/main/docker/Dockerfile.native | 27 ++ .../src/main/docker/Dockerfile.native-micro | 30 ++ .../hono/communication/api/Application.java | 62 +++ .../api/DeviceCommunicationHttpServer.java | 239 +++++++++++ .../api/config/DeviceCommandConstants.java | 32 ++ .../api/config/DeviceConfigsConstants.java | 53 +++ .../api/data/DeviceCommandRequest.java | 111 +++++ .../communication/api/data/DeviceConfig.java | 145 +++++++ .../api/data/DeviceConfigEntity.java | 123 ++++++ .../api/data/DeviceConfigRequest.java | 111 +++++ .../ListDeviceConfigVersionsResponse.java | 97 +++++ .../exception/DeviceNotFoundException.java | 61 +++ .../api/handler/DeviceCommandHandler.java | 59 +++ .../api/handler/DeviceConfigsHandler.java | 98 +++++ .../api/mapper/DeviceConfigMapper.java | 57 +++ .../repository/DeviceConfigsRepository.java | 52 +++ .../DeviceConfigsRepositoryImpl.java | 204 +++++++++ .../api/service/DatabaseSchemaCreator.java | 28 ++ .../service/DatabaseSchemaCreatorImpl.java | 84 ++++ .../api/service/DatabaseService.java | 36 ++ .../api/service/DatabaseServiceImpl.java | 62 +++ .../api/service/DeviceCommandService.java | 32 ++ .../api/service/DeviceCommandServiceImpl.java | 40 ++ .../api/service/DeviceConfigService.java | 49 +++ .../api/service/DeviceConfigServiceImpl.java | 84 ++++ .../VertxHttpHandlerManagerService.java | 52 +++ .../core/app/AbstractServiceApplication.java | 172 ++++++++ .../core/app/ApplicationConfig.java | 65 +++ .../core/app/DatabaseConfig.java | 85 ++++ .../communication/core/app/ServerConfig.java | 71 ++++ .../core/http/AbstractVertxHttpServer.java | 43 ++ .../core/http/HttpEndpointHandler.java | 32 ++ .../communication/core/http/HttpServer.java | 34 ++ .../communication/core/utils/DbUtils.java | 74 ++++ .../core/utils/ResponseUtils.java | 119 ++++++ .../api/hono-device-communication-v1.yaml | 215 ++++++++++ .../src/main/resources/api/hono-endpoint.yaml | 205 +++++++++ .../src/main/resources/application.yaml | 36 ++ .../db/create_device_config_table.sql | 28 ++ .../resources/db/migration/V1.0__create.sql | 11 + .../communication/api/ApplicationTest.java | 63 +++ .../DeviceCommunicationHttpServerTest.java | 396 ++++++++++++++++++ .../handler/DeviceCommandsHandlerTest.java | 90 ++++ .../api/handler/DeviceConfigsHandlerTest.java | 238 +++++++++++ .../DatabaseSchemaCreatorImplTest.java | 93 ++++ .../api/service/DatabaseServiceImplTest.java | 83 ++++ .../service/DeviceConfigServiceImplTest.java | 127 ++++++ .../src/test/resources/application.yaml | 16 + 61 files changed, 5540 insertions(+), 2 deletions(-) create mode 100644 device-communication/.dockerignore create mode 100644 device-communication/.gitignore create mode 100644 device-communication/.mvn/wrapper/.gitignore create mode 100644 device-communication/.mvn/wrapper/MavenWrapperDownloader.java create mode 100644 device-communication/.mvn/wrapper/maven-wrapper.properties create mode 100644 device-communication/README.md create mode 100644 device-communication/img.png create mode 100644 device-communication/mvnw create mode 100644 device-communication/mvnw.cmd create mode 100644 device-communication/pom.xml create mode 100644 device-communication/src/main/docker/Dockerfile.jvm create mode 100644 device-communication/src/main/docker/Dockerfile.legacy-jar create mode 100644 device-communication/src/main/docker/Dockerfile.native create mode 100644 device-communication/src/main/docker/Dockerfile.native-micro create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/Application.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/DeviceCommunicationHttpServer.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/config/DeviceCommandConstants.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/config/DeviceConfigsConstants.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceCommandRequest.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfig.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfigEntity.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfigRequest.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/data/ListDeviceConfigVersionsResponse.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/exception/DeviceNotFoundException.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/handler/DeviceCommandHandler.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/handler/DeviceConfigsHandler.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/mapper/DeviceConfigMapper.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/repository/DeviceConfigsRepository.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/repository/DeviceConfigsRepositoryImpl.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreator.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreatorImpl.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseService.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseServiceImpl.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceCommandService.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceCommandServiceImpl.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceConfigService.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceConfigServiceImpl.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/api/service/VertxHttpHandlerManagerService.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/app/AbstractServiceApplication.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/app/ApplicationConfig.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/app/DatabaseConfig.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/app/ServerConfig.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/http/AbstractVertxHttpServer.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/http/HttpEndpointHandler.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/http/HttpServer.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/utils/DbUtils.java create mode 100644 device-communication/src/main/java/org/eclipse/hono/communication/core/utils/ResponseUtils.java create mode 100644 device-communication/src/main/resources/api/hono-device-communication-v1.yaml create mode 100644 device-communication/src/main/resources/api/hono-endpoint.yaml create mode 100644 device-communication/src/main/resources/application.yaml create mode 100644 device-communication/src/main/resources/db/create_device_config_table.sql create mode 100644 device-communication/src/main/resources/db/migration/V1.0__create.sql create mode 100644 device-communication/src/test/java/org/eclipse/hono/communication/api/ApplicationTest.java create mode 100644 device-communication/src/test/java/org/eclipse/hono/communication/api/DeviceCommunicationHttpServerTest.java create mode 100644 device-communication/src/test/java/org/eclipse/hono/communication/api/handler/DeviceCommandsHandlerTest.java create mode 100644 device-communication/src/test/java/org/eclipse/hono/communication/api/handler/DeviceConfigsHandlerTest.java create mode 100644 device-communication/src/test/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreatorImplTest.java create mode 100644 device-communication/src/test/java/org/eclipse/hono/communication/api/service/DatabaseServiceImplTest.java create mode 100644 device-communication/src/test/java/org/eclipse/hono/communication/api/service/DeviceConfigServiceImplTest.java create mode 100644 device-communication/src/test/resources/application.yaml diff --git a/README.md b/README.md index 3a7347db..ed9bff77 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,14 @@ Eclipse Hono Extras provides additional resources for [Eclipse Hono™](http ## Hono Protocol Gateways -See the [protocol-gateway](protocol-gateway) directory for a template and an example implementation for a [Hono protocol gateway](https://www.eclipse.org/hono/docs/concepts/connecting-devices/#connecting-via-a-protocol-gateway). +See the [protocol-gateway](protocol-gateway) directory for a template and an example implementation for +a [Hono protocol gateway](https://www.eclipse.org/hono/docs/concepts/connecting-devices/#connecting-via-a-protocol-gateway). ## Device registry migration -See the [device-registry-migration](device-registry-migration) directory for example migration scripts from one device registry type to another. +See the [device-registry-migration](device-registry-migration) directory for example migration scripts from one device +registry type to another. + +## Device communication API + +See the [device-communication](device-communication) directory for api specifications and implementation. diff --git a/device-communication/.dockerignore b/device-communication/.dockerignore new file mode 100644 index 00000000..1e5989c0 --- /dev/null +++ b/device-communication/.dockerignore @@ -0,0 +1,6 @@ +* +!target/*-runner +!target/*-runner.jar +!target/lib/* +!target/quarkus-app/* +!src/main/resources/* \ No newline at end of file diff --git a/device-communication/.gitignore b/device-communication/.gitignore new file mode 100644 index 00000000..693002a0 --- /dev/null +++ b/device-communication/.gitignore @@ -0,0 +1,40 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env diff --git a/device-communication/.mvn/wrapper/.gitignore b/device-communication/.mvn/wrapper/.gitignore new file mode 100644 index 00000000..e72f5e8b --- /dev/null +++ b/device-communication/.mvn/wrapper/.gitignore @@ -0,0 +1 @@ +maven-wrapper.jar diff --git a/device-communication/.mvn/wrapper/MavenWrapperDownloader.java b/device-communication/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 00000000..05212d56 --- /dev/null +++ b/device-communication/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + private static final String WRAPPER_VERSION = "3.1.1"; + + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = + "https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/" + WRAPPER_VERSION + + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to use instead of the + * default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if (mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if (mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if (!outputFile.getParentFile().exists()) { + if (!outputFile.getParentFile().mkdirs()) { + System.out.println("- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) + throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/device-communication/.mvn/wrapper/maven-wrapper.properties b/device-communication/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..61a2ef15 --- /dev/null +++ b/device-communication/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar diff --git a/device-communication/README.md b/device-communication/README.md new file mode 100644 index 00000000..507b14ab --- /dev/null +++ b/device-communication/README.md @@ -0,0 +1,104 @@ +# Device Communication API + +Device communication API enables users and applications to send configurations and commands to devices via HTTP +endpoints. + +![img.png](img.png) + +### Application + +The application is reactive and uses Quarkus Framework for the application and Vertx tools for the HTTP server. + +### Hono internal communication + +API uses [Google's PubSub](https://cloud.google.com/pubsub/docs/overview?hl=de) service to communicate with the command +router. + +## API endpoints + +#### commands/{tenantId}/{deviceId} + +- POST : post a command for a specific device (NOT IMPLEMENTED YET) + +

+ +#### configs/{tenantId}/{deviceId}?numVersion=(int 0 - 10) + +- GET : list of device config versions + +- POST: create a device config version + +For more information please see resources/api/openApi file. + +## Database + +Application uses PostgresSQL database. All the database configurations can be found in application.yaml file. + +### Tables + +- DeviceConfig
+ Is used for saving device config versions +- DeviceRegistration
+ Is used for validating if a device exist + +### Migrations + +When Applications starts tables will be created by the DatabaseSchemaCreator service. + +### Running postgresSQL container local + +For running the PostgresSQL Database local with docker run: + +`````` + +docker run -p 5432:5432 --name some-postgres -e POSTGRES_PASSWORD=mysecretpassword -d postgres + +`````` + +After the container is running, log in to the container and with psql create the database. Then we have +to set the application settings. + +Default postgresSQl values: + +- userName = postgres +- password = mysecretpassword + +## Build and Push API Docker Image + +Mavens auto build and push functionality ca be enabled from application.yaml settings: + +```` + +quarkus: + container-image: + builder: docker + build: true + push: true + image: "gcr.io/sotec-iot-core-dev/hono-device-communication" + +```` + +By running maven package, install or deploy will automatically build the docker image and if push is enabled it will +push the image +to the given registry. + +## OpenApi Contract-first + +For creating the endpoints, Vertx takes the openApi definition file and maps every endpoint operation-ID with a specific +Handler +function. + +## Handlers + +Handlers are providing callBack functions for every endpoint. Functions are going to be called automatically from vertx +server every time a request is received. + +## Adding a new Endpoint + +Adding new Endpoint steps: + +1. Add Endpoint in openApi file and set an operationId +2. Use an existing const Class or create a new one under /config and set the operation id name +3. Implement an HttpEndpointHandler and set the Routes + + diff --git a/device-communication/img.png b/device-communication/img.png new file mode 100644 index 0000000000000000000000000000000000000000..aff6c99d8d5427eadb35a33d4cfb8db19f1dd16d GIT binary patch literal 110144 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%0A+#6>AyKUE%W_<# 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

CPgYW4pq~ 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 \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" + diff --git a/device-communication/mvnw.cmd b/device-communication/mvnw.cmd new file mode 100644 index 00000000..abb7c324 --- /dev/null +++ b/device-communication/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/device-communication/pom.xml b/device-communication/pom.xml new file mode 100644 index 00000000..16a757ab --- /dev/null +++ b/device-communication/pom.xml @@ -0,0 +1,227 @@ + + + 4.0.0 + org.eclipse.hono + device-communication + 1.0-SNAPSHOT + + 3.10.1 + 17 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 2.15.3.Final + true + 3.0.0-M7 + 1.5.3.Final + 2.1.0 + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-ide-launcher + + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-vertx + + + io.vertx + vertx-core + + + io.vertx + vertx-web + + + io.vertx + vertx-web-openapi + + + io.vertx + vertx-web-validation + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + io.quarkus + quarkus-config-yaml + + + org.mockito + mockito-inline + test + + + + io.vertx + vertx-pg-client + 4.3.7 + + + jakarta.persistence + jakarta.persistence-api + 2.2.3 + + + com.ongres.scram + client + 2.1 + + + + io.vertx + vertx-sql-client-templates + + + + org.mapstruct + mapstruct + ${org.mapstruct.version} + + + + + io.quarkus + quarkus-container-image-docker + 2.16.0.Final + + + io.vertx + vertx-health-check + + + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + + io.quarkus + quarkus-extension-processor + ${quarkus.platform.version} + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + + org.eclipse.hono + hono-legal + ${hono.version} + + + com.puppycrawl.tools + checkstyle + 10.2 + + + + + checkstyle-check + verify + + check + + + + + checkstyle/default.xml + checkstyle/suppressions.xml + true + + ${project.build.sourceDirectory} + ${project.build.testSourceDirectory} + + + + + + + + native + + + native + + + + false + native + + + + diff --git a/device-communication/src/main/docker/Dockerfile.jvm b/device-communication/src/main/docker/Dockerfile.jvm new file mode 100644 index 00000000..c5a55161 --- /dev/null +++ b/device-communication/src/main/docker/Dockerfile.jvm @@ -0,0 +1,93 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./mvnw package +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/device-communication-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/device-communication-jvm +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5005 +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/device-communication-jvm +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM registry.access.redhat.com/ubi8/openjdk-17:1.14 + +ENV LANGUAGE='en_US:en' + + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + diff --git a/device-communication/src/main/docker/Dockerfile.legacy-jar b/device-communication/src/main/docker/Dockerfile.legacy-jar new file mode 100644 index 00000000..108988bd --- /dev/null +++ b/device-communication/src/main/docker/Dockerfile.legacy-jar @@ -0,0 +1,93 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./mvnw package -Dquarkus.package.type=legacy-jar +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/device-communication-legacy-jar . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/device-communication-legacy-jar +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5005 +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/device-communication-legacy-jar +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM registry.access.redhat.com/ubi8/openjdk-17:1.14-10 + +ENV LANGUAGE='en_US:en' + +RUN mkdir api/ +RUN mkdir db/ + +COPY target/classes/api/* /api/ +COPY target/classes/db/* /db/ +COPY target/lib/* /deployments/lib/ +COPY target/*-runner.jar /deployments/quarkus-run.jar + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" diff --git a/device-communication/src/main/docker/Dockerfile.native b/device-communication/src/main/docker/Dockerfile.native new file mode 100644 index 00000000..9dd59a05 --- /dev/null +++ b/device-communication/src/main/docker/Dockerfile.native @@ -0,0 +1,27 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# +# Before building the container image run: +# +# ./mvnw package -Pnative +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t quarkus/device-communication . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/device-communication +# +### +FROM registry.access.redhat.com/ubi8/ubi-minimal:8.6 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root target/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/device-communication/src/main/docker/Dockerfile.native-micro b/device-communication/src/main/docker/Dockerfile.native-micro new file mode 100644 index 00000000..44595bbb --- /dev/null +++ b/device-communication/src/main/docker/Dockerfile.native-micro @@ -0,0 +1,30 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# It uses a micro base image, tuned for Quarkus native executables. +# It reduces the size of the resulting container image. +# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image. +# +# Before building the container image run: +# +# ./mvnw package -Pnative +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/device-communication . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/device-communication +# +### +FROM quay.io/quarkus/quarkus-micro-image:1.0 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root target/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/Application.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/Application.java new file mode 100644 index 00000000..5be95fd9 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/Application.java @@ -0,0 +1,62 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api; + +import org.eclipse.hono.communication.core.app.AbstractServiceApplication; +import org.eclipse.hono.communication.core.app.ApplicationConfig; +import org.eclipse.hono.communication.core.http.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.vertx.core.Vertx; + +/** + * Device Communication application. + */ +public class Application extends AbstractServiceApplication { + + private final Logger log = LoggerFactory.getLogger(AbstractServiceApplication.class); + private final HttpServer server; + + /** + * Creates new Application with all dependencies. + * + * @param vertx The quarkus Vertx instance + * @param appConfigs The application configs + * @param server The http server + */ + public Application(final Vertx vertx, + final ApplicationConfig appConfigs, + final HttpServer server) { + super(vertx, appConfigs); + this.server = server; + } + + @Override + public void doStart() { + log.info("Starting HTTP server..."); + server.start(); + } + + @Override + public void doStop() { + log.info("Stopping HTTP server..."); + server.stop(); + } + + +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/DeviceCommunicationHttpServer.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/DeviceCommunicationHttpServer.java new file mode 100644 index 00000000..5cc13001 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/DeviceCommunicationHttpServer.java @@ -0,0 +1,239 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import javax.inject.Singleton; + +import org.eclipse.hono.communication.api.service.DatabaseSchemaCreator; +import org.eclipse.hono.communication.api.service.DatabaseService; +import org.eclipse.hono.communication.api.service.VertxHttpHandlerManagerService; +import org.eclipse.hono.communication.core.app.ApplicationConfig; +import org.eclipse.hono.communication.core.app.ServerConfig; +import org.eclipse.hono.communication.core.http.AbstractVertxHttpServer; +import org.eclipse.hono.communication.core.http.HttpEndpointHandler; +import org.eclipse.hono.communication.core.http.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.netty.handler.codec.http.HttpHeaderNames; +import io.quarkus.runtime.Quarkus; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.healthchecks.HealthCheckHandler; +import io.vertx.ext.healthchecks.Status; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.openapi.RouterBuilder; +import io.vertx.ext.web.validation.BadRequestException; + + +/** + * Vertx HTTP Server for the device communication api. + */ +@Singleton +public class DeviceCommunicationHttpServer extends AbstractVertxHttpServer implements HttpServer { + private final Logger log = LoggerFactory.getLogger(DeviceCommunicationHttpServer.class); + private final String serverStartedMsg = "HTTP Server is listening at http://{}:{}"; + private final String serverFailedMsg = "HTTP Server failed to start: {}"; + private final VertxHttpHandlerManagerService httpHandlerManager; + + private final DatabaseService db; + private final DatabaseSchemaCreator databaseSchemaCreator; + private List httpEndpointHandlers; + + + /** + * Creates a new DeviceCommunicationHttpServer with all dependencies. + * + * @param appConfigs THe application configurations + * @param vertx The quarkus Vertx instance + * @param httpHandlerManager The http handler manager + * @param databaseService The database connection + * @param databaseSchemaCreator The database migrations service + */ + public DeviceCommunicationHttpServer(final ApplicationConfig appConfigs, + final Vertx vertx, + final VertxHttpHandlerManagerService httpHandlerManager, + final DatabaseService databaseService, + final DatabaseSchemaCreator databaseSchemaCreator) { + super(appConfigs, vertx); + this.httpHandlerManager = httpHandlerManager; + this.databaseSchemaCreator = databaseSchemaCreator; + this.httpEndpointHandlers = new ArrayList<>(); + this.db = databaseService; + } + + + @Override + public void start() { + //Create Database Tables + databaseSchemaCreator.createDBTables(); + + // Create Endpoints Router + this.httpEndpointHandlers = httpHandlerManager.getAvailableHandlerServices(); + RouterBuilder.create(this.vertx, appConfigs.getServerConfig().getOpenApiFilePath()) + .onSuccess(routerBuilder -> { + final Router apiRouter = this.createRouterWithEndpoints(routerBuilder, httpEndpointHandlers); + this.startVertxServer(apiRouter); + }) + .onFailure(error -> { + if (error != null) { + log.error("Can not create Router {}", error.getMessage()); + } else { + log.error("Can not create Router"); + } + stop(); + Quarkus.asyncExit(-1); + + }); + + // Wait until application is stopped + Quarkus.waitForExit(); + + } + + /** + * Creates the Router object and adds endpoints and handlers. + * + * @param routerBuilder Vertx RouterBuilder object + * @param httpEndpointHandlers All available http endpoint handlers + * @return The created Router object + */ + Router createRouterWithEndpoints(final RouterBuilder routerBuilder, final List httpEndpointHandlers) { + for (HttpEndpointHandler handlerService : httpEndpointHandlers) { + handlerService.addRoutes(routerBuilder); + } + final var apiRouter = Router.router(vertx); + final var router = routerBuilder.createRouter(); + + final var serverConfig = appConfigs.getServerConfig(); + addHealthCheckHandlers(apiRouter, serverConfig); + final var basePath = String.format("%s*", serverConfig.getBasePath()); // absolut path not allowed only /* + + log.info("API base path: {}", basePath); + + apiRouter.route(basePath).subRouter(router); + return apiRouter; + } + + /** + * Adds readiness and liveness handlers. + * + * @param router Created router object + */ + private void addHealthCheckHandlers(final Router router, final ServerConfig serverConfig) { + addReadinessHandlers(router, serverConfig.getReadinessPath()); + addLivenessHandlers(router, serverConfig.getLivenessPath()); + } + + private void addReadinessHandlers(final Router router, final String readinessPath) { + log.info("Adding readiness path: {}", readinessPath); + final HealthCheckHandler healthCheckHandler = HealthCheckHandler.create(vertx); + + healthCheckHandler.register("database-communication-is-ready", + promise -> + db.getDbClient().getConnection(connection -> { + if (connection.failed()) { + log.error(connection.cause().getMessage()); + promise.tryComplete(Status.KO()); + } else { + connection.result().close(); + promise.tryComplete(Status.OK()); + } + }) + ); + + router.get(readinessPath).handler(healthCheckHandler); + } + + + private void addLivenessHandlers(final Router router, final String livenessPath) { + log.info("Adding liveness path: {}", livenessPath); + final HealthCheckHandler healthCheckHandler = HealthCheckHandler.create(vertx); + healthCheckHandler.register("liveness", promise -> promise.tryComplete(Status.OK())); + router.get(livenessPath).handler(healthCheckHandler); + } + + /** + * Starts the server and blocks until application is stopped. + * + * @param router The Router object + */ + void startVertxServer(final Router router) { + final var serverConfigs = appConfigs.getServerConfig(); + final var serverOptions = new HttpServerOptions() + .setPort(serverConfigs.getServerPort()) + .setHost(serverConfigs.getServerUrl()); + + final var serverCreationFuture = vertx + .createHttpServer(serverOptions) + .requestHandler(router) + .listen(); + + serverCreationFuture + .onSuccess(server -> log.info(this.serverStartedMsg, serverConfigs.getServerUrl() + , serverConfigs.getServerPort())) + .onFailure(error -> log.info(this.serverFailedMsg, error.getMessage())); + } + + /** + * Adds status code 400 and sets the error message for the routingContext response. + * + * @param routingContext the routing context object + * @throws NullPointerException – if routingContext is {@code null}. + */ + void addDefault400ExceptionHandler(final RoutingContext routingContext) { + Objects.requireNonNull(routingContext); + final String errorMsg = ((BadRequestException) routingContext.failure()).toJson().toString(); + log.error(errorMsg); + routingContext.response().setStatusCode(400).end(errorMsg); + } + + /** + * Adds status code 404 and sets the error message for the routingContext response. + * + * @param routingContext the routing context object + * @throws NullPointerException – if routingContext is {@code null}. + */ + void addDefault404ExceptionHandler(final RoutingContext routingContext) { + Objects.requireNonNull(routingContext); + if (!routingContext.response().ended()) { + routingContext.response().setStatusCode(404); + + if (!routingContext.request().method().equals(HttpMethod.HEAD)) { + routingContext.response() + .putHeader(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=utf-8") + .end("

Resource not found

"); + } else { + routingContext.response().end(); + } + } + } + + + @Override + public void stop() { + // stop server custom functionality + db.close(); + + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/config/DeviceCommandConstants.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/config/DeviceCommandConstants.java new file mode 100644 index 00000000..ec3778f2 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/config/DeviceCommandConstants.java @@ -0,0 +1,32 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.config; + +/** + * Device commands constant values. + */ +public final class DeviceCommandConstants { + + /** + * OpenApi POST device command operation id. + */ + public static final String POST_DEVICE_COMMAND_OP_ID = "postCommand"; + + private DeviceCommandConstants() { + // avoid instantiation + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/config/DeviceConfigsConstants.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/config/DeviceConfigsConstants.java new file mode 100644 index 00000000..86ff6ed2 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/config/DeviceConfigsConstants.java @@ -0,0 +1,53 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.config; + +/** + * Device configs constant values. + */ +public final class DeviceConfigsConstants { + + /** + * OpenApi GET device configs operation id. + */ + public static final String LIST_CONFIG_VERSIONS_OP_ID = "listConfigVersions"; + /** + * Path parameter name for tenantId. + */ + public static final String TENANT_PATH_PARAMS = "tenantid"; + /** + * Path parameter name for deviceId. + */ + public static final String DEVICE_PATH_PARAMS = "deviceid"; + /** + * Path parameter name for number of versions. + */ + public static final String NUM_VERSION_QUERY_PARAMS = "numVersions"; + + /** + * Sql migrations script path. + */ + public static final String CREATE_SQL_SCRIPT_PATH = "db/create_device_config_table.sql"; + /** + * OpenApi POST device configs operation id. + */ + public static final String POST_MODIFY_DEVICE_CONFIG_OP_ID = "modifyCloudToDeviceConfig"; + + private DeviceConfigsConstants() { + // avoid instantiation + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceCommandRequest.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceCommandRequest.java new file mode 100644 index 00000000..91d7874e --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceCommandRequest.java @@ -0,0 +1,111 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ +package org.eclipse.hono.communication.api.data; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + + + + +/** + * Command json object structure. + **/ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DeviceCommandRequest { + + private String binaryData; + private String subfolder; + + /** + * Creates a new DeviceCommandRequest. + */ + public DeviceCommandRequest() { + + } + + /** + * Creates a new DeviceCommandRequest. + * + * @param binaryData Binary data + * @param subfolder THe subfolder + */ + public DeviceCommandRequest(final String binaryData, final String subfolder) { + this.binaryData = binaryData; + this.subfolder = subfolder; + } + + + @JsonProperty("binaryData") + public String getBinaryData() { + return binaryData; + } + + public void setBinaryData(final String binaryData) { + this.binaryData = binaryData; + } + + + @JsonProperty("subfolder") + public String getSubfolder() { + return subfolder; + } + + public void setSubfolder(final String subfolder) { + this.subfolder = subfolder; + } + + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final DeviceCommandRequest deviceCommandRequest = (DeviceCommandRequest) o; + return Objects.equals(binaryData, deviceCommandRequest.binaryData) && + Objects.equals(subfolder, deviceCommandRequest.subfolder); + } + + @Override + public int hashCode() { + return Objects.hash(binaryData, subfolder); + } + + @Override + public String toString() { + + return "class DeviceCommandRequest {\n" + + " binaryData: " + toIndentedString(binaryData) + "\n" + + " subfolder: " + toIndentedString(subfolder) + "\n" + + "}"; + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(final Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfig.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfig.java new file mode 100644 index 00000000..47425745 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfig.java @@ -0,0 +1,145 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.data; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + + + +/** + * The device configuration. + **/ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DeviceConfig { + + private String version; + private String cloudUpdateTime; + private String deviceAckTime; + private String binaryData; + + /** + * Creates a new device config. + */ + public DeviceConfig() { + + } + + /** + * Creates a new device config. + * + * @param version Config version + * @param cloudUpdateTime Cloud update time + * @param deviceAckTime Device ack time + * @param binaryData Binary data + */ + public DeviceConfig(final String version, final String cloudUpdateTime, final String deviceAckTime, final String binaryData) { + this.version = version; + this.cloudUpdateTime = cloudUpdateTime; + this.deviceAckTime = deviceAckTime; + this.binaryData = binaryData; + } + + + @JsonProperty("version") + public String getVersion() { + return version; + } + + @JsonProperty("version") + public void setVersion(final String version) { + this.version = version; + } + + + @JsonProperty("cloudUpdateTime") + public String getCloudUpdateTime() { + return cloudUpdateTime; + } + + @JsonProperty("cloud_update_time") + public void setCloudUpdateTime(final String cloudUpdateTime) { + this.cloudUpdateTime = cloudUpdateTime; + } + + + @JsonProperty("deviceAckTime") + public String getDeviceAckTime() { + return deviceAckTime; + } + + @JsonProperty("device_ack_time") + public void setDeviceAckTime(final String deviceAckTime) { + this.deviceAckTime = deviceAckTime; + } + + + @JsonProperty("binaryData") + public String getBinaryData() { + return binaryData; + } + + @JsonProperty("binary_data") + public void setBinaryData(final String binaryData) { + this.binaryData = binaryData; + } + + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final DeviceConfig deviceConfig = (DeviceConfig) o; + return Objects.equals(version, deviceConfig.version) && + Objects.equals(cloudUpdateTime, deviceConfig.cloudUpdateTime) && + Objects.equals(deviceAckTime, deviceConfig.deviceAckTime) && + Objects.equals(binaryData, deviceConfig.binaryData); + } + + @Override + public int hashCode() { + return Objects.hash(version, cloudUpdateTime, deviceAckTime, binaryData); + } + + @Override + public String toString() { + + return "class DeviceConfig {\n" + + " version: " + toIndentedString(version) + "\n" + + " cloudUpdateTime: " + toIndentedString(cloudUpdateTime) + "\n" + + " deviceAckTime: " + toIndentedString(deviceAckTime) + "\n" + + " binaryData: " + toIndentedString(binaryData) + "\n" + + "}"; + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(final Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfigEntity.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfigEntity.java new file mode 100644 index 00000000..186ccc44 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfigEntity.java @@ -0,0 +1,123 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.data; + +import java.util.Objects; + +/** + * The device configuration entity object. + **/ +public class DeviceConfigEntity { + + + private int version; + private String tenantId; + private String deviceId; + + private String cloudUpdateTime; + private String deviceAckTime; + private String binaryData; + + + /** + * Creates new DeviceConfigEntity. + */ + public DeviceConfigEntity() { + } + + + public int getVersion() { + return version; + } + + public void setVersion(final int version) { + this.version = version; + } + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(final String tenantId) { + this.tenantId = tenantId; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(final String deviceId) { + this.deviceId = deviceId; + } + + + public String getCloudUpdateTime() { + return cloudUpdateTime; + } + + public void setCloudUpdateTime(final String cloudUpdateTime) { + this.cloudUpdateTime = cloudUpdateTime; + } + + public String getDeviceAckTime() { + return deviceAckTime; + } + + public void setDeviceAckTime(final String deviceAckTime) { + this.deviceAckTime = deviceAckTime; + } + + public String getBinaryData() { + return binaryData; + } + + public void setBinaryData(final String binaryData) { + this.binaryData = binaryData; + } + + + @Override + public String toString() { + return "DeviceConfigEntity{" + + "version=" + version + + ", tenantId='" + tenantId + '\'' + + ", deviceId='" + deviceId + '\'' + + ", cloudUpdateTime='" + cloudUpdateTime + '\'' + + ", deviceAckTime='" + deviceAckTime + '\'' + + ", binaryData='" + binaryData + '\'' + + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final var that = (DeviceConfigEntity) o; + return version == that.version && tenantId.equals(that.tenantId) && deviceId.equals(that.deviceId) && cloudUpdateTime.equals(that.cloudUpdateTime) && Objects.equals(deviceAckTime, that.deviceAckTime) && binaryData.equals(that.binaryData); + } + + @Override + public int hashCode() { + return Objects.hash(version, tenantId, deviceId, cloudUpdateTime, deviceAckTime, binaryData); + } + + +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfigRequest.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfigRequest.java new file mode 100644 index 00000000..fd33dbdb --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/DeviceConfigRequest.java @@ -0,0 +1,111 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.data; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + + + +/** + * Request body for modifying device configs. + **/ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DeviceConfigRequest { + + private String versionToUpdate; + private String binaryData; + + /** + * Creates a new DeviceConfigRequest. + */ + public DeviceConfigRequest() { + + } + + /** + * Creates a new DeviceConfigRequest. + * + * @param versionToUpdate Version to update + * @param binaryData The binary data + */ + public DeviceConfigRequest(final String versionToUpdate, final String binaryData) { + this.versionToUpdate = versionToUpdate; + this.binaryData = binaryData; + } + + + @JsonProperty("versionToUpdate") + public String getVersionToUpdate() { + return versionToUpdate; + } + + public void setVersionToUpdate(final String versionToUpdate) { + this.versionToUpdate = versionToUpdate; + } + + + @JsonProperty("binaryData") + public String getBinaryData() { + return binaryData; + } + + public void setBinaryData(final String binaryData) { + this.binaryData = binaryData; + } + + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final var deviceConfigRequest = (DeviceConfigRequest) o; + return Objects.equals(versionToUpdate, deviceConfigRequest.versionToUpdate) && + Objects.equals(binaryData, deviceConfigRequest.binaryData); + } + + @Override + public int hashCode() { + return Objects.hash(versionToUpdate, binaryData); + } + + @Override + public String toString() { + + return "class DeviceConfigRequest {\n" + + " versionToUpdate: " + toIndentedString(versionToUpdate) + "\n" + + " binaryData: " + toIndentedString(binaryData) + "\n" + + "}"; + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(final Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/data/ListDeviceConfigVersionsResponse.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/ListDeviceConfigVersionsResponse.java new file mode 100644 index 00000000..9e0894cf --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/data/ListDeviceConfigVersionsResponse.java @@ -0,0 +1,97 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ +package org.eclipse.hono.communication.api.data; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + + + +/** + * A list of a device config versions. + **/ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ListDeviceConfigVersionsResponse { + + private List deviceConfigs = new ArrayList<>(); + + /** + * Creates a new ListDeviceConfigVersionsResponse. + */ + public ListDeviceConfigVersionsResponse() { + + } + + /** + * Creates a new ListDeviceConfigVersionsResponse. + * + * @param deviceConfigs The device configs + */ + public ListDeviceConfigVersionsResponse(final List deviceConfigs) { + this.deviceConfigs = deviceConfigs; + } + + + @JsonProperty("deviceConfigs") + public List getDeviceConfigs() { + return deviceConfigs; + } + + public void setDeviceConfigs(final List deviceConfigs) { + this.deviceConfigs = deviceConfigs; + } + + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final var listDeviceConfigVersionsResponse = (ListDeviceConfigVersionsResponse) o; + return Objects.equals(deviceConfigs, listDeviceConfigVersionsResponse.deviceConfigs); + } + + @Override + public int hashCode() { + return Objects.hash(deviceConfigs); + } + + @Override + public String toString() { + + return "class ListDeviceConfigVersionsResponse {\n" + + " deviceConfigs: " + toIndentedString(deviceConfigs) + "\n" + + "}"; + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(final Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/exception/DeviceNotFoundException.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/exception/DeviceNotFoundException.java new file mode 100644 index 00000000..7c83515f --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/exception/DeviceNotFoundException.java @@ -0,0 +1,61 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.exception; + +import java.util.NoSuchElementException; + +/** + * Device Not found exception code: 404. + */ +public class +DeviceNotFoundException extends NoSuchElementException { + + /** + * Creates a new DeviceNotFoundException. + */ + public DeviceNotFoundException() { + } + + /** + * Creates a new DeviceNotFoundException. + * + * @param msg String message + * @param cause Throwable + */ + public DeviceNotFoundException(final String msg, final Throwable cause) { + super(msg, cause); + } + + /** + * Creates a new DeviceNotFoundException. + * + * @param cause Throwable + */ + public DeviceNotFoundException(final Throwable cause) { + super(cause); + } + + + /** + * Creates a new DeviceNotFoundException. + * + * @param msg String message + */ + public DeviceNotFoundException(final String msg) { + super(msg); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/handler/DeviceCommandHandler.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/handler/DeviceCommandHandler.java new file mode 100644 index 00000000..e95fc0aa --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/handler/DeviceCommandHandler.java @@ -0,0 +1,59 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.handler; + +import javax.enterprise.context.ApplicationScoped; + +import org.eclipse.hono.communication.api.config.DeviceCommandConstants; +import org.eclipse.hono.communication.api.service.DeviceCommandService; +import org.eclipse.hono.communication.core.http.HttpEndpointHandler; + +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.openapi.RouterBuilder; + +/** + * Handler for device command endpoints. + */ +@ApplicationScoped +public class DeviceCommandHandler implements HttpEndpointHandler { + + private final DeviceCommandService commandService; + + /** + * Creates a new DeviceCommandHandler. + * + * @param commandService The device command service + */ + public DeviceCommandHandler(final DeviceCommandService commandService) { + this.commandService = commandService; + } + + @Override + public void addRoutes(final RouterBuilder routerBuilder) { + routerBuilder.operation(DeviceCommandConstants.POST_DEVICE_COMMAND_OP_ID) + .handler(this::handlePostCommand); + } + + /** + * Handle post device commands. + * + * @param routingContext The RoutingContext + */ + public void handlePostCommand(final RoutingContext routingContext) { + commandService.postCommand(routingContext); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/handler/DeviceConfigsHandler.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/handler/DeviceConfigsHandler.java new file mode 100644 index 00000000..a1649e23 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/handler/DeviceConfigsHandler.java @@ -0,0 +1,98 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.handler; + +import javax.enterprise.context.ApplicationScoped; + +import org.eclipse.hono.communication.api.config.DeviceConfigsConstants; +import org.eclipse.hono.communication.api.data.DeviceConfig; +import org.eclipse.hono.communication.api.data.DeviceConfigRequest; +import org.eclipse.hono.communication.api.data.ListDeviceConfigVersionsResponse; +import org.eclipse.hono.communication.api.service.DeviceConfigService; +import org.eclipse.hono.communication.core.http.HttpEndpointHandler; +import org.eclipse.hono.communication.core.utils.ResponseUtils; + +import io.vertx.core.Future; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.openapi.RouterBuilder; + + + + +/** + * Handler for device config endpoints. + */ +@ApplicationScoped +public class DeviceConfigsHandler implements HttpEndpointHandler { + + private final DeviceConfigService configService; + + /** + * Creates a new DeviceConfigsHandler. + * + * @param configService The device configs + */ + public DeviceConfigsHandler(final DeviceConfigService configService) { + this.configService = configService; + } + + + @Override + public void addRoutes(final RouterBuilder routerBuilder) { + routerBuilder.operation(DeviceConfigsConstants.LIST_CONFIG_VERSIONS_OP_ID) + .handler(this::handleListConfigVersions); + routerBuilder.operation(DeviceConfigsConstants.POST_MODIFY_DEVICE_CONFIG_OP_ID) + .handler(this::handleModifyCloudToDeviceConfig); + } + + /** + * Handles post device configs. + * + * @param routingContext The RoutingContext + * @return Future of DeviceConfig + */ + public Future handleModifyCloudToDeviceConfig(final RoutingContext routingContext) { + final var tenantId = routingContext.pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS); + final var deviceId = routingContext.pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS); + + final DeviceConfigRequest deviceConfig = routingContext.body() + .asJsonObject() + .mapTo(DeviceConfigRequest.class); + + return configService.modifyCloudToDeviceConfig(deviceConfig, deviceId, tenantId) + .onSuccess(result -> ResponseUtils.successResponse(routingContext, result)) + .onFailure(err -> ResponseUtils.errorResponse(routingContext, err)); + } + + /** + * Handles get device configs. + * + * @param routingContext The RoutingContext + * @return Future of ListDeviceConfigVersionsResponse + */ + public Future handleListConfigVersions(final RoutingContext routingContext) { + final var numVersions = routingContext.queryParams().get(DeviceConfigsConstants.NUM_VERSION_QUERY_PARAMS); + + final var limit = numVersions == null ? 0 : Integer.parseInt(numVersions); + final var tenantId = routingContext.pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS); + final var deviceId = routingContext.pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS); + + return configService.listAll(deviceId, tenantId, limit) + .onSuccess(result -> ResponseUtils.successResponse(routingContext, result)) + .onFailure(err -> ResponseUtils.errorResponse(routingContext, err)); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/mapper/DeviceConfigMapper.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/mapper/DeviceConfigMapper.java new file mode 100644 index 00000000..5a7783f5 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/mapper/DeviceConfigMapper.java @@ -0,0 +1,57 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.mapper; + +import java.time.Instant; + +import org.eclipse.hono.communication.api.data.DeviceConfig; +import org.eclipse.hono.communication.api.data.DeviceConfigEntity; +import org.eclipse.hono.communication.api.data.DeviceConfigRequest; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.NullValuePropertyMappingStrategy; + +/** + * Mapper for device config objects. + */ +@Mapper(componentModel = "cdi", + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) +public interface DeviceConfigMapper { + + + /** + * Convert device config entity to device config. + * + * @param entity The device config entity + * @return The device config + */ + DeviceConfig deviceConfigEntityToConfig(DeviceConfigEntity entity); + + /** + * Convert device config request to device config entity. + * + * @param request The device config request + * @return The device config entity + */ + @Mapping(target = "version", source = "request.versionToUpdate") + @Mapping(target = "cloudUpdateTime", expression = "java(getDateTime())") + DeviceConfigEntity configRequestToDeviceConfigEntity(DeviceConfigRequest request); + + default String getDateTime() { + return Instant.now().toString(); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/repository/DeviceConfigsRepository.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/repository/DeviceConfigsRepository.java new file mode 100644 index 00000000..4e08c1b5 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/repository/DeviceConfigsRepository.java @@ -0,0 +1,52 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.repository; + +import java.util.List; + +import org.eclipse.hono.communication.api.data.DeviceConfig; +import org.eclipse.hono.communication.api.data.DeviceConfigEntity; + +import io.vertx.core.Future; +import io.vertx.sqlclient.SqlConnection; + +/** + * Device config repository interface. + */ +public interface DeviceConfigsRepository { + + /** + * Lists all config versions for a specific device. Result is order by version desc + * + * @param sqlConnection The sql connection instance + * @param deviceId The device id + * @param tenantId The tenant id + * @param limit The number of config to show + * @return A Future with a List of DeviceConfigs + */ + Future> listAll(SqlConnection sqlConnection, String deviceId, String tenantId, int limit); + + + /** + * Creates a new config version and deletes the oldest version if the total num of versions in DB is bigger than the MAX_LIMIT. + * + * @param sqlConnection The sql connection instance + * @param entity The instance to insert + * @return A Future of the created DeviceConfigEntity + */ + Future createNew(SqlConnection sqlConnection, DeviceConfigEntity entity); +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/repository/DeviceConfigsRepositoryImpl.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/repository/DeviceConfigsRepositoryImpl.java new file mode 100644 index 00000000..f2ace5b8 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/repository/DeviceConfigsRepositoryImpl.java @@ -0,0 +1,204 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.enterprise.context.ApplicationScoped; + +import org.eclipse.hono.communication.api.data.DeviceConfig; +import org.eclipse.hono.communication.api.data.DeviceConfigEntity; +import org.eclipse.hono.communication.api.exception.DeviceNotFoundException; +import org.eclipse.hono.communication.core.app.DatabaseConfig; +import org.graalvm.collections.Pair; + +import io.vertx.core.Future; +import io.vertx.core.impl.logging.Logger; +import io.vertx.core.impl.logging.LoggerFactory; +import io.vertx.sqlclient.RowIterator; +import io.vertx.sqlclient.SqlConnection; +import io.vertx.sqlclient.templates.RowMapper; +import io.vertx.sqlclient.templates.SqlTemplate; + +/** + * Repository class for making CRUD operations for device config entities. + */ +@ApplicationScoped +public class DeviceConfigsRepositoryImpl implements DeviceConfigsRepository { + private final String SQL_INSERT = "INSERT INTO device_configs (version, tenant_id, device_id, cloud_update_time, device_ack_time, binary_data) " + + "VALUES (#{version}, #{tenantId}, #{deviceId}, #{cloudUpdateTime}, #{deviceAckTime}, #{binaryData}) RETURNING version"; + private final String SQL_LIST = "SELECT version, cloud_update_time, device_ack_time, binary_data " + + "FROM device_configs WHERE device_id = #{deviceId} and tenant_id = #{tenantId} ORDER BY version DESC LIMIT #{limit}"; + private final String SQL_DELETE_MIN_VERSION = "DELETE FROM device_configs WHERE device_id = #{deviceId} and tenant_id = #{tenantId} " + + "and version = (SELECT MIN(version) from device_configs WHERE device_id = #{deviceId} and tenant_id = #{tenantId}) RETURNING version"; + private final String SQL_FIND_TOTAL_AND_MAX_VERSION = "SELECT COALESCE(COUNT(*), 0) as total, COALESCE(MAX(version), 0) as max_version from device_configs " + + "WHERE device_id = #{deviceId} and tenant_id = #{tenantId}"; + + private final int MAX_LIMIT = 10; + private final Logger log = LoggerFactory.getLogger(DeviceConfigsRepositoryImpl.class); + private String SQL_COUNT_DEVICES_WITH_PK_FILTER = "SELECT COUNT(*) as total FROM public.%s where %s = #{tenantId} and %s = #{deviceId}"; + + + /** + * Creates a new DeviceConfigsRepositoryImpl. + * + * @param databaseConfig The database configs + */ + public DeviceConfigsRepositoryImpl(final DatabaseConfig databaseConfig) { + + SQL_COUNT_DEVICES_WITH_PK_FILTER = String.format(SQL_COUNT_DEVICES_WITH_PK_FILTER, + databaseConfig.getDeviceRegistrationTableName(), + databaseConfig.getDeviceRegistrationTenantIdColumn(), + databaseConfig.getDeviceRegistrationDeviceIdColumn()); + } + + + private Future searchForDevice(final SqlConnection sqlConnection, final String deviceId, final String tenantId) { + final RowMapper ROW_MAPPER = row -> row.getInteger("total"); + return SqlTemplate + .forQuery(sqlConnection, SQL_COUNT_DEVICES_WITH_PK_FILTER) + .mapTo(ROW_MAPPER) + .execute(Map.of("deviceId", deviceId, "tenantId", tenantId)).map(rowSet -> { + final RowIterator iterator = rowSet.iterator(); + return iterator.next(); + }); + + } + + private Future> findMaxVersionAndTotalEntries(final SqlConnection sqlConnection, final String deviceId, final String tenantId) { + final RowMapper> ROW_MAPPER = row -> + Pair.create(row.getInteger("total"), row.getInteger("max_version")); + return SqlTemplate + .forQuery(sqlConnection, SQL_FIND_TOTAL_AND_MAX_VERSION) + .mapTo(ROW_MAPPER) + .execute(Map.of("deviceId", deviceId, "tenantId", tenantId)).map(rowSet -> { + final RowIterator> iterator = rowSet.iterator(); + return iterator.next(); + }); + + } + + @Override + public Future> listAll(final SqlConnection sqlConnection, final String deviceId, final String tenantId, final int limit) { + final int queryLimit = limit == 0 ? MAX_LIMIT : limit; + return searchForDevice(sqlConnection, deviceId, tenantId) + .compose( + counter -> { + if (counter < 1) { + throw new DeviceNotFoundException(String.format("Device with id %s and tenant id %s doesn't exist", + deviceId, + tenantId)); + } + return SqlTemplate + .forQuery(sqlConnection, SQL_LIST) + .mapTo(DeviceConfig.class) + .execute(Map.of("deviceId", deviceId, "tenantId", tenantId, "limit", queryLimit)) + .map(rowSet -> { + final List configs = new ArrayList<>(); + rowSet.forEach(configs::add); + return configs; + }) + .onSuccess(success -> log.info( + String.format("Listing all configs for device %s and tenant %s", + deviceId, tenantId))) + .onFailure(throwable -> log.error("Error: {}", throwable)); + }); + } + + + /** + * Inserts a new entity in to the db. + * + * @param sqlConnection The sql connection instance + * @param entity The instance to insert + * @return A Future of the created DeviceConfigEntity + */ + private Future insert(final SqlConnection sqlConnection, final DeviceConfigEntity entity) { + return SqlTemplate + .forUpdate(sqlConnection, SQL_INSERT) + .mapFrom(DeviceConfigEntity.class) + .mapTo(DeviceConfigEntity.class) + .execute(entity) + .map(rowSet -> { + final RowIterator iterator = rowSet.iterator(); + if (iterator.hasNext()) { + entity.setVersion(iterator.next().getVersion()); + return entity; + } else { + throw new IllegalStateException(String.format("Can't create device config: %s", entity)); + } + }) + .onSuccess(success -> log.info(String.format("Device config created successfully: %s", success.toString()))) + .onFailure(throwable -> log.error(throwable.getMessage())); + + } + + /** + * Delete the smallest config version. + * + * @param sqlConnection The sql connection instance + * @param entity The device config for searching and deleting the smallest version + * @return A Future of the deleted version + */ + + private Future deleteMinVersion(final SqlConnection sqlConnection, final DeviceConfigEntity entity) { + final RowMapper ROW_MAPPER = row -> row.getInteger("version"); + return SqlTemplate + .forQuery(sqlConnection, SQL_DELETE_MIN_VERSION) + .mapFrom(DeviceConfigEntity.class) + .mapTo(ROW_MAPPER) + .execute(entity) + .map(rowSet -> { + final RowIterator iterator = rowSet.iterator(); + return iterator.next(); + }) + .onSuccess(deletedVersion -> log.info(String.format("Device config version %s was deleted", deletedVersion))); + } + + + @Override + public Future createNew(final SqlConnection sqlConnection, final DeviceConfigEntity entity) { + return searchForDevice(sqlConnection, entity.getDeviceId(), entity.getTenantId()) + .compose( + counter -> { + if (counter < 1) { + throw new DeviceNotFoundException(String.format("Device with id %s and tenant id %s doesn't exist", + entity.getDeviceId(), + entity.getTenantId())); + } + return findMaxVersionAndTotalEntries(sqlConnection, entity.getDeviceId(), entity.getTenantId()) + .compose( + values -> { + final int total = values.getLeft(); + final int maxVersion = values.getRight(); + + entity.setVersion(maxVersion + 1); + + if (total > MAX_LIMIT - 1) { + return deleteMinVersion(sqlConnection, entity).compose( + ok -> insert(sqlConnection, entity) + + ); + } + return insert(sqlConnection, entity); + } + ); + }).onFailure(error -> log.error(error.getMessage())); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreator.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreator.java new file mode 100644 index 00000000..86702dd9 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreator.java @@ -0,0 +1,28 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +/** + * Interface for creating Database Tables at application startup. + */ +public interface DatabaseSchemaCreator { + + /** + * Create database tables. + */ + void createDBTables(); +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreatorImpl.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreatorImpl.java new file mode 100644 index 00000000..d890db0b --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreatorImpl.java @@ -0,0 +1,84 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import java.util.Map; + +import javax.enterprise.context.ApplicationScoped; + +import org.eclipse.hono.communication.api.config.DeviceConfigsConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.quarkus.runtime.Quarkus; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.sqlclient.templates.SqlTemplate; + +/** + * Creates all Database tables if they are not exist. + */ + +@ApplicationScoped +public class DatabaseSchemaCreatorImpl implements DatabaseSchemaCreator { + private static final Logger log = LoggerFactory.getLogger(DatabaseSchemaCreatorImpl.class); + private final Vertx vertx; + private final String tableCreationErrorMsg = "Table deviceConfig can not be created {}"; + private final String tableCreationSuccessMsg = "Successfully migrate Table: deviceConfig."; + private final DatabaseService db; + + + /** + * Creates a new DatabaseSchemaCreatorImpl. + * + * @param vertx The quarkus Vertx instance + * @param db The database service + */ + public DatabaseSchemaCreatorImpl(final Vertx vertx, final DatabaseService db) { + this.vertx = vertx; + this.db = db; + } + + @Override + public void createDBTables() { + createDeviceConfigTable(); + } + + + private void createDeviceConfigTable() { + log.info("Running database migration from file {}", DeviceConfigsConstants.CREATE_SQL_SCRIPT_PATH); + + final Promise loadScriptTracker = Promise.promise(); + vertx.fileSystem().readFile(DeviceConfigsConstants.CREATE_SQL_SCRIPT_PATH, loadScriptTracker); + db.getDbClient().withTransaction( + sqlConnection -> + loadScriptTracker.future() + .map(Buffer::toString) + .compose(script -> SqlTemplate + .forQuery(sqlConnection, script) + .execute(Map.of()))) + .onSuccess(ok -> log.info(tableCreationSuccessMsg)) + .onFailure(error -> { + log.error(tableCreationErrorMsg, error.getMessage()); + db.close(); + Quarkus.asyncExit(-1); + }); + + + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseService.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseService.java new file mode 100644 index 00000000..e8e5bd87 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseService.java @@ -0,0 +1,36 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import io.vertx.pgclient.PgPool; + +/** + * Database service interface. + */ +public interface DatabaseService { + /** + * Gets the database client instance. + * + * @return The database client + */ + PgPool getDbClient(); + + /** + * Closes the database connection. + */ + void close(); +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseServiceImpl.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseServiceImpl.java new file mode 100644 index 00000000..ab815ba2 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DatabaseServiceImpl.java @@ -0,0 +1,62 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import javax.enterprise.context.ApplicationScoped; + +import org.eclipse.hono.communication.core.app.DatabaseConfig; +import org.eclipse.hono.communication.core.utils.DbUtils; + +import io.vertx.core.Vertx; +import io.vertx.core.impl.logging.Logger; +import io.vertx.core.impl.logging.LoggerFactory; +import io.vertx.pgclient.PgPool; + +/** + * Service for database. + */ +@ApplicationScoped +public class DatabaseServiceImpl implements DatabaseService { + + private final Logger log = LoggerFactory.getLogger(DatabaseServiceImpl.class); + private final PgPool dbClient; + + /** + * Creates a new DatabaseServiceImpl. + * + * @param databaseConfigs The database configs + * @param vertx The quarkus Vertx instance + */ + public DatabaseServiceImpl(final DatabaseConfig databaseConfigs, final Vertx vertx) { + this.dbClient = DbUtils.createDbClient(vertx, databaseConfigs); + log.debug("Database connection is open."); + } + + @Override + public PgPool getDbClient() { + return dbClient; + } + + @Override + public void close() { + if (this.dbClient != null) { + this.dbClient.close(); + log.info("Database connection is closed."); + } + } + +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceCommandService.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceCommandService.java new file mode 100644 index 00000000..316d47b5 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceCommandService.java @@ -0,0 +1,32 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import io.vertx.ext.web.RoutingContext; + +/** + * Device commands interface. + */ +public interface DeviceCommandService { + + /** + * Post device command. + * + * @param routingContext The RoutingContext + */ + void postCommand(RoutingContext routingContext); +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceCommandServiceImpl.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceCommandServiceImpl.java new file mode 100644 index 00000000..a8d2709c --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceCommandServiceImpl.java @@ -0,0 +1,40 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import javax.enterprise.context.ApplicationScoped; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.vertx.ext.web.RoutingContext; + +/** + * Service for device commands. + */ +@ApplicationScoped +public class DeviceCommandServiceImpl implements DeviceCommandService { + private final Logger log = LoggerFactory.getLogger(DeviceCommandServiceImpl.class); + + + @Override + public void postCommand(final RoutingContext routingContext) { + // TODO publish command and send response + log.info("postCommand received"); + routingContext.response().setStatusCode(501).end(); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceConfigService.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceConfigService.java new file mode 100644 index 00000000..cf282858 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceConfigService.java @@ -0,0 +1,49 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + + +import org.eclipse.hono.communication.api.data.DeviceConfig; +import org.eclipse.hono.communication.api.data.DeviceConfigRequest; +import org.eclipse.hono.communication.api.data.ListDeviceConfigVersionsResponse; + +import io.vertx.core.Future; +/** + * Device config interface. + */ +public interface DeviceConfigService { + + /** + * Create a new device config and send it to the device. + * + * @param deviceConfig The device config + * @param deviceId The device id + * @param tenantId The tenant id + * @return Future of DeviceConfig + */ + Future modifyCloudToDeviceConfig(DeviceConfigRequest deviceConfig, String deviceId, String tenantId); + + /** + * Lists all the configuration for a specific device. + * + * @param deviceId Device Id + * @param tenantId Tenant Id + * @param limit Limit between 1 and 10 + * @return Future of ListDeviceConfigVersionsResponse + */ + Future listAll(String deviceId, String tenantId, int limit); +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceConfigServiceImpl.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceConfigServiceImpl.java new file mode 100644 index 00000000..a8abca20 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/DeviceConfigServiceImpl.java @@ -0,0 +1,84 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import javax.enterprise.context.ApplicationScoped; + +import org.eclipse.hono.communication.api.data.DeviceConfig; +import org.eclipse.hono.communication.api.data.DeviceConfigRequest; +import org.eclipse.hono.communication.api.data.ListDeviceConfigVersionsResponse; +import org.eclipse.hono.communication.api.mapper.DeviceConfigMapper; +import org.eclipse.hono.communication.api.repository.DeviceConfigsRepository; + +import io.vertx.core.Future; + +/** + * Service for device commands. + */ + +@ApplicationScoped +public class DeviceConfigServiceImpl implements DeviceConfigService { + + private final DeviceConfigsRepository repository; + private final DatabaseService db; + private final DeviceConfigMapper mapper; + + /** + * Creates a new DeviceConfigServiceImpl with all dependencies. + * + * @param repository The DeviceConfigsRepository + * @param db The database service + * @param mapper The DeviceConfigMapper + */ + public DeviceConfigServiceImpl(final DeviceConfigsRepository repository, + final DatabaseService db, + final DeviceConfigMapper mapper) { + + this.repository = repository; + this.db = db; + this.mapper = mapper; + } + + @Override + public Future modifyCloudToDeviceConfig(final DeviceConfigRequest deviceConfig, final String deviceId, final String tenantId) { + + final var entity = mapper.configRequestToDeviceConfigEntity(deviceConfig); + entity.setDeviceId(deviceId); + entity.setTenantId(tenantId); + + return db.getDbClient().withTransaction( + sqlConnection -> + repository.createNew(sqlConnection, entity)) + .map(mapper::deviceConfigEntityToConfig); + } + + + @Override + public Future listAll(final String deviceId, final String tenantId, final int limit) { + return db.getDbClient().withConnection( + sqlConnection -> repository.listAll(sqlConnection, deviceId, tenantId, limit) + .map( + result -> { + final var listConfig = new ListDeviceConfigVersionsResponse(); + listConfig.setDeviceConfigs(result); + return listConfig; + } + ) + ); + + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/api/service/VertxHttpHandlerManagerService.java b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/VertxHttpHandlerManagerService.java new file mode 100644 index 00000000..f7909951 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/api/service/VertxHttpHandlerManagerService.java @@ -0,0 +1,52 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import java.util.List; + +import javax.enterprise.context.ApplicationScoped; + +import org.eclipse.hono.communication.api.handler.DeviceCommandHandler; +import org.eclipse.hono.communication.api.handler.DeviceConfigsHandler; +import org.eclipse.hono.communication.core.http.HttpEndpointHandler; + + +/** + * Provides and Manages available HTTP vertx handlers. + */ +@ApplicationScoped +public class VertxHttpHandlerManagerService { + /** + * Available vertx endpoints handler services. + */ + private final List availableHandlerServices; + + + /** + * Creates a new VertxHttpHandlerManagerService with all dependencies. + * + * @param configHandler The configuration handler + * @param commandHandler The command handler + */ + public VertxHttpHandlerManagerService(final DeviceConfigsHandler configHandler, final DeviceCommandHandler commandHandler) { + this.availableHandlerServices = List.of(configHandler, commandHandler); + } + + public List getAvailableHandlerServices() { + return availableHandlerServices; + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/app/AbstractServiceApplication.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/app/AbstractServiceApplication.java new file mode 100644 index 00000000..d4b00d3d --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/app/AbstractServiceApplication.java @@ -0,0 +1,172 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.app; + +import java.util.Arrays; +import java.util.Base64; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import javax.enterprise.event.Observes; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import io.vertx.core.Closeable; +import io.vertx.core.Vertx; +import io.vertx.core.impl.VertxInternal; +import io.vertx.core.impl.cpu.CpuCoreSensor; +import io.vertx.core.json.impl.JsonUtil; + + +/** + * Abstract Service application class. + */ +public abstract class AbstractServiceApplication { + + /** + * YAML file application configurations properties. + */ + protected final ApplicationConfig appConfigs; + /** + * The vert.x instance managed by Quarkus. + */ + protected final Vertx vertx; + private final Logger log = LoggerFactory.getLogger(AbstractServiceApplication.class); + private Closeable addedVertxCloseHook; + + + /** + * Creates a new AbstractServiceApplication. + * + * @param vertx The quarkus Vertx instance + * @param appConfigs The application configs + */ + public AbstractServiceApplication(final Vertx vertx, + final ApplicationConfig appConfigs) { + this.vertx = vertx; + this.appConfigs = appConfigs; + } + + /** + * Logs information about the JVM. + */ + protected void logJvmDetails() { + if (log.isInfoEnabled()) { + + final String base64Encoder = Base64.getEncoder() == JsonUtil.BASE64_ENCODER ? "legacy" : "URL safe"; + + log.info(""" + running on Java VM [version: {}, name: {}, vendor: {}, max memory: {}MiB, processors: {}] \ + with vert.x using {} Base64 encoder\ + """, + System.getProperty("java.version"), + System.getProperty("java.vm.name"), + System.getProperty("java.vm.vendor"), + Runtime.getRuntime().maxMemory() >> 20, + CpuCoreSensor.availableProcessors(), + base64Encoder); + } + } + + /** + * Registers a close hook that will be notified when the Vertx instance is being closed. + */ + private void registerVertxCloseHook() { + if (vertx instanceof VertxInternal vertxInternal) { + final Closeable closeHook = completion -> { + final var stackTrace = Thread.currentThread().getStackTrace(); + final String s = Arrays.stream(stackTrace) + .skip(2) + .map(element -> "\tat %s.%s(%s:%d)".formatted( + element.getClassName(), + element.getMethodName(), + element.getFileName(), + element.getLineNumber())) + .collect(Collectors.joining(System.lineSeparator())); + log.warn("managed vert.x instance has been closed unexpectedly{}{}", System.lineSeparator(), s); + this.doStop(); + completion.complete(); + }; + vertxInternal.addCloseHook(closeHook); + addedVertxCloseHook = closeHook; + } else { + log.debug("Vertx instance is not a VertxInternal, skipping close hook registration"); + } + } + + /** + * Starts this component. + *

+ * This implementation + *

    + *
  1. logs the VM details,
  2. + *
  3. invokes {@link #doStart()}.
  4. + *
+ * + * @param ev The event indicating shutdown. + */ + public void onStart(final @Observes StartupEvent ev) { + + logJvmDetails(); + registerVertxCloseHook(); + log.info("Starting component {}...", appConfigs.componentName); + doStart(); + } + + /** + * Invoked during start up. + *

+ * Subclasses should override this method in order to initialize + * the component. + */ + protected void doStart() { + // do nothing + } + + /** + * Do work on application stop signal. + */ + protected void doStop() { + // do nothing + } + + + /** + * Stops this component. + * + * @param ev The event indicating shutdown. + */ + public void onStop(final @Observes ShutdownEvent ev) { + doStop(); + log.info("shutting down {}", appConfigs.componentName); + if (addedVertxCloseHook != null && vertx instanceof VertxInternal vertxInternal) { + vertxInternal.removeCloseHook(addedVertxCloseHook); + } + final CompletableFuture shutdown = new CompletableFuture<>(); + vertx.close(attempt -> { + if (attempt.succeeded()) { + shutdown.complete(null); + } else { + shutdown.completeExceptionally(attempt.cause()); + } + }); + shutdown.join(); + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/app/ApplicationConfig.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/app/ApplicationConfig.java new file mode 100644 index 00000000..174204bf --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/app/ApplicationConfig.java @@ -0,0 +1,65 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.app; + +import javax.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + + +/** + * Application configurations. + */ +@Singleton +public class ApplicationConfig { + + @ConfigProperty(name = "app.version") + String version; + @ConfigProperty(name = "app.name") + String componentName; + private final ServerConfig serverConfig; + private final DatabaseConfig databaseConfig; + + + /** + * Creates a new ApplicationConfig. + * + * @param serverConfig The server configs + * @param databaseConfig The database configs + */ + public ApplicationConfig(final ServerConfig serverConfig, final DatabaseConfig databaseConfig) { + this.serverConfig = serverConfig; + this.databaseConfig = databaseConfig; + } + + public String getVersion() { + return version; + } + + public String getComponentName() { + return componentName; + } + + + public ServerConfig getServerConfig() { + return serverConfig; + } + + public DatabaseConfig getDatabaseConfig() { + return databaseConfig; + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/app/DatabaseConfig.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/app/DatabaseConfig.java new file mode 100644 index 00000000..0819106a --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/app/DatabaseConfig.java @@ -0,0 +1,85 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.app; + +import javax.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + + +/** + * Database configurations. + */ +@Singleton +public class DatabaseConfig { + + // Datasource properties + @ConfigProperty(name = "vertx.database.port") + int port; + @ConfigProperty(name = "vertx.database.host") + String host; + @ConfigProperty(name = "vertx.database.username") + String userName; + @ConfigProperty(name = "vertx.database.password") + String password; + @ConfigProperty(name = "vertx.database.name") + String name; + @ConfigProperty(name = "vertx.database.pool-max-size") + int poolMaxSize; + @ConfigProperty(name = "vertx.device-registration.table") + String deviceRegistrationTableName; + @ConfigProperty(name = "vertx.device-registration.tenant-id-column") + String deviceRegistrationTenantIdColumn; + @ConfigProperty(name = "vertx.device-registration.device-id-column") + String deviceRegistrationDeviceIdColumn; + + public String getDeviceRegistrationTableName() { + return deviceRegistrationTableName; + } + + public String getDeviceRegistrationTenantIdColumn() { + return deviceRegistrationTenantIdColumn; + } + + public String getDeviceRegistrationDeviceIdColumn() { + return deviceRegistrationDeviceIdColumn; + } + + public int getPoolMaxSize() { + return poolMaxSize; + } + + public int getPort() { + return port; + } + + public String getHost() { + return host; + } + + public String getUserName() { + return userName; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/app/ServerConfig.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/app/ServerConfig.java new file mode 100644 index 00000000..4c5ee60c --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/app/ServerConfig.java @@ -0,0 +1,71 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.app; + +import javax.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + + +/** + * Server configurations. + */ +@Singleton +public class ServerConfig { + + // Vertx server properties + @ConfigProperty(name = "vertx.openapi.file") + String openApiFilePath; + @ConfigProperty(name = "vertx.server.url") + String serverUrl; + @ConfigProperty(name = "vertx.server.port", defaultValue = "8080") + int serverPort; + + @ConfigProperty(name = "vertx.server.paths.readiness", defaultValue = "/readiness") + String readinessPath; + + + @ConfigProperty(name = "vertx.server.paths.liveness", defaultValue = "/liveness") + String livenessPath; + + @ConfigProperty(name = "vertx.server.paths.base") + String basePath; + + public String getBasePath() { + return basePath; + } + + public String getReadinessPath() { + return readinessPath; + } + + public String getLivenessPath() { + return livenessPath; + } + + public String getOpenApiFilePath() { + return openApiFilePath; + } + + public String getServerUrl() { + return serverUrl; + } + + public int getServerPort() { + return serverPort; + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/http/AbstractVertxHttpServer.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/http/AbstractVertxHttpServer.java new file mode 100644 index 00000000..493f22af --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/http/AbstractVertxHttpServer.java @@ -0,0 +1,43 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.http; + +import org.eclipse.hono.communication.core.app.ApplicationConfig; + +import io.vertx.core.Vertx; + + +/** + * Abstract class for creating HTTP server in quarkus. + * using the managed vertx api + */ +public abstract class AbstractVertxHttpServer { + protected final ApplicationConfig appConfigs; + protected final Vertx vertx; + + + /** + * Creates a new AbstractVertxHttpServer. + * + * @param appConfigs The application configs + * @param vertx The quarkus Vertx instance + */ + public AbstractVertxHttpServer(final ApplicationConfig appConfigs, final Vertx vertx) { + this.appConfigs = appConfigs; + this.vertx = vertx; + } +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/http/HttpEndpointHandler.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/http/HttpEndpointHandler.java new file mode 100644 index 00000000..8eba4df6 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/http/HttpEndpointHandler.java @@ -0,0 +1,32 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.http; + +import io.vertx.ext.web.openapi.RouterBuilder; + +/** + * An vertx endpoint that handles HTTP requests. + */ +public interface HttpEndpointHandler { + /** + * Adds custom routes for handling requests that this endpoint can handle. + * + * @param router The router to add the routes to. + */ + void addRoutes(RouterBuilder router); + +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/http/HttpServer.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/http/HttpServer.java new file mode 100644 index 00000000..3431c190 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/http/HttpServer.java @@ -0,0 +1,34 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.http; + +/** + * HTTP server service. + */ +public interface HttpServer { + + /** + * Starts the http server. + */ + void start(); + + /** + * Stops the http server. + */ + void stop(); + +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/utils/DbUtils.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/utils/DbUtils.java new file mode 100644 index 00000000..45568ce2 --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/utils/DbUtils.java @@ -0,0 +1,74 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.utils; + +import org.eclipse.hono.communication.core.app.DatabaseConfig; + +import io.quarkus.runtime.Quarkus; +import io.vertx.core.Vertx; +import io.vertx.core.impl.logging.Logger; +import io.vertx.core.impl.logging.LoggerFactory; +import io.vertx.pgclient.PgConnectOptions; +import io.vertx.pgclient.PgPool; +import io.vertx.sqlclient.PoolOptions; + + +/** + * Database utilities class. + */ +public final class DbUtils { + + static final Logger log = LoggerFactory.getLogger(DbUtils.class); + static final String connectionFailedMsg = "Failed to connect to Database: %s"; + static final String connectionSuccessMsg = "Database connection created successfully."; + + private DbUtils() { + // avoid instantiation + } + + /** + * Build DB client that is used to manage a pool of connections. + * + * @param vertx The quarkus Vertx instance + * @param dbConfigs The database configs + * @return PostgreSQL pool + */ + public static PgPool createDbClient(final Vertx vertx, final DatabaseConfig dbConfigs) { + + + final PgConnectOptions connectOptions = new PgConnectOptions() + .setHost(dbConfigs.getHost()) + .setPort(dbConfigs.getPort()) + .setDatabase(dbConfigs.getName()) + .setUser(dbConfigs.getUserName()) + .setPassword(dbConfigs.getPassword()); + + final PoolOptions poolOptions = new PoolOptions().setMaxSize(dbConfigs.getPoolMaxSize()); + final var pool = PgPool.pool(vertx, connectOptions, poolOptions); + pool.getConnection(connection -> { + if (connection.failed()) { + log.error(String.format(connectionFailedMsg, connection.cause().getMessage())); + Quarkus.asyncExit(-1); + } else { + log.info(connectionSuccessMsg); + } + }); + return pool; + + } + +} diff --git a/device-communication/src/main/java/org/eclipse/hono/communication/core/utils/ResponseUtils.java b/device-communication/src/main/java/org/eclipse/hono/communication/core/utils/ResponseUtils.java new file mode 100644 index 00000000..f53db74a --- /dev/null +++ b/device-communication/src/main/java/org/eclipse/hono/communication/core/utils/ResponseUtils.java @@ -0,0 +1,119 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.core.utils; + +import org.eclipse.hono.communication.api.exception.DeviceNotFoundException; + +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.validation.BadRequestException; + + +/** + * HTTP Response utilities class. + */ +public abstract class ResponseUtils { + private static final String CONTENT_TYPE_HEADER = "Content-Type"; + private static final String APPLICATION_JSON_TYPE = "application/json"; + + private ResponseUtils() { + // avoid instantiation + } + + /** + * Build success response using 200 as its status code and response object as body. + * + * @param rc The routing context + * @param response The response object + */ + public static void successResponse(final RoutingContext rc, + final Object response) { + rc.response() + .setStatusCode(200) + .putHeader(CONTENT_TYPE_HEADER, APPLICATION_JSON_TYPE) + .end(Json.encodePrettily(response)); + } + + /** + * Build success response using 201 Created as its status code and response object as body. + * + * @param rc Routing context + * @param response Response body + */ + public static void createdResponse(final RoutingContext rc, + final Object response) { + rc.response() + .setStatusCode(201) + .putHeader(CONTENT_TYPE_HEADER, APPLICATION_JSON_TYPE) + .end(Json.encodePrettily(response)); + } + + /** + * Build success response using 204 No Content as its status code and no response body. + * + * @param rc Routing context + */ + public static void noContentResponse(final RoutingContext rc) { + rc.response() + .setStatusCode(204) + .end(); + } + + /** + * Build error response using 400 Bad Request, 404 Not Found or 500 Internal Server Error + * as its status code and throwable as its body. + * + * @param rc Routing context + * @param error Throwable exception + */ + public static void errorResponse(final RoutingContext rc, final Throwable error) { + final int status; + final String message; + + + if (error instanceof IllegalArgumentException + || error instanceof IllegalStateException + || error instanceof NullPointerException + || error instanceof BadRequestException) { + + // Bad Request + status = 400; + message = error.getMessage(); + } else if (error instanceof DeviceNotFoundException) { + // Not Found + status = 404; + message = error.getMessage(); + } else { + // Internal Server Error + status = 500; + if (error != null) { + message = String.format("Internal Server Error: %s", error.getMessage()); + } else { + message = "Internal Server Error"; + } + + } + + rc.response() + .setStatusCode(status) + .putHeader(CONTENT_TYPE_HEADER, APPLICATION_JSON_TYPE) + .end(new JsonObject().put("error", message).encodePrettily()); + } + + +} diff --git a/device-communication/src/main/resources/api/hono-device-communication-v1.yaml b/device-communication/src/main/resources/api/hono-device-communication-v1.yaml new file mode 100644 index 00000000..63764c51 --- /dev/null +++ b/device-communication/src/main/resources/api/hono-device-communication-v1.yaml @@ -0,0 +1,215 @@ +--- +openapi: 3.0.2 +info: + title: Hono Device Communication + version: 1.0.0 + description: Device commands and configs API. +servers: + - url: http://localhost:8080/api/v1 + +paths: + /commands/{tenantid}/{deviceid}: + summary: Device commands + description: Commands for a specific device + post: + requestBody: + description: CommandRequest object as JSON + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceCommandRequest' + required: true + tags: + - COMMANDS + responses: + "200": + description: Command was sent successfully + "400": + description: Command Validation error or Bad request + "404": + description: Device not found + "500": + description: Internal server error + operationId: postCommand + summary: Send a command to device. + parameters: + - name: tenantid + description: Unique registry ID + schema: + type: string + in: path + required: true + - name: deviceid + description: Unique device ID + schema: + type: string + in: path + required: true + /configs/{tenantid}/{deviceid}: + summary: Device configs + description: Create or list Configs for a specific device + get: + tags: + - CONFIGS + parameters: + - name: numVersions + description: "The number of versions to list. Versions are listed in decreasing + order of the version number. The maximum number of versions saved in Database is + 10. If this value is zero, it will return all the versions available." + schema: + type: integer + minimum: 0 + maximum: 10 + in: query + required: false + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListDeviceConfigVersionsResponse' + description: Lists the device config versions + "404": + description: Device not found + "500": + description: Internal server error + operationId: listConfigVersions + summary: List a device config versions + description: "Lists the last few versions of the device configuration in descending + order (i.e.: newest first)." + post: + requestBody: + description: DeviceConfigRequest object as JSON + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceConfigRequest' + examples: + Device-configs-example: + value: + versionToUpdate: some text + binaryData: some text + required: true + tags: + - CONFIGS + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceConfig' + description: Device config updated successfully + "400": + description: Validation error or Bad request + "404": + description: Device not found + "500": + description: Internal Server error + operationId: modifyCloudToDeviceConfig + summary: Modify cloud to device config + description: Creates an device config version and Returns the + the new configuration version and its metadata. + parameters: + - name: tenantid + description: Unique registry id + schema: + type: string + in: path + required: true + - name: deviceid + description: Unique device id + schema: + type: string + in: path + required: true +components: + schemas: + DeviceCommandRequest: + title: Root Type for Command + description: Command json object structure + required: + - binaryData + type: object + properties: + binaryData: + description: "The command data to send to the device in base64-encoded string\ + \ format.\r\n\r\n" + type: string + subfolder: + description: "Optional subfolder for the command. If empty, the command + will be delivered to the /devices/{device-id}/commands topic, otherwise + it will be delivered to the /devices/{device-id}/commands/{subfolder} + topic. Multi-level subfolders are allowed. This field must not have + more than 256 characters, and must not contain any MQTT wildcards + (\"+\" or \"#\") or null characters." + type: string + example: + binaryData: A base64-encoded string + subfolder: Optional subfolder for the command + DeviceConfigRequest: + description: Request body for modifying device configs + required: + - binaryData + type: object + properties: + versionToUpdate: + description: "string (int64 format)\r\n\r\nThe Config version number." + type: string + binaryData: + description: "string (bytes format)\r\n\r\nThe configuration data for the + device in string base64-encoded format.\r\n" + type: string + ListDeviceConfigVersionsResponse: + title: Root Type for ListDeviceConfigVersionsResponse + description: A list of a device config versions + type: object + properties: + deviceConfigs: + description: List of DeviceConfig objects + type: array + items: + $ref: '#/components/schemas/DeviceConfig' + example: + deviceConfigs: + - object: DeviceConfig + DeviceConfig: + title: Root Type for DeviceConfig + description: The device configuration. + type: object + properties: + version: + description: "String (int64 format) [Output only] The version + of this update. The version number is assigned by the server, and is + always greater than 0 after device creation. The version must be 0 on + the devices.create request if a config is specified; the response of + devices.create will always have a value of 1." + type: string + cloudUpdateTime: + description: "String (Timestamp format) [Output only] The time at + which this configuration version was updated in Cloud IoT Core. This + timestamp is set by the server. Timestamp in + RFC3339 UTC \"Zulu\" format, accurate to nanoseconds. + Example: \"2014-10-02T15:01:23.045123456Z\"." + type: string + deviceAckTime: + description: "string (Timestamp format) [Output only] The time at + which Cloud IoT Core received the acknowledgment from the device, indicating + that the device has received this configuration version. If this field + is not present, the device has not yet acknowledged that it received + this version. Note that when the config was sent to the device, many + config versions may have been available in Cloud IoT Core while the + device was disconnected, and on connection, only the latest version + is sent to the device. Some versions may never be sent to the device, + and therefore are never acknowledged. This timestamp is set by Cloud + IoT Core. Timestamp in RFC3339 UTC \"Zulu\" format, accurate + to nanoseconds. Example: \"2014-10-02T15:01:23.045123456Z\"." + type: string + binaryData: + description: "string (bytes format) The device configuration data + in string base64-encoded format." + type: string + example: + version: string + cloudUpdateTime: string + deviceAckTime: string + binaryData: string diff --git a/device-communication/src/main/resources/api/hono-endpoint.yaml b/device-communication/src/main/resources/api/hono-endpoint.yaml new file mode 100644 index 00000000..e2e1491b --- /dev/null +++ b/device-communication/src/main/resources/api/hono-endpoint.yaml @@ -0,0 +1,205 @@ +swagger: "2.0" +x-components: { } +host: "hono-device-communication-gce.endpoints.sotec-iot-core-dev.cloud.goog" +info: + description: Device commands and configs API. + title: Hono Device Communication API + version: 1.0.0 +schemes: + - http +basePath: /api/v1 +definitions: + DeviceCommandRequest: + description: Command json object structure + example: + binaryData: A base64-encoded string + subfolder: Optional subfolder for the command + properties: + binaryData: + description: "The command data to send to the device in base64-encoded string format.\r\n\r\n" + type: string + subfolder: + description: >- + Optional subfolder for the command. If empty, the command will be + delivered to the /devices/{device-id}/commands topic, otherwise it + will be delivered to the /devices/{device-id}/commands/{subfolder} + topic. Multi-level subfolders are allowed. This field must not have + more than 256 characters, and must not contain any MQTT wildcards + ("+" or "#") or null characters. + type: string + required: + - binaryData + title: Root Type for Command + type: object + DeviceConfig: + description: The device configuration. + example: + binaryData: string + cloudUpdateTime: string + deviceAckTime: string + version: string + properties: + binaryData: + description: >- + string (bytes format) The device configuration data in string + base64-encoded format. + type: string + cloudUpdateTime: + description: >- + String (Timestamp format) [Output only] The time at which this + configuration version was updated in Cloud IoT Core. This timestamp is + set by the server. Timestamp in RFC3339 UTC "Zulu" format, accurate + to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z". + type: string + deviceAckTime: + description: >- + string (Timestamp format) [Output only] The time at which Cloud IoT + Core received the acknowledgment from the device, indicating that the + device has received this configuration version. If this field is not + present, the device has not yet acknowledged that it received this + version. Note that when the config was sent to the device, many config + versions may have been available in Cloud IoT Core while the device + was disconnected, and on connection, only the latest version is sent + to the device. Some versions may never be sent to the device, and + therefore are never acknowledged. This timestamp is set by Cloud IoT + Core. Timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds. + Example: "2014-10-02T15:01:23.045123456Z". + type: string + version: + description: >- + String (int64 format) [Output only] The version of this update. The + version number is assigned by the server, and is always greater than 0 + after device creation. The version must be 0 on the devices.create + request if a config is specified; the response of devices.create will + always have a value of 1. + type: string + title: Root Type for DeviceConfig + type: object + DeviceConfigRequest: + description: Request body for modifying device configs + properties: + binaryData: + description: "string (bytes format) The configuration data for the device in string base64-encoded format.\r\n" + type: string + versionToUpdate: + description: "string (int64 format) The Config version number." + type: string + required: + - binaryData + type: object + + ListDeviceConfigVersionsResponse: + description: A list of a device config versions + example: + deviceConfigs: + - object: DeviceConfig + properties: + deviceConfigs: + description: List of DeviceConfig objects + items: + $ref: "#/definitions/DeviceConfig" + type: array + title: Root Type for ListDeviceConfigVersionsResponse + type: object +paths: + "/commands/{tenantid}/{deviceid}": + parameters: + - description: Unique registry ID + in: path + name: tenantid + required: true + type: string + - description: Unique device ID + in: path + name: deviceid + required: true + type: string + post: + consumes: + - application/json + operationId: postCommand + parameters: + - description: CommandRequest object as JSON + in: body + name: body + required: true + schema: + $ref: "#/definitions/DeviceCommandRequest" + responses: + "200": + description: Command was sent successfully + "400": + description: Command can not be send to device + summary: Send a command to device. + tags: + - COMMANDS + + "/configs/{tenantid}/{deviceid}": + get: + description: >- + Lists the last few versions of the device configuration in descending + order (i.e.: newest first). + operationId: listConfigVersions + parameters: + - description: >- + The number of versions to list. Versions are listed in decreasing + order of the version number. The maximum number of versions saved in + Database is 10. If this value is zero, it will return all the + versions available. + in: query + maximum: 10 + minimum: 0 + name: numVersions + required: false + type: integer + produces: + - application/json + responses: + "200": + description: Lists the device config versions + schema: + $ref: "#/definitions/ListDeviceConfigVersionsResponse" + summary: List a device config versions + tags: + - CONFIGS + parameters: + - description: Unique registry id + in: path + name: tenantid + required: true + type: string + - description: Unique device id + in: path + name: deviceid + required: true + type: string + post: + consumes: + - application/json + description: >- + Creates an device config version and Returns the the new configuration + version and its metadata. + operationId: modifyCloudToDeviceConfig + parameters: + - description: DeviceConfigRequest object as JSON + in: body + name: body + required: true + schema: + $ref: "#/definitions/DeviceConfigRequest" + produces: + - application/json + responses: + "200": + description: Device config updated successfully + schema: + $ref: "#/definitions/DeviceConfig" + "400": + description: Validation error or Bad request + "404": + description: Not Found + "500": + description: Internal Server error + summary: Modify cloud to device config + tags: + - CONFIGS \ No newline at end of file diff --git a/device-communication/src/main/resources/application.yaml b/device-communication/src/main/resources/application.yaml new file mode 100644 index 00000000..1b15d64f --- /dev/null +++ b/device-communication/src/main/resources/application.yaml @@ -0,0 +1,36 @@ +app: + name: "Device Communication" + version: ${COM_APP_VERSION:"v1"} +vertx: + openapi: + file: ${COM_OPENAPI_FILE_PATH:api/hono-device-communication-v1.yaml} + + server: + url: ${COM_SERVER_HOST:0.0.0.0} + port: ${COM_SERVER_PORT:8080} + paths: + base: ${COM_SERVER_BASE_PATH:/api/v1/} # base path should always end with "/" + liveness: ${COM_SERVER_LIVENESS_PATH:/alive} + readiness: ${COM_SERVER_READINESS_PATH:/ready} + + database: + pool-max-size: ${COM_POOL_MAX_SIZE:5} + name: ${COM_DB_NAME:hono} + host: ${COM_DB_HOST:localhost} + port: ${COM_DB_PORT:5432} + username: ${COM_DB_USERNAME:postgres} + password: ${COM_DB_PASSWORD:mysecretpassword} + db-kind: "postgresql" + + # Device registration table configs. Used for validating devices + device-registration: + table: ${COM_DB_DEVICE_REG_TABLE:device_registrations} + tenant-id-column: ${COM_DB_DEVICE_REG_TENANT_COL_NAME:tenant_id} + device-id-column: ${COM_DB_DEVICE_REG_DEVICE_COL_NAME:device_id} + +quarkus: + container-image: + builder: docker + build: true + push: false + image: "gcr.io/sotec-iot-core-dev/hono-device-communication" diff --git a/device-communication/src/main/resources/db/create_device_config_table.sql b/device-communication/src/main/resources/db/create_device_config_table.sql new file mode 100644 index 00000000..ae425132 --- /dev/null +++ b/device-communication/src/main/resources/db/create_device_config_table.sql @@ -0,0 +1,28 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + + +CREATE TABLE IF NOT EXISTS device_configs +( + version INT not null, + tenant_id VARCHAR(100) not null, + device_id VARCHAR(100) not null, + cloud_update_time VARCHAR(100) not null, + device_ack_time VARCHAR(100), + binary_data VARCHAR not null, + + PRIMARY KEY (version, tenant_id, device_id) +) \ No newline at end of file diff --git a/device-communication/src/main/resources/db/migration/V1.0__create.sql b/device-communication/src/main/resources/db/migration/V1.0__create.sql new file mode 100644 index 00000000..69fb9487 --- /dev/null +++ b/device-communication/src/main/resources/db/migration/V1.0__create.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS public."deviceConfig" +( + version INT not null, + "tenantId" VARCHAR(100) not null, + "deviceId" VARCHAR(100) not null, + "cloudUpdateTime" VARCHAR(100) not null, + "deviceAckTime" VARCHAR(100), + "binaryData" VARCHAR not null, + + PRIMARY KEY (version, "tenantId", "deviceId") +) \ No newline at end of file diff --git a/device-communication/src/test/java/org/eclipse/hono/communication/api/ApplicationTest.java b/device-communication/src/test/java/org/eclipse/hono/communication/api/ApplicationTest.java new file mode 100644 index 00000000..c45b74ed --- /dev/null +++ b/device-communication/src/test/java/org/eclipse/hono/communication/api/ApplicationTest.java @@ -0,0 +1,63 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import org.eclipse.hono.communication.core.app.ApplicationConfig; +import org.eclipse.hono.communication.core.http.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + + +import io.vertx.core.Vertx; + + +class ApplicationTest { + + private HttpServer httpServerMock; + private Application application; + + @BeforeEach + void setUp() { + httpServerMock = mock(HttpServer.class); + final Vertx vertxMock = mock(Vertx.class); + final ApplicationConfig appConfigs = mock(ApplicationConfig.class); + application = new Application(vertxMock, appConfigs, httpServerMock); + + verifyNoMoreInteractions(httpServerMock, appConfigs, vertxMock); + } + + @AfterEach + void tearDown() { + } + + @Test + void doStart() { + doNothing().when(httpServerMock).start(); + + application.doStart(); + + verify(httpServerMock, times(1)).start(); + + } +} diff --git a/device-communication/src/test/java/org/eclipse/hono/communication/api/DeviceCommunicationHttpServerTest.java b/device-communication/src/test/java/org/eclipse/hono/communication/api/DeviceCommunicationHttpServerTest.java new file mode 100644 index 00000000..c25a29db --- /dev/null +++ b/device-communication/src/test/java/org/eclipse/hono/communication/api/DeviceCommunicationHttpServerTest.java @@ -0,0 +1,396 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + + +import java.util.List; + +import org.eclipse.hono.communication.api.handler.DeviceCommandHandler; +import org.eclipse.hono.communication.api.service.DatabaseSchemaCreator; +import org.eclipse.hono.communication.api.service.DatabaseSchemaCreatorImpl; +import org.eclipse.hono.communication.api.service.DatabaseService; +import org.eclipse.hono.communication.api.service.DatabaseServiceImpl; +import org.eclipse.hono.communication.api.service.VertxHttpHandlerManagerService; +import org.eclipse.hono.communication.core.app.ApplicationConfig; +import org.eclipse.hono.communication.core.app.ServerConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import io.netty.handler.codec.http.HttpHeaderNames; +import io.quarkus.runtime.Quarkus; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.openapi.RouterBuilder; +import io.vertx.ext.web.validation.BadRequestException; + + +class DeviceCommunicationHttpServerTest { + + private ApplicationConfig appConfigsMock; + private VertxHttpHandlerManagerService handlerServiceMock; + private Vertx vertxMock; + private Router routerMock; + private RouterBuilder routerBuilderMock; + private HttpServer httpServerMock; + private DeviceCommunicationHttpServer deviceCommunicationHttpServer; + private RoutingContext routingContextMock; + private HttpServerResponse httpServerResponseMock; + private HttpServerRequest httpServerRequestMock; + private BadRequestException badRequestExceptionMock; + private DatabaseSchemaCreator databaseSchemaCreatorMock; + private Route routeMock; + private JsonObject jsonObjMock; + + private DatabaseService dbMock; + private ServerConfig serverConfigMock; + + @BeforeEach + void setUp() { + handlerServiceMock = mock(VertxHttpHandlerManagerService.class); + vertxMock = mock(Vertx.class); + routerMock = mock(Router.class); + routerBuilderMock = mock(RouterBuilder.class); + appConfigsMock = mock(ApplicationConfig.class); + serverConfigMock = mock(ServerConfig.class); + httpServerMock = mock(HttpServer.class); + routingContextMock = mock(RoutingContext.class); + httpServerResponseMock = mock(HttpServerResponse.class); + httpServerRequestMock = mock(HttpServerRequest.class); + badRequestExceptionMock = mock(BadRequestException.class); + jsonObjMock = mock(JsonObject.class); + dbMock = mock(DatabaseServiceImpl.class); + databaseSchemaCreatorMock = mock(DatabaseSchemaCreatorImpl.class); + routeMock = mock(Route.class); + deviceCommunicationHttpServer = new DeviceCommunicationHttpServer(appConfigsMock, + vertxMock, + handlerServiceMock, + dbMock, + databaseSchemaCreatorMock); + + } + + + @AfterEach + void tearDown() { + Mockito.verifyNoMoreInteractions(handlerServiceMock, + vertxMock, + routerMock, + routerBuilderMock, + appConfigsMock, + httpServerMock, + routingContextMock, + httpServerResponseMock, + httpServerRequestMock, + badRequestExceptionMock, + jsonObjMock, + dbMock, + serverConfigMock, + databaseSchemaCreatorMock, + routeMock); + } + + + @Test + void startSucceeded() { + final var mockedCommandService = mock(DeviceCommandHandler.class); + Mockito.verifyNoMoreInteractions(mockedCommandService); + doNothing().when(mockedCommandService).addRoutes(this.routerBuilderMock); + try (MockedStatic mockedRouterBuilderStatic = mockStatic(RouterBuilder.class)) { + + try (MockedStatic routerMockedStatic = mockStatic(Router.class)) { + + routerMockedStatic.when(() -> Router.router(any())).thenReturn(routerMock); + mockedRouterBuilderStatic.when(() -> RouterBuilder.create(any(), any())) + .thenReturn(Future.succeededFuture(routerBuilderMock)); + mockedRouterBuilderStatic.verifyNoMoreInteractions(); + when(appConfigsMock.getServerConfig()).thenReturn(serverConfigMock); + when(handlerServiceMock.getAvailableHandlerServices()).thenReturn(List.of(mockedCommandService)); + when(routerBuilderMock.createRouter()).thenReturn(routerMock); + when(serverConfigMock.getLivenessPath()).thenReturn("/live"); + when(serverConfigMock.getReadinessPath()).thenReturn("/ready"); + when(routerMock.get(anyString())).thenReturn(routeMock); + when(routeMock.handler(any())).thenReturn(routeMock); + when(routerMock.errorHandler(anyInt(), any())).thenReturn(routerMock); + when(vertxMock.createHttpServer(any(HttpServerOptions.class))).thenReturn(httpServerMock); + when(httpServerMock.requestHandler(any())).thenReturn(httpServerMock); + when(httpServerMock.listen()).thenReturn(Future.succeededFuture(httpServerMock)); + when(serverConfigMock.getOpenApiFilePath()).thenReturn("/myPath"); + when(serverConfigMock.getBasePath()).thenReturn("/basePath"); + when(routerMock.route(any())).thenReturn(routeMock); + + try (MockedStatic quarkusMockedStatic = mockStatic(Quarkus.class)) { + final DeviceCommunicationHttpServer deviceCommunicationHttpServerSpy = spy(this.deviceCommunicationHttpServer); + deviceCommunicationHttpServerSpy.start(); + + + verify(deviceCommunicationHttpServerSpy, times(1)).createRouterWithEndpoints(eq(routerBuilderMock), any()); + verify(deviceCommunicationHttpServerSpy, times(1)).startVertxServer(any()); + mockedRouterBuilderStatic.verify(() -> RouterBuilder.create(vertxMock, "/myPath"), times(1)); + routerMockedStatic.verify(() -> Router.router(vertxMock), times(1)); + + + verify(databaseSchemaCreatorMock, times(1)).createDBTables(); + verify(handlerServiceMock, times(1)).getAvailableHandlerServices(); + verify(routerBuilderMock, times(1)).createRouter(); + + + verify(vertxMock, times(1)).createHttpServer(any(HttpServerOptions.class)); + verify(httpServerMock, times(1)).requestHandler(any()); + verify(httpServerMock, times(1)).listen(); + verify(appConfigsMock, times(3)).getServerConfig(); + verify(serverConfigMock, times(2)).getServerUrl(); + verify(serverConfigMock, times(2)).getServerPort(); + verify(serverConfigMock, times(1)).getLivenessPath(); + verify(serverConfigMock, times(1)).getReadinessPath(); + verify(serverConfigMock, times(1)).getBasePath(); + verify(mockedCommandService, times(1)).addRoutes(routerBuilderMock); + verify(serverConfigMock, times(1)).getOpenApiFilePath(); + verify(routerMock, times(1)).errorHandler(eq(400), any()); + verify(routerMock, times(1)).errorHandler(eq(404), any()); + verify(routerMock, times(2)).get(anyString()); + verify(routeMock, times(2)).handler(any()); + verify(routerMock, times(1)).route(anyString()); + verify(routeMock, times(1)).subRouter(any()); + quarkusMockedStatic.verify(Quarkus::waitForExit, times(1)); + routerMockedStatic.verifyNoMoreInteractions(); + mockedRouterBuilderStatic.verifyNoMoreInteractions(); + quarkusMockedStatic.verifyNoMoreInteractions(); + + } + } + + + } + + + } + + @Test + void createRouterFailed() { + final var mockedCommandService = mock(DeviceCommandHandler.class); + Mockito.verifyNoMoreInteractions(mockedCommandService); + doNothing().when(mockedCommandService).addRoutes(this.routerBuilderMock); + try (MockedStatic mockedRouterBuilderStatic = mockStatic(RouterBuilder.class)) { + mockedRouterBuilderStatic.when(() -> RouterBuilder.create(any(), any())) + .thenReturn(Future.failedFuture(new RuntimeException())); + when(appConfigsMock.getServerConfig()).thenReturn(serverConfigMock); + mockedRouterBuilderStatic.verifyNoMoreInteractions(); + when(serverConfigMock.getOpenApiFilePath()).thenReturn("/myPath"); + + try (MockedStatic quarkusMockedStatic = mockStatic(Quarkus.class)) { + this.deviceCommunicationHttpServer.start(); + + + verify(dbMock, times(1)).close(); + verify(databaseSchemaCreatorMock, times(1)).createDBTables(); + verify(handlerServiceMock, times(1)).getAvailableHandlerServices(); + verify(appConfigsMock, times(1)).getServerConfig(); + verify(serverConfigMock, times(1)).getOpenApiFilePath(); + quarkusMockedStatic.verify(() -> Quarkus.asyncExit(-1), times(1)); + quarkusMockedStatic.verify(Quarkus::waitForExit, times(1)); + quarkusMockedStatic.verifyNoMoreInteractions(); + } + + } + } + + + @Test + void createServerFailed() { + final var mockedCommandService = mock(DeviceCommandHandler.class); + Mockito.verifyNoMoreInteractions(mockedCommandService); + doNothing().when(mockedCommandService).addRoutes(this.routerBuilderMock); + try (MockedStatic mockedRouterBuilderStatic = mockStatic(RouterBuilder.class)) { + try (MockedStatic quarkusMockedStatic = mockStatic(Quarkus.class)) { + try (MockedStatic routerMockedStatic = mockStatic(Router.class)) { + mockedRouterBuilderStatic.when(() -> RouterBuilder.create(any(), any())) + .thenReturn(Future.succeededFuture(routerBuilderMock)); + + routerMockedStatic.when(() -> Router.router(any())).thenReturn(routerMock); + + when(appConfigsMock.getServerConfig()).thenReturn(serverConfigMock); + when(handlerServiceMock.getAvailableHandlerServices()).thenReturn(List.of()); + when(routerBuilderMock.createRouter()).thenReturn(routerMock); + when(routerMock.errorHandler(anyInt(), any())).thenReturn(routerMock); + when(serverConfigMock.getLivenessPath()).thenReturn("/live"); + when(serverConfigMock.getReadinessPath()).thenReturn("/ready"); + when(routerMock.get(anyString())).thenReturn(routeMock); + when(routeMock.handler(any())).thenReturn(routeMock); + when(vertxMock.createHttpServer(any(HttpServerOptions.class))).thenReturn(httpServerMock); + when(httpServerMock.requestHandler(routerMock)).thenReturn(httpServerMock); + when(httpServerMock.listen()).thenReturn(Future.failedFuture(new Throwable())); + when(serverConfigMock.getOpenApiFilePath()).thenReturn("/myPath"); + when(serverConfigMock.getBasePath()).thenReturn("/basePath"); + when(routerMock.route(any())).thenReturn(routeMock); + final DeviceCommunicationHttpServer deviceCommunicationHttpServerSpy = spy(this.deviceCommunicationHttpServer); + deviceCommunicationHttpServerSpy.start(); + + + verify(deviceCommunicationHttpServerSpy, times(1)).createRouterWithEndpoints(eq(routerBuilderMock), any()); + verify(deviceCommunicationHttpServerSpy, times(1)).startVertxServer(any()); + + + mockedRouterBuilderStatic.verify(() -> RouterBuilder.create(vertxMock, "/myPath"), times(1)); + + verify(databaseSchemaCreatorMock, times(1)).createDBTables(); + verify(handlerServiceMock, times(1)).getAvailableHandlerServices(); + verify(routerBuilderMock, times(1)).createRouter(); + verify(vertxMock, times(1)).createHttpServer(any(HttpServerOptions.class)); + verify(httpServerMock, times(1)).requestHandler(routerMock); + verify(httpServerMock, times(1)).listen(); + verify(appConfigsMock, times(3)).getServerConfig(); + verify(serverConfigMock, times(1)).getOpenApiFilePath(); + verify(serverConfigMock, times(1)).getServerPort(); + verify(serverConfigMock, times(1)).getServerUrl(); + verify(serverConfigMock, times(1)).getLivenessPath(); + verify(serverConfigMock, times(1)).getReadinessPath(); + verify(serverConfigMock, times(1)).getBasePath(); + verify(routerMock, times(1)).errorHandler(eq(400), any()); + verify(routerMock, times(1)).errorHandler(eq(404), any()); + verify(routerMock, times(2)).get(anyString()); + verify(routeMock, times(2)).handler(any()); + verify(routeMock, times(1)).subRouter(any()); + verify(routerMock, times(1)).route(anyString()); + routerMockedStatic.verify(() -> Router.router(vertxMock), times(1)); + quarkusMockedStatic.verify(Quarkus::waitForExit, times(1)); + mockedRouterBuilderStatic.verifyNoMoreInteractions(); + routerMockedStatic.verifyNoMoreInteractions(); + quarkusMockedStatic.verifyNoMoreInteractions(); + } + } + + } + } + + + @Test + void addDefault400ExceptionHandler() { + final var errorMsg = "This is an error message"; + final int code = 400; + + when(routingContextMock.failure()).thenReturn(badRequestExceptionMock); + when(badRequestExceptionMock.toJson()).thenReturn(jsonObjMock); + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.setStatusCode(code)).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.end()).thenReturn(Future.succeededFuture()); + when(jsonObjMock.toString()).thenReturn(errorMsg); + + deviceCommunicationHttpServer.addDefault400ExceptionHandler(routingContextMock); + + verify(routingContextMock, times(1)).response(); + verify(routingContextMock, times(1)).failure(); + verify(badRequestExceptionMock, times(1)).toJson(); + verify(httpServerResponseMock, times(1)).setStatusCode(code); + verify(httpServerResponseMock, times(1)).end(errorMsg); + + } + + @Test + void addDefault404ExceptionHandlerPutsHeader() { + + final var errorMsg = "This is an error message"; + final int code = 404; + + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.putHeader(eq(HttpHeaderNames.CONTENT_TYPE), + anyString())).thenReturn(httpServerResponseMock); + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(routingContextMock.request()).thenReturn(httpServerRequestMock); + when(httpServerResponseMock.ended()).thenReturn(Boolean.FALSE); + when(routingContextMock.failure()).thenReturn(badRequestExceptionMock); + when(badRequestExceptionMock.toJson()).thenReturn(jsonObjMock); + when(httpServerRequestMock.method()).thenReturn(HttpMethod.GET); + when(httpServerResponseMock.setStatusCode(code)).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.end()).thenReturn(Future.succeededFuture()); + when(jsonObjMock.toString()).thenReturn(errorMsg); + + deviceCommunicationHttpServer.addDefault404ExceptionHandler(routingContextMock); + + verify(routingContextMock, times(3)).response(); + verify(routingContextMock, times(1)).request(); + verify(httpServerResponseMock, times(1)).setStatusCode(code); + verify(httpServerResponseMock, times(1)).putHeader(eq(HttpHeaderNames.CONTENT_TYPE), + anyString()); + verify(httpServerResponseMock, times(1)).end(anyString()); + verify(httpServerResponseMock, times(1)).ended(); + verify(httpServerRequestMock, times(1)).method(); + + } + + @Test + void addDefault404ExceptionHandlerResponseEnded() { + + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.ended()).thenReturn(Boolean.TRUE); + + deviceCommunicationHttpServer.addDefault404ExceptionHandler(routingContextMock); + + verify(routingContextMock, times(1)).response(); + verify(httpServerResponseMock, times(1)).ended(); + + } + + @Test + void addDefault404ExceptionHandlerMethodEqualsHead() { + + final var errorMsg = "This is an error message"; + final int code = 404; + + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.ended()).thenReturn(Boolean.FALSE); + when(routingContextMock.failure()).thenReturn(badRequestExceptionMock); + when(badRequestExceptionMock.toJson()).thenReturn(jsonObjMock); + + when(routingContextMock.request()).thenReturn(httpServerRequestMock); + when(httpServerRequestMock.method()).thenReturn(HttpMethod.HEAD); + when(httpServerResponseMock.setStatusCode(code)).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.end()).thenReturn(Future.succeededFuture()); + when(jsonObjMock.toString()).thenReturn(errorMsg); + + deviceCommunicationHttpServer.addDefault404ExceptionHandler(routingContextMock); + + verify(routingContextMock, times(3)).response(); + verify(routingContextMock, times(1)).request(); + verify(httpServerResponseMock, times(1)).setStatusCode(code); + + verify(httpServerResponseMock, times(1)).end(); + verify(httpServerResponseMock, times(1)).ended(); + verify(httpServerRequestMock, times(1)).method(); + } + + @Test + void stop() { + doNothing().when(dbMock).close(); + deviceCommunicationHttpServer.stop(); + verify(dbMock).close(); + } +} diff --git a/device-communication/src/test/java/org/eclipse/hono/communication/api/handler/DeviceCommandsHandlerTest.java b/device-communication/src/test/java/org/eclipse/hono/communication/api/handler/DeviceCommandsHandlerTest.java new file mode 100644 index 00000000..533259d2 --- /dev/null +++ b/device-communication/src/test/java/org/eclipse/hono/communication/api/handler/DeviceCommandsHandlerTest.java @@ -0,0 +1,90 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.handler; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import org.eclipse.hono.communication.api.config.DeviceCommandConstants; +import org.eclipse.hono.communication.api.service.DeviceCommandService; +import org.eclipse.hono.communication.api.service.DeviceCommandServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.openapi.Operation; +import io.vertx.ext.web.openapi.RouterBuilder; + + +class DeviceCommandsHandlerTest { + + private final DeviceCommandService commandServiceMock; + private final RouterBuilder routerBuilderMock; + private final RoutingContext routingContextMock; + private final Operation operationMock; + private final DeviceCommandHandler deviceCommandsHandler; + + DeviceCommandsHandlerTest() { + operationMock = mock(Operation.class); + commandServiceMock = mock(DeviceCommandServiceImpl.class); + routerBuilderMock = mock(RouterBuilder.class); + routingContextMock = mock(RoutingContext.class); + deviceCommandsHandler = new DeviceCommandHandler(commandServiceMock); + } + + @AfterEach + void tearDown() { + verifyNoMoreInteractions( + commandServiceMock, + routerBuilderMock, + routingContextMock, + operationMock); + } + + @BeforeEach + void setUp() { + verifyNoMoreInteractions( + commandServiceMock, + routerBuilderMock, + routingContextMock, + operationMock); + + } + + @Test + void addRoutes() { + when(routerBuilderMock.operation(anyString())).thenReturn(operationMock); + when(operationMock.handler(any())).thenReturn(operationMock); + + deviceCommandsHandler.addRoutes(routerBuilderMock); + + verify(routerBuilderMock, times(1)).operation(DeviceCommandConstants.POST_DEVICE_COMMAND_OP_ID); + verify(operationMock, times(1)).handler(any()); + + } + + @Test + void handlePostCommand() { + doNothing().when(commandServiceMock).postCommand(routingContextMock); + + deviceCommandsHandler.handlePostCommand(routingContextMock); + + verify(commandServiceMock, times(1)).postCommand(routingContextMock); + } + +} diff --git a/device-communication/src/test/java/org/eclipse/hono/communication/api/handler/DeviceConfigsHandlerTest.java b/device-communication/src/test/java/org/eclipse/hono/communication/api/handler/DeviceConfigsHandlerTest.java new file mode 100644 index 00000000..e1a65770 --- /dev/null +++ b/device-communication/src/test/java/org/eclipse/hono/communication/api/handler/DeviceConfigsHandlerTest.java @@ -0,0 +1,238 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.handler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.eclipse.hono.communication.api.config.DeviceConfigsConstants; +import org.eclipse.hono.communication.api.data.DeviceConfig; +import org.eclipse.hono.communication.api.data.DeviceConfigRequest; +import org.eclipse.hono.communication.api.data.ListDeviceConfigVersionsResponse; +import org.eclipse.hono.communication.api.service.DeviceConfigService; +import org.eclipse.hono.communication.api.service.DeviceConfigServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RequestBody; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.openapi.Operation; +import io.vertx.ext.web.openapi.RouterBuilder; + + +class DeviceConfigsHandlerTest { + + private final DeviceConfigService configServiceMock; + private final RouterBuilder routerBuilderMock; + private final RoutingContext routingContextMock; + private final Operation operationMock; + private final RequestBody requestBodyMock; + private final DeviceConfigsHandler deviceConfigsHandler; + private final JsonObject jsonObjMock; + private final HttpServerResponse httpServerResponseMock; + private final IllegalArgumentException illegalArgumentExceptionMock; + + private final String tenantID = "tenant_ID"; + private final String deviceID = "device_ID"; + private final String errorMsg = "test_error"; + private final DeviceConfigRequest deviceConfigRequest = new DeviceConfigRequest("1", "binary_data"); + private final DeviceConfig deviceConfigEntity = new DeviceConfig(); + private final DeviceConfig deviceConfig = new DeviceConfig(); + + DeviceConfigsHandlerTest() { + operationMock = mock(Operation.class); + configServiceMock = mock(DeviceConfigServiceImpl.class); + routerBuilderMock = mock(RouterBuilder.class); + routingContextMock = mock(RoutingContext.class); + requestBodyMock = mock(RequestBody.class); + jsonObjMock = mock(JsonObject.class); + httpServerResponseMock = mock(HttpServerResponse.class); + illegalArgumentExceptionMock = mock(IllegalArgumentException.class); + deviceConfigsHandler = new DeviceConfigsHandler(configServiceMock); + + deviceConfigEntity.setVersion("1"); + + + deviceConfig.setVersion(""); + + } + + @BeforeEach + void setUp() { + + } + + @AfterEach + void tearDown() { + verifyNoMoreInteractions( + configServiceMock, + routerBuilderMock, + routingContextMock, + operationMock, + requestBodyMock, + jsonObjMock, + httpServerResponseMock, + illegalArgumentExceptionMock); + } + + @Test + void addRoutes() { + when(routerBuilderMock.operation(anyString())).thenReturn(operationMock); + when(operationMock.handler(any())).thenReturn(operationMock); + + deviceConfigsHandler.addRoutes(routerBuilderMock); + + verify(routerBuilderMock, times(1)).operation(DeviceConfigsConstants.LIST_CONFIG_VERSIONS_OP_ID); + verify(routerBuilderMock, times(1)).operation(DeviceConfigsConstants.POST_MODIFY_DEVICE_CONFIG_OP_ID); + verify(operationMock, times(2)).handler(any()); + } + + @Test + void handleModifyCloudToDeviceConfig_success() { + when(routingContextMock.pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS)).thenReturn(tenantID); + when(routingContextMock.pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS)).thenReturn(deviceID); + when(routingContextMock.body()).thenReturn(requestBodyMock); + when(requestBodyMock.asJsonObject()).thenReturn(jsonObjMock); + when(jsonObjMock.mapTo(DeviceConfigRequest.class)).thenReturn(deviceConfigRequest); + when(configServiceMock.modifyCloudToDeviceConfig(deviceConfigRequest, deviceID, tenantID)).thenReturn(Future.succeededFuture(deviceConfigEntity)); + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.setStatusCode(anyInt())).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.putHeader("Content-Type", + "application/json")).thenReturn(httpServerResponseMock); + + final var results = deviceConfigsHandler.handleModifyCloudToDeviceConfig(routingContextMock); + + verify(configServiceMock).modifyCloudToDeviceConfig(deviceConfigRequest, deviceID, tenantID); + verify(routingContextMock, times(1)).pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS); + verify(routingContextMock, times(1)).pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS); + verify(routingContextMock, times(1)).body(); + verify(requestBodyMock, times(1)).asJsonObject(); + verify(jsonObjMock, times(1)).mapTo(DeviceConfigRequest.class); + verifySuccessResponse(results, deviceConfigEntity); + + } + + + @Test + void handleModifyCloudToDeviceConfig_failure() { + when(routingContextMock.pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS)).thenReturn(tenantID); + when(routingContextMock.pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS)).thenReturn(deviceID); + when(routingContextMock.body()).thenReturn(requestBodyMock); + when(requestBodyMock.asJsonObject()).thenReturn(jsonObjMock); + when(jsonObjMock.mapTo(DeviceConfigRequest.class)).thenReturn(deviceConfigRequest); + when(illegalArgumentExceptionMock.getMessage()).thenReturn(errorMsg); + when(configServiceMock.modifyCloudToDeviceConfig(deviceConfigRequest, deviceID, tenantID)).thenReturn(Future.failedFuture(illegalArgumentExceptionMock)); + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.setStatusCode(anyInt())).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.putHeader("Content-Type", + "application/json")).thenReturn(httpServerResponseMock); + + final var results = deviceConfigsHandler.handleModifyCloudToDeviceConfig(routingContextMock); + + verify(configServiceMock).modifyCloudToDeviceConfig(deviceConfigRequest, deviceID, tenantID); + verify(routingContextMock, times(1)).pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS); + + verify(routingContextMock, times(1)).pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS); + verify(routingContextMock, times(1)).body(); + verify(requestBodyMock, times(1)).asJsonObject(); + verify(jsonObjMock, times(1)).mapTo(DeviceConfigRequest.class); + + verifyErrorResponse(results); + + + } + + @Test + void handleListConfigVersions_success() { + final var listDeviceConfigVersionsResponse = new ListDeviceConfigVersionsResponse(List.of(deviceConfig)); + final MultiMap queryParams = MultiMap.caseInsensitiveMultiMap().add(DeviceConfigsConstants.NUM_VERSION_QUERY_PARAMS, String.valueOf(10)); + when(routingContextMock.queryParams()).thenReturn(queryParams); + when(routingContextMock.pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS)).thenReturn(tenantID); + when(routingContextMock.pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS)).thenReturn(deviceID); + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.setStatusCode(anyInt())).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.putHeader("Content-Type", + "application/json")).thenReturn(httpServerResponseMock); + when(configServiceMock.listAll(deviceID, tenantID, 10)).thenReturn(Future.succeededFuture(listDeviceConfigVersionsResponse)); + + final var results = deviceConfigsHandler.handleListConfigVersions(routingContextMock); + + verify(configServiceMock, times(1)).listAll(deviceID, tenantID, 10); + verify(routingContextMock, times(1)).queryParams(); + verify(routingContextMock, times(1)).pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS); + verify(routingContextMock, times(1)).pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS); + + verifySuccessResponse(results, listDeviceConfigVersionsResponse); + + } + + + @Test + void handleListConfigVersions_failed() { + final MultiMap queryParams = MultiMap.caseInsensitiveMultiMap().add(DeviceConfigsConstants.NUM_VERSION_QUERY_PARAMS, String.valueOf(10)); + when(routingContextMock.queryParams()).thenReturn(queryParams); + when(routingContextMock.pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS)).thenReturn(tenantID); + when(routingContextMock.pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS)).thenReturn(deviceID); + when(routingContextMock.response()).thenReturn(httpServerResponseMock); + when(httpServerResponseMock.setStatusCode(anyInt())).thenReturn(httpServerResponseMock); + when(illegalArgumentExceptionMock.getMessage()).thenReturn(errorMsg); + when(httpServerResponseMock.putHeader("Content-Type", + "application/json")).thenReturn(httpServerResponseMock); + when(configServiceMock.listAll(deviceID, tenantID, 10)).thenReturn(Future.failedFuture(illegalArgumentExceptionMock)); + + final var results = deviceConfigsHandler.handleListConfigVersions(routingContextMock); + + verify(configServiceMock, times(1)).listAll(deviceID, tenantID, 10); + verify(routingContextMock, times(1)).queryParams(); + verify(routingContextMock, times(1)).pathParam(DeviceConfigsConstants.TENANT_PATH_PARAMS); + + verify(routingContextMock, times(1)).pathParam(DeviceConfigsConstants.DEVICE_PATH_PARAMS); + verifyErrorResponse(results); + + + } + + + void verifyErrorResponse(final Future results) { + verify(routingContextMock, times(1)).response(); + verify(httpServerResponseMock).setStatusCode(400); + verify(illegalArgumentExceptionMock).getMessage(); + verify(httpServerResponseMock).putHeader("Content-Type", + "application/json"); + verify(httpServerResponseMock).end(new JsonObject().put("error", errorMsg).encodePrettily()); + Assertions.assertTrue(results.failed()); + } + + void verifySuccessResponse(final Future results, final Object responseObj) { + verify(routingContextMock, times(1)).response(); + verify(httpServerResponseMock).setStatusCode(200); + verify(httpServerResponseMock).putHeader("Content-Type", + "application/json"); + verify(httpServerResponseMock).end(Json.encodePrettily(responseObj)); + Assertions.assertTrue(results.succeeded()); + } +} diff --git a/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreatorImplTest.java b/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreatorImplTest.java new file mode 100644 index 00000000..24c9cb8d --- /dev/null +++ b/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DatabaseSchemaCreatorImplTest.java @@ -0,0 +1,93 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.file.FileSystem; +import io.vertx.pgclient.PgPool; + + +class DatabaseSchemaCreatorImplTest { + + private final Vertx vertxMock; + private final DatabaseService dbMock; + private final FileSystem fileSystemMock; + private final PgPool pgPoolMock; + + private final DatabaseSchemaCreatorImpl databaseSchemaCreator; + + DatabaseSchemaCreatorImplTest() { + this.dbMock = mock(DatabaseService.class); + this.vertxMock = mock(Vertx.class); + this.fileSystemMock = mock(FileSystem.class); + this.pgPoolMock = mock(PgPool.class); + + this.databaseSchemaCreator = new DatabaseSchemaCreatorImpl(vertxMock, dbMock); + } + + @BeforeEach + void setUp() { + } + + @AfterEach + void tearDown() { + verifyNoMoreInteractions(vertxMock, dbMock, pgPoolMock, fileSystemMock); + } + + @Test + void createDBTables_success() { + when(vertxMock.fileSystem()).thenReturn(fileSystemMock); + when(fileSystemMock.readFile(anyString(), any())).thenReturn(fileSystemMock); + when(dbMock.getDbClient()).thenReturn(pgPoolMock); + when(pgPoolMock.withTransaction(any())).thenReturn(Future.succeededFuture()); + + databaseSchemaCreator.createDBTables(); + + verify(vertxMock).fileSystem(); + verify(fileSystemMock).readFile(anyString(), any()); + verify(dbMock).getDbClient(); + verify(pgPoolMock).withTransaction(any()); + + } + + + @Test + void createDBTables_failed() { + when(vertxMock.fileSystem()).thenReturn(fileSystemMock); + when(fileSystemMock.readFile(anyString(), any())).thenReturn(fileSystemMock); + when(dbMock.getDbClient()).thenReturn(pgPoolMock); + when(pgPoolMock.withTransaction(any())).thenReturn(Future.failedFuture(new Throwable())); + + databaseSchemaCreator.createDBTables(); + + verify(vertxMock).fileSystem(); + verify(fileSystemMock).readFile(anyString(), any()); + verify(dbMock).getDbClient(); + verify(dbMock).close(); + verify(pgPoolMock).withTransaction(any()); + + } +} diff --git a/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DatabaseServiceImplTest.java b/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DatabaseServiceImplTest.java new file mode 100644 index 00000000..886c2259 --- /dev/null +++ b/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DatabaseServiceImplTest.java @@ -0,0 +1,83 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import static org.mockito.Mockito.*; + +import org.eclipse.hono.communication.core.app.DatabaseConfig; +import org.eclipse.hono.communication.core.utils.DbUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import io.vertx.core.Vertx; +import io.vertx.pgclient.PgPool; + + +class DatabaseServiceImplTest { + + private final Vertx vertxMock; + private final DatabaseConfig databaseConfigMock; + + private final PgPool pgPoolMock; + private DatabaseService databaseService; + + DatabaseServiceImplTest() { + vertxMock = mock(Vertx.class); + databaseConfigMock = mock(DatabaseConfig.class); + pgPoolMock = mock(PgPool.class); + + + } + + @AfterEach + void tearDown() { + verifyNoMoreInteractions(vertxMock, databaseConfigMock, pgPoolMock); + + } + + + @Test + void getDbClient() { + try (MockedStatic dbUtilsMockedStatic = mockStatic(DbUtils.class)) { + dbUtilsMockedStatic.when(() -> DbUtils.createDbClient(vertxMock, databaseConfigMock)).thenReturn(pgPoolMock); + databaseService = new DatabaseServiceImpl(databaseConfigMock, vertxMock); + + final var client = databaseService.getDbClient(); + + Assertions.assertSame(client, pgPoolMock); + + dbUtilsMockedStatic.verify(() -> DbUtils.createDbClient(vertxMock, databaseConfigMock), times(1)); + dbUtilsMockedStatic.verifyNoMoreInteractions(); + } + } + + @Test + void close() { + + try (MockedStatic dbUtilsMockedStatic = mockStatic(DbUtils.class)) { + dbUtilsMockedStatic.when(() -> DbUtils.createDbClient(vertxMock, databaseConfigMock)).thenReturn(pgPoolMock); + databaseService = new DatabaseServiceImpl(databaseConfigMock, vertxMock); + + databaseService.close(); + verify(pgPoolMock, times(1)).close(); + dbUtilsMockedStatic.verify(() -> DbUtils.createDbClient(vertxMock, databaseConfigMock), times(1)); + dbUtilsMockedStatic.verifyNoMoreInteractions(); + } + } +} diff --git a/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DeviceConfigServiceImplTest.java b/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DeviceConfigServiceImplTest.java new file mode 100644 index 00000000..56b767fa --- /dev/null +++ b/device-communication/src/test/java/org/eclipse/hono/communication/api/service/DeviceConfigServiceImplTest.java @@ -0,0 +1,127 @@ +/* + * *********************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + *

+ * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + *

+ * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + *

+ * SPDX-License-Identifier: EPL-2.0 + * ********************************************************** + * + */ + +package org.eclipse.hono.communication.api.service; + +import static org.mockito.Mockito.*; + +import org.eclipse.hono.communication.api.data.DeviceConfig; +import org.eclipse.hono.communication.api.data.DeviceConfigEntity; +import org.eclipse.hono.communication.api.data.DeviceConfigRequest; +import org.eclipse.hono.communication.api.data.ListDeviceConfigVersionsResponse; +import org.eclipse.hono.communication.api.mapper.DeviceConfigMapper; +import org.eclipse.hono.communication.api.repository.DeviceConfigsRepository; +import org.eclipse.hono.communication.api.repository.DeviceConfigsRepositoryImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.vertx.core.Future; +import io.vertx.pgclient.PgPool; + + +class DeviceConfigServiceImplTest { + + private final DeviceConfigsRepository repositoryMock; + private final DatabaseService dbMock; + private final PgPool poolMock; + private final DeviceConfigMapper mapperMock; + private final DeviceConfigService deviceConfigService; + + private final String tenantId = "tenant_ID"; + private final String deviceId = "device_ID"; + + DeviceConfigServiceImplTest() { + this.repositoryMock = mock(DeviceConfigsRepositoryImpl.class); + this.dbMock = mock(DatabaseServiceImpl.class); + this.mapperMock = mock(DeviceConfigMapper.class); + this.poolMock = mock(PgPool.class); + this.deviceConfigService = new DeviceConfigServiceImpl(repositoryMock, dbMock, mapperMock); + } + + + @AfterEach + void tearDown() { + verifyNoMoreInteractions(repositoryMock, mapperMock, dbMock); + } + + @Test + void modifyCloudToDeviceConfig_success() { + final var deviceConfigRequest = new DeviceConfigRequest(); + final var deviceConfigEntity = new DeviceConfigEntity(); + final var deviceConfigEntityResponse = new DeviceConfig(); + when(mapperMock.configRequestToDeviceConfigEntity(deviceConfigRequest)).thenReturn(deviceConfigEntity); + when(mapperMock.deviceConfigEntityToConfig(deviceConfigEntity)).thenReturn(deviceConfigEntityResponse); + when(dbMock.getDbClient()).thenReturn(poolMock); + when(poolMock.withTransaction(any())).thenReturn(Future.succeededFuture(deviceConfigEntity)); + + + final var results = deviceConfigService.modifyCloudToDeviceConfig(deviceConfigRequest, deviceId, tenantId); + + verify(mapperMock, times(1)).configRequestToDeviceConfigEntity(deviceConfigRequest); + verify(mapperMock, times(1)).deviceConfigEntityToConfig(deviceConfigEntity); + verify(dbMock, times(1)).getDbClient(); + verify(poolMock, times(1)).withTransaction(any()); + Assertions.assertTrue(results.succeeded()); + } + + @Test + void modifyCloudToDeviceConfig_failure() { + final var deviceConfigRequest = new DeviceConfigRequest(); + final var deviceConfigEntity = new DeviceConfigEntity(); + when(mapperMock.configRequestToDeviceConfigEntity(deviceConfigRequest)).thenReturn(deviceConfigEntity); + when(dbMock.getDbClient()).thenReturn(poolMock); + when(poolMock.withTransaction(any())).thenReturn(Future.failedFuture(new Throwable("test_error"))); + + + final var results = deviceConfigService.modifyCloudToDeviceConfig(deviceConfigRequest, deviceId, tenantId); + + verify(mapperMock, times(1)).configRequestToDeviceConfigEntity(deviceConfigRequest); + verify(dbMock, times(1)).getDbClient(); + verify(poolMock, times(1)).withTransaction(any()); + Assertions.assertTrue(results.failed()); + } + + @Test + void listAll_success() { + final var deviceConfigVersions = new ListDeviceConfigVersionsResponse(); + when(dbMock.getDbClient()).thenReturn(poolMock); + when(poolMock.withConnection(any())).thenReturn(Future.succeededFuture(deviceConfigVersions)); + + final var results = deviceConfigService.listAll(deviceId, tenantId, 10); + + verify(dbMock, times(1)).getDbClient(); + verify(poolMock, times(1)).withConnection(any()); + Assertions.assertTrue(results.succeeded()); + + + } + + + @Test + void listAll_failed() { + when(dbMock.getDbClient()).thenReturn(poolMock); + when(poolMock.withConnection(any())).thenReturn(Future.failedFuture(new Throwable("test_error"))); + + final var results = deviceConfigService.listAll(deviceId, tenantId, 10); + + verify(dbMock, times(1)).getDbClient(); + verify(poolMock, times(1)).withConnection(any()); + Assertions.assertTrue(results.failed()); + + + } +} diff --git a/device-communication/src/test/resources/application.yaml b/device-communication/src/test/resources/application.yaml new file mode 100644 index 00000000..a2f4cbaf --- /dev/null +++ b/device-communication/src/test/resources/application.yaml @@ -0,0 +1,16 @@ +app: + name: "Device Communication" +vertx: + openapi: + file: "/api/hono-device-communication-v1.yaml" + server: + url: "localhost" + port: 8081 + +"%test": + app: + name: "Device Communication" + vertx: + server: + url: "localhost" + port: "8081" \ No newline at end of file