소스 검색

Core of the project and implemented EmailService

Lukas Cerny 1 년 전
부모
커밋
57af7d2cfe
29개의 변경된 파일1464개의 추가작업 그리고 20개의 파일을 삭제
  1. 38 0
      Dockerfile
  2. 53 5
      build.gradle
  3. 7 9
      config/config.yaml
  4. 20 0
      docker-compose.yaml
  5. 2 0
      docker.dev.env
  6. 6 0
      gradle.properties
  7. 2 0
      local.dev.env
  8. 127 0
      src/main/java/cz/senslog/messaging/app/Application.java
  9. 255 0
      src/main/java/cz/senslog/messaging/app/ConfigProperty.java
  10. 147 0
      src/main/java/cz/senslog/messaging/app/Configuration.java
  11. 33 0
      src/main/java/cz/senslog/messaging/app/EnvironmentalProperty.java
  12. 7 6
      src/main/java/cz/senslog/messaging/app/Main.java
  13. 51 0
      src/main/java/cz/senslog/messaging/app/VertxDeployer.java
  14. 32 0
      src/main/java/cz/senslog/messaging/domain/ChannelConfig.java
  15. 18 0
      src/main/java/cz/senslog/messaging/domain/MessageLengthType.java
  16. 41 0
      src/main/java/cz/senslog/messaging/domain/ServiceConfig.java
  17. 6 0
      src/main/java/cz/senslog/messaging/domain/ServiceType.java
  18. 34 0
      src/main/java/cz/senslog/messaging/domain/SubscriberConfig.java
  19. 44 0
      src/main/java/cz/senslog/messaging/server/ExceptionHandler.java
  20. 25 0
      src/main/java/cz/senslog/messaging/server/HttpIllegalArgumentException.java
  21. 134 0
      src/main/java/cz/senslog/messaging/server/HttpVertxServer.java
  22. 52 0
      src/main/java/cz/senslog/messaging/service/AbstractService.java
  23. 110 0
      src/main/java/cz/senslog/messaging/service/EmailService.java
  24. 25 0
      src/main/java/cz/senslog/messaging/service/ServiceCreator.java
  25. 11 0
      src/main/java/cz/senslog/messaging/utils/ResourcesUtils.java
  26. 8 0
      src/main/java/cz/senslog/messaging/utils/ServiceUtil.java
  27. 23 0
      src/main/java/cz/senslog/messaging/utils/Tuple.java
  28. 19 0
      src/main/resources/log4j2.xml
  29. 134 0
      src/main/resources/openAPISpec.yaml

+ 38 - 0
Dockerfile

@@ -0,0 +1,38 @@
+FROM openjdk:17 AS builder
+
+COPY src /app/src
+COPY gradle /app/gradle
+COPY build.gradle settings.gradle gradle.properties gradlew /app/
+WORKDIR /app
+RUN ./gradlew assemble
+
+FROM openjdk:17 AS test
+
+COPY --from=builder /app/build /app/build
+COPY --from=builder /app/gradle /app/gradle
+COPY --from=builder /app/build.gradle /app/
+COPY --from=builder /app/settings.gradle /app/
+COPY --from=builder /app/gradle.properties /app/
+COPY --from=builder /app/gradlew /app/
+
+WORKDIR /app
+
+RUN ./gradlew test
+
+FROM openjdk:17-jdk-slim-buster AS production
+
+COPY --from=builder /app/build/libs/ /app/
+COPY --from=builder /app/gradle.properties /app/
+
+WORKDIR /app
+
+CMD java -cp "messaging.jar" cz.senslog.messaging.app.Main
+
+FROM openjdk:17-jdk-slim-buster AS dev-debug
+
+COPY --from=builder /app/build/libs/ /app/
+COPY --from=builder /app/gradle.properties /app/
+
+WORKDIR /app
+
+CMD java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -cp "messaging.jar" cz.senslog.messaging.app.Main

+ 53 - 5
build.gradle

@@ -1,19 +1,67 @@
+import java.time.Instant
+
 plugins {
     id 'java'
+    id 'application'
 }
 
-group = 'cz.senslog'
-version = '1.0-SNAPSHOT'
+
+group projectGroup
+version projectVersion
+
+application {
+    mainClass = 'cz.senslog.messaging.app.Main'
+}
 
 repositories {
+    mavenLocal()
     mavenCentral()
 }
 
-dependencies {
-    testImplementation platform('org.junit:junit-bom:5.9.1')
-    testImplementation 'org.junit.jupiter:junit-jupiter'
+java {
+    sourceCompatibility = JavaVersion.VERSION_17
+    targetCompatibility = JavaVersion.VERSION_17
+}
+
+build {
+    doLast {
+        ant.propertyfile(file: "gradle.properties") {
+            entry( key: "buildVersion", value: Instant.now().getEpochSecond())
+        }
+    }
+}
+
+jar {
+    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+    manifest {
+        attributes 'Implementation-Title': 'SensLog Messaging',
+                'Implementation-Version': version,
+                'Main-Class': 'cz.senslog.messaging.app.Main'
+    }
+    from {
+        configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
+    }
 }
 
+jar.archiveFileName = "messaging.jar"
+
 test {
     useJUnitPlatform()
+}
+
+
+dependencies {
+    implementation 'org.apache.logging.log4j:log4j-api:2.22.1'
+    implementation 'org.apache.logging.log4j:log4j-core:2.22.1'
+
+    implementation group: 'org.yaml', name: 'snakeyaml', version: '2.2'
+
+    implementation 'io.vertx:vertx-core:4.5.3'
+    implementation 'io.vertx:vertx-web:4.5.3'
+    implementation 'io.vertx:vertx-web-openapi:4.5.3'
+
+    implementation 'org.eclipse.angus:angus-mail:2.0.2'
+
+    testImplementation platform('org.junit:junit-bom:5.9.1')
+    testImplementation 'org.junit.jupiter:junit-jupiter'
 }

+ 7 - 9
config/config.yaml

@@ -1,15 +1,14 @@
 
-handlers:
+subscribers:
   lspReportHandler:
     service: lspEmailService
-    template: "default"
     senderEmail: "watchdog@senslog.org"
+    subject: "[Watchdog] Report"
     recipientEmail:
-      - email: "servis@lesprojekt.cz"
-        hideEmail: false
       - email: "dev@lukascerny.me"
         hideEmail: true
-    subject: "[Watchdog] $group.name - $result - Report"
+      - email: "luc.cerny@gmail.com"
+        hideEmail: false
 
   lspEmergencyHandler:
     service: lspWhatsAppService
@@ -40,11 +39,10 @@ services:
 channels:
   report_rostenice:
     messageType: "LARGE"
-    handlers:
+    subscribers:
       - lspReportHandler
 
   alert_rostenice:
     messageType: "TINY"
-    handlers:
-      - lspWhatsAppService
-      - lspSMSService
+    subscribers:
+      - lspEmergencyHandler

+ 20 - 0
docker-compose.yaml

@@ -0,0 +1,20 @@
+version: "3.9"
+
+services:
+  messaging-prod:
+    container_name: senslog_messaging
+    image: senslog/messaging
+    build:
+      target: production
+      context: .
+
+  messaging-dev:
+    container_name: senslog_messaging_dev
+    image: senslog/messaging-dev
+    build:
+      target: dev-debug
+      context: .
+    env_file:
+      - docker.dev.env
+    ports:
+      - "8080:8080"

+ 2 - 0
docker.dev.env

@@ -0,0 +1,2 @@
+CONFIG_FILE_PATH=./config.yaml
+SERVER_HTTP_PORT=8080

+ 6 - 0
gradle.properties

@@ -0,0 +1,6 @@
+#Sat, 01 Apr 2023 19:58:59 +0200
+projectGroup=cz.senslog
+projectName=messaging
+projectVersion=0.0.2
+
+buildVersion=1680371939

+ 2 - 0
local.dev.env

@@ -0,0 +1,2 @@
+CONFIG_FILE_PATH=./config/config.yaml
+SERVER_HTTP_PORT=8080

+ 127 - 0
src/main/java/cz/senslog/messaging/app/Application.java

@@ -0,0 +1,127 @@
+package cz.senslog.messaging.app;
+
+
+import cz.senslog.messaging.domain.ServiceConfig;
+import cz.senslog.messaging.domain.SubscriberConfig;
+import cz.senslog.messaging.server.HttpVertxServer;
+import io.vertx.core.AbstractVerticle;
+import io.vertx.core.DeploymentOptions;
+import io.vertx.core.Vertx;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+
+import static cz.senslog.messaging.service.ServiceCreator.createServices;
+import static cz.senslog.messaging.utils.ServiceUtil.createServiceSubscriberId;
+
+
+public final class Application {
+
+    private static final Logger logger = LogManager.getLogger(Application.class);
+
+    private static final String DEFAULT_UNKNOWN = "unknown";
+    private static final long START_UP;
+    public static String COMPILED_VERSION;
+    public static String BUILD_VERSION;
+    public static String PROJECT_NAME;
+
+    static {
+        START_UP = System.currentTimeMillis();
+        try {
+            InputStream input = new FileInputStream("gradle.properties");
+            Properties prop = new Properties();
+            prop.load(input);
+            PROJECT_NAME = prop.getProperty("projectName", DEFAULT_UNKNOWN);
+            COMPILED_VERSION = prop.getProperty("projectVersion", DEFAULT_UNKNOWN);
+            BUILD_VERSION = prop.getProperty("buildVersion", DEFAULT_UNKNOWN);
+        } catch (IOException ex) {
+            logger.catching(ex);
+        }
+    }
+
+    public static long uptime() {
+        return System.currentTimeMillis() - START_UP;
+    }
+
+    public static String uptimeFormatted() {
+        Duration duration = Duration.ofMillis(uptime());
+        long HH = duration.toHours();
+        long MM = duration.toMinutesPart();
+        long SS = duration.toSecondsPart();
+        return String.format("%02d:%02d:%02d", HH, MM, SS);
+    }
+
+    public static void terminate(String message) {
+        logger.error(message);
+        System.exit(1);
+    }
+
+    public static void start() {
+        logger.info("Starting app '{}', version '{}', build '{}'.", PROJECT_NAME, COMPILED_VERSION, BUILD_VERSION);
+
+        EnvironmentalProperty envProperties = EnvironmentalProperty.getInstance();
+
+        Configuration configuration;
+        try {
+            configuration = Configuration.createFromYaml(envProperties.getConfigPath());
+        } catch (IOException e) {
+            terminate(e.getMessage()); return;
+        }
+
+        JsonArray channels = new JsonArray(configuration.getChannelsById().values().stream().map(ch -> JsonObject.of(
+                "id", ch.id(),
+                "maxLength", ch.messageType().maxLength(),
+                "subscribers", new JsonArray(ch.subscribers().stream()
+                        .map(s -> JsonObject.of(
+                                "id", createServiceSubscriberId(s.service().id(), s.id()),
+                                "serviceId", s.service().id(),
+                                "subscriberId", s.id()
+                        )).toList())
+        )).toList());
+
+        JsonObject services = new JsonObject();
+        for (ServiceConfig service : configuration.getServicesById().values()) {
+            services.put(service.id(), JsonObject.of(
+                    "config", service.config(),
+                    "subscribedRecipients", new JsonArray()
+            ));
+        }
+        for (SubscriberConfig subscriber : configuration.getSubscribersById().values()) {
+            services.getJsonObject(subscriber.service().id()).getJsonArray("subscribedRecipients").add(JsonObject.of(
+                    "id", subscriber.id(),
+                    "config", subscriber.config()
+            ));
+        }
+
+        DeploymentOptions opt = new DeploymentOptions()
+                .setConfig(envProperties.asJson().mergeIn(JsonObject.of(
+                        "channels", channels,
+                        "services", services
+                )));
+
+        List<AbstractVerticle> verticles = new ArrayList<>();
+
+        verticles.add(new HttpVertxServer());
+        verticles.addAll(createServices(configuration.getServicesById().values()));
+
+        Vertx.vertx().deployVerticle(VertxDeployer.deploy(verticles.toArray(AbstractVerticle[]::new)), opt, res -> {
+            if(res.succeeded()) {
+                logger.info("Deployment id is: {}", res.result());
+                logger.info("Started in {} second.", uptime() / 1000.0);
+            } else {
+                logger.error("Deployment failed! The reason is '{}'", res.cause().getMessage());
+                logger.catching(res.cause());
+            }
+        });
+    }
+}

+ 255 - 0
src/main/java/cz/senslog/messaging/app/ConfigProperty.java

@@ -0,0 +1,255 @@
+// Copyright (c) 2020 UWB & LESP.
+// The UWB & LESP license this file to you under the MIT license.
+
+package cz.senslog.messaging.app;
+
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.util.*;
+
+import static java.lang.String.format;
+import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+import static java.util.Optional.*;
+
+/**
+ *
+ * @author Lukas Cerny
+ * @version 1.0
+ * @since 1.0
+ */
+public class ConfigProperty {
+
+    /** Path delimiter separates nodes. */
+    private static final String PATH_DELIMITER = ".";
+
+    /** Identifier of path. */
+    private final String id;
+
+    private final String fullId;
+
+    /** Map of properties. */
+    private final Map<String, Object> properties;
+
+    /**
+     * Constructor sets new identifier of node.
+     * @param id - identifier of node.
+     */
+    protected ConfigProperty(String id, String fullId) {
+        this.id = id;
+        this.fullId = fullId;
+        this.properties = new HashMap<>();
+    }
+
+    /**
+     * Adds new property to properties.
+     * @param name - name of new property.
+     */
+    public void setProperty(String name, Object value) {
+        properties.put(name, value);
+    }
+
+    /**
+     * Returns value. It could be anything.
+     * @param name - name of property.
+     * @return object of value.
+     */
+    public Object getProperty(String name) {
+        if (properties.containsKey(name)) {
+            return properties.get(name);
+        }
+
+        throw new IllegalArgumentException(format(
+                "Property '%s' does not exist.", getNewPropertyId(name))
+        );
+    }
+
+    /**
+     * Checks if property key is presents in properties.
+     * @param name - name of property
+     * @return boolean
+     */
+    public boolean containsProperty(String name) {
+        return properties.containsKey(name);
+    }
+
+    /**
+     * Returns optional value. It could be anything.
+     * @param name - name of property.
+     * @return optional object
+     */
+    @SuppressWarnings("unchecked")
+    public <T> Optional<T> getOptionalProperty(String name, Class<T> aClass) {
+        Object value = properties.get(name);
+        if (value == null || value.getClass() != aClass) {
+            return empty();
+        }
+
+        return of((T)value);
+    }
+
+    @SuppressWarnings("unchecked")
+    public <T> List<T> getArrayPropertyOf(String name, Class<T> aClass) {
+        Object value = getProperty(name);
+        List<?> list = value instanceof List ? (List<?>)value : null;
+        if (list == null) {
+            throw new IllegalArgumentException("Cast error " + value.getClass() + " to " + List.class);
+        }
+        List<T> result = new ArrayList<>(list.size());
+        for (Object v : list) {
+            if (v.getClass() == aClass) {
+                result.add((T)v);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Returns property as a String.
+     * @param name - name of property.
+     * @return string value.
+     */
+    public String getStringProperty(String name) {
+        Object value = getProperty(name);
+        if (value instanceof String) {
+            return (String)value;
+        }
+        throw new ClassCastException(format(
+                "Value '%s' can not be cast to String", value
+        ));
+    }
+
+    public boolean getBooleanProperty(String name) {
+        Object value = getProperty(name);
+        if (value instanceof Boolean) {
+            return (Boolean)value;
+        }
+        throw new ClassCastException(format(
+                "Value '%s' can not be cast to Boolean", value
+        ));
+    }
+
+    /**
+     * Returns property as an Integer.
+     * @param name - name of property.
+     * @return integer value.
+     */
+    public Integer getIntegerProperty(String name) {
+        Object value = getProperty(name);
+        if (value instanceof Integer) {
+            return (Integer)value;
+        }
+        throw new ClassCastException(format(
+                "Value '%s' can not be cast to Integer", value
+        ));
+    }
+
+    /**
+     * Returns property as an Integer. If the property is null, will return default value.
+     * @param name - name of property.
+     * @param defaultValue - default value if the property is null
+     * @return integer value
+     */
+    public Integer getIntegerProperty(String name, int defaultValue) {
+        Object value = getProperty(name);
+        if (value instanceof Integer) {
+            return (Integer)value;
+        }
+
+        return defaultValue;
+    }
+
+    /**
+     * Returns property as a LocalDateTime.
+     * @param name - name of property.
+     * @return localDateTime value.
+     */
+    public LocalDateTime getLocalDateTimeProperty(String name) {
+        Object object = getProperty(name);
+
+        if (object instanceof LocalDateTime) {
+            return (LocalDateTime) object;
+        } else if (object instanceof Date) {
+            Date date = (Date) object;
+            return date.toInstant().atZone(ZoneOffset.systemDefault()).toLocalDateTime();
+        } else if (object instanceof String) {
+            return LocalDateTime.parse((String)object, DateTimeFormatter.ISO_DATE_TIME);
+        } else {
+            throw new ClassCastException(format(
+                    "Property '%s' can not be cast to %s", getNewPropertyId(name), LocalDateTime.class)
+            );
+        }
+    }
+
+    public LocalTime getLocalTimeProperty(String name) {
+        Object object = getProperty(name);
+
+        if (object instanceof LocalTime) {
+            return (LocalTime) object;
+        } else if (object instanceof String) {
+            return LocalTime.parse((String) object);
+        } else {
+            throw new ClassCastException(format(
+                    "Property '%s' can not be cast to %s", getNewPropertyId(name), LocalTime.class)
+            );
+        }
+    }
+
+    public ZonedDateTime getZonedDateTimeProperty(String name) {
+        Object object = getProperty(name);
+
+        if (object instanceof ZonedDateTime) {
+            return (ZonedDateTime)object;
+        } else if (object instanceof String) {
+            final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
+                    .append(ISO_LOCAL_DATE_TIME)
+                    .optionalStart()
+                    .appendLiteral('[')
+                    .parseCaseSensitive()
+                    .appendZoneRegionId()
+                    .appendLiteral(']')
+                    .toFormatter();
+            return ZonedDateTime.parse((String)object, formatter);
+        } else {
+            throw new ClassCastException(format(
+                    "Property '%s' can not be cast to %s", getNewPropertyId(name), ZonedDateTime.class)
+            );
+        }
+    }
+
+    /**
+     * Returns new node of configuration.
+     * @param name - name of property.
+     * @return node of configuration.
+     */
+    public ConfigProperty getConfigProperty(String name) {
+        Object property = getProperty(name);
+        ConfigProperty config = new ConfigProperty(name, getNewPropertyId(name));
+
+        if (property instanceof Map) {
+            Map<?, ?> properties = (Map<?, ?>) property;
+            for (Map.Entry<?, ?> propertyEntry : properties.entrySet()) {
+                Object propertyName = propertyEntry.getKey();
+                config.setProperty(propertyName.toString(), propertyEntry.getValue());
+            }
+        }
+
+        return config;
+    }
+
+    public Set<String> getAttributes() {
+        return properties.keySet();
+    }
+
+    private String getNewPropertyId(String name) {
+        return fullId + PATH_DELIMITER + name;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+}

+ 147 - 0
src/main/java/cz/senslog/messaging/app/Configuration.java

@@ -0,0 +1,147 @@
+package cz.senslog.messaging.app;
+
+import cz.senslog.messaging.domain.*;
+import cz.senslog.messaging.server.HttpVertxServer;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+
+import static java.util.stream.Collectors.toSet;
+
+public final class Configuration {
+
+    private static final Logger logger = LogManager.getLogger(HttpVertxServer.class);
+
+    public static Configuration createFromYaml(String path) throws IOException {
+        logger.info("Loading '{}' configuration file.", path);
+
+        if (!path.toLowerCase().endsWith(".yaml")) {
+            throw new IllegalArgumentException(path + "does not contain .yaml extension.");
+        }
+
+        Path filePath = Paths.get(path);
+        if (Files.notExists(filePath)) {
+            throw new FileNotFoundException(path + " does not exist");
+        }
+
+        Map<String, Object> properties;
+
+        logger.debug("Opening the file '{}'.", path);
+        try (InputStream fileStream = Files.newInputStream(filePath)) {
+            logger.debug("Parsing the yaml file '{}'.", path);
+            properties = new Yaml().load(fileStream);
+            logger.debug("The configuration yaml file '{}' was parsed successfully.", path);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+
+        if (properties == null || properties.isEmpty()) {
+            throw new IOException(String.format(
+                    "The configuration yaml file %s is empty or was not loaded successfully. ", path
+            ));
+        }
+
+        return new Configuration(
+                toJsonObject(properties, "subscribers"),
+                toJsonObject(properties, "services"),
+                toJsonObject(properties, "channels")
+        );
+    }
+
+    private final Map<String, ServiceConfig> services;
+    private final Map<String, SubscriberConfig> subscribers;
+    private final Map<String, ChannelConfig> channels;
+
+    private Configuration(JsonObject subscribers, JsonObject services, JsonObject channels) {
+        this.services = new HashMap<>(services.fieldNames().size());
+        this.subscribers = new HashMap<>(subscribers.fieldNames().size());
+        this.channels = new HashMap<>(channels.size());
+
+        for (String channelId : channels.fieldNames()) {
+
+            JsonObject chConfig = channels.getJsonObject(channelId);
+            JsonArray chSubscribers = chConfig.getJsonArray("subscribers");
+
+            List<SubscriberConfig> channelSubscribers = new ArrayList<>(chSubscribers.size());
+            ChannelConfig channel = ChannelConfig.of(channelId, MessageLengthType.valueOf(chConfig.getString("messageType")), channelSubscribers);
+            this.channels.put(channelId, channel);
+
+            for (Object chSubscriber : chSubscribers) {
+                String chSubscriberId = chSubscriber.toString();
+                String serviceId;
+                if (this.subscribers.containsKey(chSubscriberId)) {
+                    serviceId = this.subscribers.get(chSubscriberId).service().id();
+                } else if (subscribers.containsKey(chSubscriberId)) {
+                    serviceId = subscribers.getJsonObject(chSubscriberId).getString("service");
+                } else {
+                    throw new IllegalArgumentException(String.format("The subscriber '%s' does not exist.", chSubscriberId));
+                }
+
+                ServiceConfig service;
+                if (this.services.containsKey(serviceId)) {
+                    service = this.services.get(serviceId);
+                } else if (services.containsKey(serviceId)) {
+                    JsonObject serviceJson = services.getJsonObject(serviceId);
+                    service = ServiceConfig.of(serviceId,
+                            ServiceType.valueOf(serviceJson.getString("type")),
+                            serviceJson.getJsonArray("messageLengthSupport").stream().map(String.class::cast).map(MessageLengthType::valueOf).collect(toSet()),
+                            serviceJson
+                    );
+                    this.services.put(serviceId, service);
+                } else {
+                    throw new IllegalArgumentException(String.format("The service '%s' does not exist.", serviceId));
+                }
+
+                if (!service.messageLengthSupport().contains(channel.messageType())) {
+                    throw new IllegalArgumentException(String.format("Service '%s' does not support channel' message type upon the '%s'.", service.id(), channel.id()));
+                }
+
+                SubscriberConfig subscriberConfig = this.subscribers.get(chSubscriberId);
+                if (subscriberConfig == null) {
+                    subscriberConfig = SubscriberConfig.of(chSubscriberId,
+                            service, subscribers.getJsonObject(chSubscriberId)
+                    );
+                    this.subscribers.put(chSubscriberId, subscriberConfig);
+                }
+                channelSubscribers.add(subscriberConfig);
+            }
+        }
+    }
+
+    public Map<String, ServiceConfig> getServicesById() {
+        return services;
+    }
+
+    public Map<String, SubscriberConfig> getSubscribersById() {
+        return subscribers;
+    }
+
+    public Map<String, ChannelConfig> getChannelsById() {
+        return channels;
+    }
+
+    private static JsonObject toJsonObject(Map<String, Object> properties, String attribute) {
+        Object mapAsObject = properties.get(attribute);
+        if (mapAsObject == null) {
+            throw new IllegalArgumentException(String.format("Attribute '%s' does not exist.", attribute));
+        }
+
+        JsonObject property = new JsonObject();
+        if (mapAsObject instanceof Map<?, ?> map) {
+            for (Map.Entry<?, ?> entry : map.entrySet()) {
+                property.put(entry.getKey().toString(), entry.getValue());
+            }
+        }
+        return property;
+    }
+}

+ 33 - 0
src/main/java/cz/senslog/messaging/app/EnvironmentalProperty.java

@@ -0,0 +1,33 @@
+package cz.senslog.messaging.app;
+
+import io.vertx.core.json.JsonObject;
+
+import java.util.Objects;
+
+public final class EnvironmentalProperty {
+    private static EnvironmentalProperty INSTANCE = null;
+
+    public static EnvironmentalProperty getInstance() {
+        if (INSTANCE == null) {
+            INSTANCE = new EnvironmentalProperty();
+        }
+        return INSTANCE;
+    }
+
+    public int getServerPort() {
+        String portStr = System.getenv("SERVER_HTTP_PORT");
+        return portStr != null ? Integer.parseInt(portStr) : 8080;
+    }
+
+    public String getConfigPath() {
+        String configFilePath = System.getenv("CONFIG_FILE_PATH");
+        return Objects.requireNonNull(configFilePath, "System environmental variable 'CONFIG_FILE_PATH' is not set.");
+    }
+
+    public JsonObject asJson() {
+        return JsonObject.of(
+                "app.config.path", getConfigPath(),
+                "server.http.port", getServerPort()
+        );
+    }
+}

+ 7 - 6
src/main/java/cz/senslog/messaging/app/Main.java

@@ -1,7 +1,8 @@
-package cz.senslog.messaging.app;
-
-public class Main {
-    public static void main(String[] args) {
-        System.out.println("Hello SensLog Messaging System!!");
-    }
+package cz.senslog.messaging.app;
+
+public final class Main {
+
+    public static void main(String[] args) {
+        Application.start();
+    }
 }

+ 51 - 0
src/main/java/cz/senslog/messaging/app/VertxDeployer.java

@@ -0,0 +1,51 @@
+package cz.senslog.messaging.app;
+
+import io.vertx.core.*;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class VertxDeployer extends AbstractVerticle {
+
+    private static final Logger logger = LogManager.getLogger(VertxDeployer.class);
+
+    private final AbstractVerticle[] verticles;
+
+    private VertxDeployer(AbstractVerticle[] verticles) {
+        this.verticles = verticles;
+    }
+
+    public static VertxDeployer deploy(AbstractVerticle... verticles) {
+        return new VertxDeployer(verticles);
+    }
+
+    @Override
+    public void start(Promise<Void> startPromise) {
+        List<Future<?>> futureModules = new ArrayList<>(verticles.length);
+        for (AbstractVerticle v : verticles) {
+            DeploymentOptions options = new DeploymentOptions()
+                    .setThreadingModel(ThreadingModel.WORKER).setConfig(config());
+            futureModules.add(deployHelper(vertx, options, v));
+        }
+        Future.all(futureModules)
+                .onSuccess(v -> startPromise.complete()).onFailure(startPromise::fail);
+    }
+
+    private static Future<Void> deployHelper(Vertx vertx, DeploymentOptions options, AbstractVerticle verticle) {
+        logger.info("Deploying module: " + vertx.getClass().getName());
+        final Promise<Void> promise = Promise.promise();
+        vertx.deployVerticle(verticle, options, res -> {
+            if(res.failed()){
+                logger.error("Module '{}' was not deployed.", verticle.getClass().getSimpleName());
+                logger.catching(res.cause());
+                promise.fail(res.cause());
+            } else {
+                logger.info("Module '{}' was deployed successfully.", verticle.getClass().getSimpleName());
+                promise.complete();
+            }
+        });
+        return promise.future();
+    }
+}

+ 32 - 0
src/main/java/cz/senslog/messaging/domain/ChannelConfig.java

@@ -0,0 +1,32 @@
+package cz.senslog.messaging.domain;
+
+import java.util.List;
+
+public final class ChannelConfig {
+
+    private final String id;
+    private final MessageLengthType messageType;
+    private final List<SubscriberConfig> subscribers;
+
+    public static ChannelConfig of(String id, MessageLengthType messageType, List<SubscriberConfig> subscribers) {
+        return new ChannelConfig(id, messageType, subscribers);
+    }
+
+    private ChannelConfig(String id, MessageLengthType messageType, List<SubscriberConfig> subscribers) {
+        this.id = id;
+        this.messageType = messageType;
+        this.subscribers = subscribers;
+    }
+
+    public String id() {
+        return id;
+    }
+
+    public MessageLengthType messageType() {
+        return messageType;
+    }
+
+    public List<SubscriberConfig> subscribers() {
+        return subscribers;
+    }
+}

+ 18 - 0
src/main/java/cz/senslog/messaging/domain/MessageLengthType.java

@@ -0,0 +1,18 @@
+package cz.senslog.messaging.domain;
+
+public enum MessageLengthType {
+    TINY    (100),
+    SMALL   (500),
+    LARGE   (Integer.MAX_VALUE)
+
+    ;
+    private final int maxLength;
+
+    MessageLengthType(int maxLength) {
+        this.maxLength = maxLength;
+    }
+
+    public int maxLength() {
+        return maxLength;
+    }
+}

+ 41 - 0
src/main/java/cz/senslog/messaging/domain/ServiceConfig.java

@@ -0,0 +1,41 @@
+package cz.senslog.messaging.domain;
+
+import io.vertx.core.json.JsonObject;
+
+import java.util.Map;
+import java.util.Set;
+
+public final class ServiceConfig {
+
+    private final String id;
+    private final ServiceType type;
+    private final Set<MessageLengthType> messageLengthSupport;
+    private final JsonObject config;
+
+    public static ServiceConfig of(String id, ServiceType type, Set<MessageLengthType> messageLengthSupport, JsonObject config) {
+        return new ServiceConfig(id, type, messageLengthSupport, config);
+    }
+
+    private ServiceConfig(String id, ServiceType type, Set<MessageLengthType> messageLengthSupport, JsonObject config) {
+        this.id = id;
+        this.type = type;
+        this.messageLengthSupport = messageLengthSupport;
+        this.config = config;
+    }
+
+    public String id() {
+        return id;
+    }
+
+    public ServiceType type() {
+        return type;
+    }
+
+    public Set<MessageLengthType> messageLengthSupport() {
+        return messageLengthSupport;
+    }
+
+    public JsonObject config() {
+        return config;
+    }
+}

+ 6 - 0
src/main/java/cz/senslog/messaging/domain/ServiceType.java

@@ -0,0 +1,6 @@
+package cz.senslog.messaging.domain;
+
+public enum ServiceType {
+
+    EMAIL, WHATSAPP, SMS
+}

+ 34 - 0
src/main/java/cz/senslog/messaging/domain/SubscriberConfig.java

@@ -0,0 +1,34 @@
+package cz.senslog.messaging.domain;
+
+import io.vertx.core.json.JsonObject;
+
+import java.util.Map;
+
+public final class SubscriberConfig {
+
+    private final String id;
+    private final ServiceConfig service;
+    private final JsonObject config;
+
+    public static SubscriberConfig of(String id, ServiceConfig service, JsonObject config) {
+        return new SubscriberConfig(id, service, config);
+    }
+
+    private SubscriberConfig(String id, ServiceConfig service, JsonObject config) {
+        this.id = id;
+        this.service = service;
+        this.config = config;
+    }
+
+    public String id() {
+        return id;
+    }
+
+    public ServiceConfig service() {
+        return service;
+    }
+
+    public JsonObject config() {
+        return config;
+    }
+}

+ 44 - 0
src/main/java/cz/senslog/messaging/server/ExceptionHandler.java

@@ -0,0 +1,44 @@
+package cz.senslog.messaging.server;
+
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.web.RoutingContext;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.Optional;
+
+import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
+
+
+public final class ExceptionHandler implements io.vertx.core.Handler<RoutingContext> {
+
+    private static final Logger logger = LogManager.getLogger(ExceptionHandler.class);
+
+    private static final String DEFAULT_ERROR_MESSAGE = "Something went wrong!";
+
+    public static ExceptionHandler createAsJSON() {
+        return new ExceptionHandler();
+    }
+
+    private static int codeByException(Throwable throwable, int defaultCode) {
+
+        return defaultCode;
+    }
+
+    @Override
+    public void handle(RoutingContext rc) {
+        Throwable th = Optional.ofNullable(rc.failure()).orElse(new Throwable(DEFAULT_ERROR_MESSAGE));
+
+        String message = th.getMessage();
+        int code = codeByException(th, rc.statusCode());
+
+        logger.error(message);
+        rc.response()
+                .putHeader(CONTENT_TYPE, "application/json")
+                .setStatusCode(code)
+                .end(JsonObject.of(
+                        "code", code,
+                        "message", message
+                ).encode());
+    }
+}

+ 25 - 0
src/main/java/cz/senslog/messaging/server/HttpIllegalArgumentException.java

@@ -0,0 +1,25 @@
+package cz.senslog.messaging.server;
+
+public class HttpIllegalArgumentException extends IllegalArgumentException {
+
+    private final int code;
+
+    public HttpIllegalArgumentException(int code, String message) {
+        super(message);
+        this.code = code;
+    }
+
+    public HttpIllegalArgumentException(int code, String format, Object... args) {
+        super(String.format(format, args));
+        this.code = code;
+    }
+
+    public HttpIllegalArgumentException(int code, Throwable th) {
+        super(th);
+        this.code = code;
+    }
+
+    public int getCode() {
+        return code;
+    }
+}

+ 134 - 0
src/main/java/cz/senslog/messaging/server/HttpVertxServer.java

@@ -0,0 +1,134 @@
+package cz.senslog.messaging.server;
+
+import cz.senslog.messaging.app.Application;
+import cz.senslog.messaging.domain.ChannelConfig;
+import cz.senslog.messaging.domain.SubscriberConfig;
+import cz.senslog.messaging.utils.ResourcesUtils;
+import io.vertx.core.AbstractVerticle;
+import io.vertx.core.DeploymentOptions;
+import io.vertx.core.Promise;
+import io.vertx.core.eventbus.DeliveryOptions;
+import io.vertx.core.http.HttpMethod;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.web.Router;
+import io.vertx.ext.web.handler.*;
+import io.vertx.ext.web.openapi.RouterBuilder;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.nio.file.Path;
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static java.util.Objects.requireNonNull;
+
+public final class HttpVertxServer extends AbstractVerticle {
+    private static final Logger logger = LogManager.getLogger(HttpVertxServer.class);
+
+    private record Subscriber(String id, String serviceId, String subscriberId){
+        public static Subscriber of(JsonObject config) {
+            return new Subscriber(
+                    config.getString("id"),
+                    config.getString("serviceId"),
+                    config.getString("subscriberId")
+            );
+        }
+    }
+
+    private record ChannelProperties(String id, int maxLength, List<Subscriber> subscribers) {
+        public static ChannelProperties of(JsonObject config) {
+            return new ChannelProperties(
+                    config.getString("id"),
+                    config.getInteger("maxLength"),
+                    config.getJsonArray("subscribers").stream().map(JsonObject.class::cast).map(Subscriber::of).toList()
+            );
+        }
+    }
+
+    @Override
+    public void start(Promise<Void> startPromise) {
+        Path openApiUrl = ResourcesUtils.getPath("openAPISpec.yaml");
+        logger.info("Loading the OpenAPI spec from '{}'", openApiUrl);
+
+        final Map<String, ChannelProperties> channels = config().getJsonArray("channels", JsonArray.of()).stream()
+                .map(JsonObject.class::cast).map(ChannelProperties::of)
+                .collect(Collectors.toMap(ChannelProperties::id, Function.identity()));
+
+        RouterBuilder.create(vertx, requireNonNull(openApiUrl, "Open API Specification was not found as a resource.").toString())
+                .onSuccess(openAPIRouterBuilder -> {
+                    logger.info("The OpenAPI specification was loaded successfully.");
+
+                    openAPIRouterBuilder.rootHandler(LoggerHandler.create(false, LoggerFormat.SHORT));
+
+                    openAPIRouterBuilder.rootHandler(CorsHandler.create()
+                            .allowedMethod(HttpMethod.GET)
+                            .allowedMethod(HttpMethod.POST)
+
+                            .allowedHeader("x-requested-with")
+                            .allowedHeader("Access-Control-Allow-Origin")
+                            .allowedHeader("Origin")
+                            .allowedHeader("Content-Type")
+                            .allowedHeader("Accept")
+                    );
+
+                    // The order matters, so adding the body handler should happen after any PLATFORM or SECURITY_POLICY handler(s).
+                    openAPIRouterBuilder.rootHandler(BodyHandler.create());
+
+                    openAPIRouterBuilder.operation("infoGET").handler(rc -> rc.response().end(JsonObject.of(
+                            "name", Application.PROJECT_NAME,
+                            "version", Application.COMPILED_VERSION,
+                            "build", Application.BUILD_VERSION,
+                            "uptime", Application.uptimeFormatted(),
+                            "uptimeMillis", Application.uptime()
+                    ).encode()));
+
+                    openAPIRouterBuilder.operation("channelsGET").handler(rc -> rc.response().end(new JsonArray(channels.values().stream().map(ch -> JsonObject.of(
+                            "name", ch.id(),
+                            "maxLengthSize", ch.maxLength()
+                    )).collect(Collectors.toList())).encode()));
+
+                    openAPIRouterBuilder.operation("channelNamePOST").handler(rc -> {
+                        String channelName = rc.pathParam("name");
+                        ChannelProperties channel = Optional.ofNullable(channels.get(channelName))
+                                .orElseThrow(() -> new HttpIllegalArgumentException(404, "Channel name <%s> not found.", channelName));
+
+                        String messageEncoded = rc.body().asString();
+                        int messageLength = messageEncoded.length() / 4 * 3;
+                        if (messageLength > channel.maxLength()) {
+                            throw new HttpIllegalArgumentException(400, "Message length <%d> does not meet channel's requirements.", messageLength);
+                        }
+
+                        String message = new String(Base64.getDecoder().decode(messageEncoded));
+                        channel.subscribers().forEach(s -> vertx.eventBus().publish(s.id(), message, new DeliveryOptions()
+                                .addHeader("service", s.serviceId())
+                                .addHeader("subscriber", s.subscriberId())
+                        ));
+
+                        rc.response().end(JsonObject.of("status", "ACCEPTED").encode());
+                    });
+
+                    Router mainRouter = openAPIRouterBuilder.createRouter();
+
+                    mainRouter.route().failureHandler(ExceptionHandler.createAsJSON());
+
+                    mainRouter.errorHandler(404, h -> h.fail(404,
+                            new Throwable(String.format("Resource '%s' not found.", h.request().uri()))
+                    ));
+
+                    vertx.createHttpServer()
+                            .requestHandler(mainRouter)
+                            .listen(config().getInteger("server.http.port"))
+                            .onSuccess(server -> {
+                                logger.info("HTTP server running on port {}.", server.actualPort());
+                                startPromise.complete();
+                            })
+                            .onFailure(fail -> {
+                                logger.error(fail);
+                                startPromise.fail(fail);
+                            });
+                })
+                .onFailure(startPromise::fail);
+    }
+}

+ 52 - 0
src/main/java/cz/senslog/messaging/service/AbstractService.java

@@ -0,0 +1,52 @@
+package cz.senslog.messaging.service;
+
+import cz.senslog.messaging.domain.ServiceType;
+import io.vertx.core.AbstractVerticle;
+import io.vertx.core.Handler;
+import io.vertx.core.eventbus.Message;
+import io.vertx.core.json.JsonObject;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import static cz.senslog.messaging.utils.ServiceUtil.createServiceSubscriberId;
+
+public abstract class AbstractService extends AbstractVerticle {
+
+    private static final Logger logger = LogManager.getLogger(AbstractService.class);
+
+    private final ServiceType type;
+    private final String id;
+
+    protected AbstractService(ServiceType type, String id) {
+        this.type = type;
+        this.id = id;
+    }
+
+    protected abstract String handleMessage(String subscriber, String message);
+
+    protected abstract void config(JsonObject config);
+
+    @Override
+    public void start()  {
+        JsonObject fullServiceConfig = config().getJsonObject("services").getJsonObject(id);
+        config(fullServiceConfig);
+
+        Handler<Message<String>> subscriberHandler = msg -> {
+            String subscriber = msg.headers().get("subscriber");
+            logger.info("Received message {}/{}.", id(), subscriber);
+            vertx.executeBlocking(() -> handleMessage(subscriber, msg.body()))
+                    .onSuccess(logger::info).onFailure(logger::error);
+        };
+
+        fullServiceConfig.getJsonArray("subscribedRecipients").stream().map(JsonObject.class::cast).map(o -> o.getString("id"))
+                        .toList().forEach(subId -> vertx.eventBus().consumer(createServiceSubscriberId(id, subId), subscriberHandler));
+    }
+
+    public ServiceType type() {
+        return type;
+    }
+
+    public String id() {
+        return id;
+    }
+}

+ 110 - 0
src/main/java/cz/senslog/messaging/service/EmailService.java

@@ -0,0 +1,110 @@
+package cz.senslog.messaging.service;
+
+import cz.senslog.messaging.domain.ServiceType;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import jakarta.mail.*;
+import jakarta.mail.internet.*;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.*;
+
+public final class EmailService extends AbstractService {
+
+    private record EmailMessageConfig(String senderEmail, String subject, String recipientsTo, String recipientsCC) {}
+
+    private static final Logger logger = LogManager.getLogger(EmailService.class);
+
+    private final Map<String, EmailMessageConfig> messageRecipientsConfigs;
+
+    private Session emailSession;
+
+    public EmailService(String id) {
+        super(ServiceType.EMAIL, id);
+        this.messageRecipientsConfigs = new HashMap<>();
+    }
+
+    @Override
+    protected String handleMessage(String subscriber, String message) {
+        try {
+            EmailMessageConfig messageConfig = Optional.ofNullable(messageRecipientsConfigs.get(subscriber))
+                    .orElseThrow(() -> new IllegalArgumentException(String.format("The subscriber '%s' does not exist.", subscriber)));
+
+            Message emailMessage = new MimeMessage(emailSession);
+            emailMessage.setFrom(new InternetAddress(messageConfig.senderEmail()));
+
+            if (messageConfig.recipientsTo() != null) {
+                emailMessage.setRecipients(Message.RecipientType.TO, InternetAddress.parse(messageConfig.recipientsTo()));
+            }
+            if (messageConfig.recipientsCC() != null) {
+                emailMessage.setRecipients(Message.RecipientType.CC, InternetAddress.parse(messageConfig.recipientsCC()));
+            }
+            emailMessage.setSubject(messageConfig.subject());
+
+            MimeBodyPart mimeBodyPart = new MimeBodyPart();
+            mimeBodyPart.setContent(message, "text/html; charset=utf-8");
+
+            Multipart multipart = new MimeMultipart();
+            multipart.addBodyPart(mimeBodyPart);
+
+            emailMessage.setContent(multipart);
+
+            Transport.send(emailMessage);
+
+            return String.format("The message for the '%s' was send successfully", subscriber);
+        } catch (MessagingException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Override
+    protected void config(JsonObject config) {
+        logger.info("Configuring the {}({}) service'", type().name(), id());
+        JsonArray subscribedRecipients = config.getJsonArray("subscribedRecipients");
+        for (Object r : subscribedRecipients) {
+            JsonObject recipient = (JsonObject) r;
+            String subscriberId = recipient.getString("id");
+            JsonObject recipientConfig = recipient.getJsonObject("config");
+            String senderEmail = recipientConfig.getString("senderEmail");
+            String subject = recipientConfig.getString("subject");
+            JsonArray recipientEmails = recipientConfig.getJsonArray("recipientEmail");
+            List<String> recipientsTo = new ArrayList<>();
+            List<String> recipientsCC = new ArrayList<>();
+            for (Object e : recipientEmails) {
+                JsonObject emailConfig = (JsonObject) e;
+                String email = emailConfig.getString("email");
+                if (emailConfig.getBoolean("hideEmail")) {
+                    recipientsCC.add(email);
+                } else {
+                    recipientsTo.add(email);
+                }
+            }
+            this.messageRecipientsConfigs.put(subscriberId,
+                    new EmailMessageConfig(
+                            senderEmail, subject,
+                            (recipientsTo.isEmpty() ? null : String.join(",", recipientsTo)),
+                            (recipientsCC.isEmpty() ? null : String.join(",", recipientsCC))
+                    )
+            );
+        }
+
+        JsonObject serviceConfig = config.getJsonObject("config");
+        Properties prop = new Properties();
+        prop.put("mail.smtp.auth", true);
+        prop.put("mail.smtp.host", serviceConfig.getString("smtpHost"));
+        prop.put("mail.smtp.port", serviceConfig.getInteger("smtpPort").toString());
+        prop.put("mail.smtp.starttls.enable", "true");
+        prop.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
+
+        this.emailSession = Session.getInstance(prop, new Authenticator() {
+            @Override
+            protected PasswordAuthentication getPasswordAuthentication() {
+                return new PasswordAuthentication(
+                        serviceConfig.getString("authUsername"),
+                        serviceConfig.getString("authPassword")
+                );
+            }
+        });
+    }
+}

+ 25 - 0
src/main/java/cz/senslog/messaging/service/ServiceCreator.java

@@ -0,0 +1,25 @@
+package cz.senslog.messaging.service;
+
+import cz.senslog.messaging.domain.ServiceConfig;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+public final class ServiceCreator {
+
+    private static final Logger logger = LogManager.getLogger(ServiceCreator.class);
+
+    public static List<AbstractService> createServices(Collection<ServiceConfig> serviceConfigs) {
+        List<AbstractService> services = new ArrayList<>(serviceConfigs.size());
+        for (ServiceConfig config : serviceConfigs) {
+            switch (config.type()) {
+                case EMAIL -> services.add(new EmailService(config.id()));
+                default -> logger.warn("Service type '{}' is not supported yet.", config.type());
+            }
+        }
+        return services;
+    }
+}

+ 11 - 0
src/main/java/cz/senslog/messaging/utils/ResourcesUtils.java

@@ -0,0 +1,11 @@
+package cz.senslog.messaging.utils;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+public final class ResourcesUtils {
+
+    public static Path getPath(String resourceFile) {
+        return Paths.get(resourceFile);
+    }
+}

+ 8 - 0
src/main/java/cz/senslog/messaging/utils/ServiceUtil.java

@@ -0,0 +1,8 @@
+package cz.senslog.messaging.utils;
+
+public final class ServiceUtil {
+
+    public static String createServiceSubscriberId(String serviceId, String subscriberId) {
+        return String.format("__service#%s.subscriber#%s", serviceId, subscriberId);
+    }
+}

+ 23 - 0
src/main/java/cz/senslog/messaging/utils/Tuple.java

@@ -0,0 +1,23 @@
+package cz.senslog.messaging.utils;
+
+import java.util.Objects;
+
+public record Tuple<A, B>(A item1, B item2) {
+
+    public static <A, B> Tuple<A,B> of(A item1, B item2) {
+        return new Tuple<>(item1, item2);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Tuple<?, ?> tuple = (Tuple<?, ?>) o;
+        return Objects.equals(item1, tuple.item1) && Objects.equals(item2, tuple.item2);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(item1, item2);
+    }
+}

+ 19 - 0
src/main/resources/log4j2.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Configuration xmlns="http://logging.apache.org/log4j/2.0/config" status="INFO">
+
+    <Properties>
+        <Property name="logPath">./</Property>
+    </Properties>
+
+    <Appenders>
+        <Console name="console" target="SYSTEM_OUT">
+            <PatternLayout pattern="%-5p | %d{yyyy-MM-dd HH:mm:ss} | [%t] %C{2} (%F:%L) - %m%n" />
+        </Console>
+    </Appenders>
+    <Loggers>
+        <Logger name="cz.senslog" level="info" />
+        <Root level="info">
+            <AppenderRef ref="console" />
+        </Root>
+    </Loggers>
+</Configuration>

+ 134 - 0
src/main/resources/openAPISpec.yaml

@@ -0,0 +1,134 @@
+openapi: "3.0.0"
+info:
+  version: 1.0.0
+  title: SensLog Messaging
+servers:
+  - url: http://127.0.0.1:8080
+paths:
+  /info:
+    get:
+      operationId: infoGET
+      summary: Information about running instance
+      responses:
+        200:
+          description: Instance information
+          content:
+            application/json:
+              schema:
+                type: object
+                required:
+                  - name
+                  - version
+                  - build
+                  - uptime
+                  - uptimeMillis
+                properties:
+                  name:
+                    type: string
+                  version:
+                    type: string
+                  build:
+                    type: string
+                  uptimeMillis:
+                    type: integer
+                    format: int64
+                  uptime:
+                    type: string
+                example:
+                  name: "senslog-messaging"
+                  version: "1.1.0"
+                  build: "123456789"
+                  uptimeMillis: 1684862333
+                  uptime: "1:20:00"
+        default:
+          description: unexpected error
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Error"
+
+  /channels:
+    get:
+      operationId: channelsGET
+      summary: Publish info about all channels
+      responses:
+        200:
+          description: JSON array of basic info of each campaign
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  properties:
+                    name:
+                      type: string
+                    lengthType:
+                      type: string
+                    maxLengthSize:
+                      type: integer
+                  example:
+                    name: "channelName"
+                    lengthType: "LARGE"
+                    maxLengthSize: 2147483647
+        default:
+          description: unexpected error
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Error"
+
+
+  /channels/{name}:
+    post:
+      operationId: channelNamePOST
+      summary: Receive message to send in Base64 Encoding
+      parameters:
+        - in: path
+          name: name
+          schema:
+            type: string
+          required: true
+      requestBody:
+        required: true
+        content:
+          text/plain;encoding=base64:
+            schema:
+              type: string
+      responses:
+        200:
+          description: JSON object with a resulting status
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - ACCEPTED
+                      - REJECTED
+                example:
+                  status: "ACCEPTED"
+        default:
+          description: unexpected error
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+
+components:
+  schemas:
+    Error:
+      type: object
+      required:
+        - code
+        - message
+      properties:
+        code:
+          type: integer
+          format: int32
+        message:
+          type: string
+      example:
+        code: 404
+        message: "Not Found"