diff --git a/application.properties b/application.properties index 360b82a..a03b4d5 100644 --- a/application.properties +++ b/application.properties @@ -1 +1,11 @@ -logging.level.de.ph87=DEBUG \ No newline at end of file +logging.level.de.ph87=INFO +logging.level.de.ph87.data.mqtt=DEBUG +#- +spring.datasource.url=jdbc:h2:./database;AUTO_SERVER=TRUE +spring.datasource.driverClassName=org.h2.Driver +#spring.jpa.hibernate.ddl-auto=create +spring.jpa.hibernate.ddl-auto=update +#- +spring.jackson.serialization.indent_output=true +#- +de.ph87.knx.mqtt.uri=tcp://10.0.0.50:1883 diff --git a/pom.xml b/pom.xml index 78fdd02..2e440a1 100644 --- a/pom.xml +++ b/pom.xml @@ -38,10 +38,6 @@ org.springframework.security spring-security-core - - - - org.springframework.boot spring-boot-starter-test @@ -58,6 +54,11 @@ org.postgresql postgresql + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.5 + \ No newline at end of file diff --git a/src/main/java/de/ph87/data/DemoService.java b/src/main/java/de/ph87/data/DemoService.java new file mode 100644 index 0000000..84b735a --- /dev/null +++ b/src/main/java/de/ph87/data/DemoService.java @@ -0,0 +1,129 @@ +package de.ph87.data; + +import de.ph87.data.series.Series; +import de.ph87.data.series.SeriesRepository; +import de.ph87.data.series.SeriesType; +import de.ph87.data.topic.Topic; +import de.ph87.data.topic.TopicRepository; +import de.ph87.data.topic.query.TopicQuery; +import de.ph87.data.topic.query.TopicQueryFunction; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DemoService { + + private final SeriesRepository seriesRepository; + + private final TopicRepository topicRepository; + + @Transactional + @EventListener(ApplicationReadyEvent.class) + public void init() { + final Series electricityEnergyProduce = series("electricity/energy/produce", "kWh", SeriesType.DELTA, 5); + final Series electricityPowerProduce = series("electricity/power/produce", "W", SeriesType.VARYING, 5); + topic( + "openDTU/pv/patrix/json2", + new TopicQuery(electricityEnergyProduce, "$.totalKWh"), + new TopicQuery(electricityPowerProduce, "$.totalW") + ); + + final Series electricityEnergyPurchase = series("electricity/energy/purchase", "kWh", SeriesType.DELTA, 5); + final Series electricityPowerPurchase = series("electricity/power/purchase", "W", SeriesType.VARYING, 5); + final Series electricityEnergyDelivery = series("electricity/energy/delivery", "kWh", SeriesType.DELTA, 5); + final Series electricityPowerDelivery = series("electricity/power/delivery", "W", SeriesType.VARYING, 5); + topic( + "electricity/grid/json", + new TopicQuery(electricityEnergyPurchase, "$.purchaseWh", 0.001), + new TopicQuery(electricityPowerPurchase, "$.powerW", TopicQueryFunction.ONLY_POSITIVE), + new TopicQuery(electricityEnergyDelivery, "$.deliveryWh", 0.001), + new TopicQuery(electricityPowerDelivery, "$.powerW", TopicQueryFunction.ONLY_NEGATIVE_BUT_NEGATE) + ); + + final Series gardenPressure = series("garden/pressure", "hPa", SeriesType.VARYING, 5); + final Series gardenTemperature = series("garden/temperature", "°C", SeriesType.VARYING, 5); + final Series gardenHumidityAbsolute = series("garden/humidity/absolute", "mg/L", SeriesType.VARYING, 5); + final Series gardenHumidityRelative = series("garden/humidity/relative", "%", SeriesType.VARYING, 5); + topic("garten/sensor/pressure", new TopicQuery(gardenPressure, "$.value")); + topic("garten/sensor/temperature", new TopicQuery(gardenTemperature, "$.value")); + topic("garten/sensor/humidity_absolute", new TopicQuery(gardenHumidityAbsolute, "$.value")); + topic("garten/sensor/humidity_relative", new TopicQuery(gardenHumidityRelative, "$.value")); + + final Series bedroomPressure = series("bedroom/pressure", "hPa", SeriesType.VARYING, 5); + final Series bedroomTemperature = series("bedroom/temperature", "°C", SeriesType.VARYING, 5); + final Series bedroomHumidityAbsolute = series("bedroom/humidity/absolute", "mg/L", SeriesType.VARYING, 5); + final Series bedroomHumidityRelative = series("bedroom/humidity/relative", "%", SeriesType.VARYING, 5); + topic("schlafzimmer/sensor/pressure", new TopicQuery(bedroomPressure, "$.value")); + topic("schlafzimmer/sensor/temperature", new TopicQuery(bedroomTemperature, "$.value")); + topic("schlafzimmer/sensor/humidity_absolute", new TopicQuery(bedroomHumidityAbsolute, "$.value")); + topic("schlafzimmer/sensor/humidity_relative", new TopicQuery(bedroomHumidityRelative, "$.value")); + + final Series basementTemperature = series("basement/temperature", "°C", SeriesType.VARYING, 60); + final Series basementHumidityAbsolute = series("basement/humidity/absolute", "mg/L", SeriesType.VARYING, 60); + final Series basementHumidityRelative = series("basement/humidity/relative", "%", SeriesType.VARYING, 60); + topic("aggregation/heizraum/luftfeuchte/absolut", "$.lastTime", new TopicQuery(basementTemperature, "$.lastValue")); + topic("aggregation/heizraum/luftfeuchte/relativ", "$.lastTime", new TopicQuery(basementHumidityAbsolute, "$.lastValue")); + topic("aggregation/heizraum/temperatur", "$.lastTime", new TopicQuery(basementHumidityRelative, "$.lastValue")); + + final Series heatingExhaustTemperature = series("heating/exhaust/temperature", "°C", SeriesType.VARYING, 60); + topic("aggregation/heizung/abgas/temperatur", "$.lastTime", new TopicQuery(heatingExhaustTemperature, "$.lastValue")); + + final Series heatingCircuitReturnTemperature = series("heating/circuit/return/temperature", "°C", SeriesType.VARYING, 60); + final Series heatingCircuitSupplyTemperature = series("heating/circuit/supply/temperature", "°C", SeriesType.VARYING, 60); + topic("aggregation/heizung/heizkreis/ruecklauf/temperatur", "$.lastTime", new TopicQuery(heatingCircuitReturnTemperature, "$.lastValue")); + topic("aggregation/heizung/heizkreis/vorlauf/temperatur", "$.lastTime", new TopicQuery(heatingCircuitSupplyTemperature, "$.lastValue")); + + final Series heatingBufferInletTemperature = series("heating/buffer/inlet/temperature", "°C", SeriesType.VARYING, 60); + final Series heatingBufferOutletTemperature = series("heating/buffer/outlet/temperature", "°C", SeriesType.VARYING, 60); + final Series heatingBufferCirculationTemperature = series("heating/buffer/circulation/temperature", "°C", SeriesType.VARYING, 60); + topic("aggregation/heizung/puffer/eingang/temperatur", "$.lastTime", new TopicQuery(heatingBufferInletTemperature, "$.lastValue")); + topic("aggregation/heizung/puffer/ausgang/temperatur", "$.lastTime", new TopicQuery(heatingBufferOutletTemperature, "$.lastValue")); + topic("aggregation/heizung/puffer/zirkulation/temperatur", "$.lastTime", new TopicQuery(heatingBufferCirculationTemperature, "$.lastValue")); + + final Series heatingBufferInsideTemperature = series("heating/buffer/inside/temperature", "°C", SeriesType.VARYING, 60); + topic("aggregation/heizung/puffer/speicher/temperatur", "$.lastTime", new TopicQuery(heatingBufferInsideTemperature, "$.lastValue")); + + final Series heatingBufferSupplyTemperature = series("heating/buffer/supply/temperature", "°C", SeriesType.VARYING, 60); + final Series heatingBufferReturnTemperature = series("heating/buffer/return/temperature", "°C", SeriesType.VARYING, 60); + topic("aggregation/heizung/puffer/vorlauf/temperatur", "$.lastTime", new TopicQuery(heatingBufferSupplyTemperature, "$.lastValue")); + topic("aggregation/heizung/puffer/ruecklauf/temperatur", "$.lastTime", new TopicQuery(heatingBufferReturnTemperature, "$.lastValue")); + + final Series cisternVolume = series("cistern/volume", "L", SeriesType.VARYING, 5); + topic("cistern/volume/PatrixJson", "$.date", new TopicQuery(cisternVolume, "$.value")); + } + + @NonNull + private Series series(@NonNull final String name, @NonNull final String unit, @NonNull final SeriesType type, final int expectedEverySeconds) { + return seriesRepository + .findByName(name) + .stream() + .peek(existing -> { + existing.setUnit(unit); + existing.setType(type); + existing.setExpectedEverySeconds(expectedEverySeconds); + }) + .findFirst() + .orElseGet(() -> seriesRepository.save(new Series(name, unit, 1, expectedEverySeconds, type))); + } + + private void topic(@NonNull final String name, @NonNull final TopicQuery... queries) { + topic(name, "$.timestamp", queries); + } + + private void topic(@NonNull final String name, @NonNull final String timestampQuery, @NonNull final TopicQuery... queries) { + final Topic topic = topicRepository.findByName(name).orElseGet(() -> topicRepository.save(new Topic(name))); + topic.setTimestampQuery(timestampQuery); + topic.getQueries().clear(); + topic.getQueries().addAll(List.of(queries)); + } + +} diff --git a/src/main/java/de/ph87/data/user/Helpers.java b/src/main/java/de/ph87/data/Helpers.java similarity index 57% rename from src/main/java/de/ph87/data/user/Helpers.java rename to src/main/java/de/ph87/data/Helpers.java index 5a0160c..30e1c5e 100644 --- a/src/main/java/de/ph87/data/user/Helpers.java +++ b/src/main/java/de/ph87/data/Helpers.java @@ -1,4 +1,4 @@ -package de.ph87.data.user; +package de.ph87.data; import jakarta.annotation.Nullable; import lombok.NonNull; @@ -8,11 +8,11 @@ import java.util.function.Function; public class Helpers { @Nullable - public static R map(@Nullable T t, @NonNull final Function map) { + public static R map(@Nullable final T t, @NonNull final Function mapper) { if (t == null) { return null; } - return map.apply(t); + return mapper.apply(t); } } diff --git a/src/main/java/de/ph87/data/log/AbstractEntityLog.java b/src/main/java/de/ph87/data/log/AbstractEntityLog.java new file mode 100644 index 0000000..1e3e5f4 --- /dev/null +++ b/src/main/java/de/ph87/data/log/AbstractEntityLog.java @@ -0,0 +1,51 @@ +package de.ph87.data.log; + +import jakarta.annotation.Nullable; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.OrderColumn; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.ToString; +import org.slf4j.Logger; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; + +@Getter +@ToString +@MappedSuperclass +@NoArgsConstructor +public abstract class AbstractEntityLog { + + @NonNull + @OrderColumn + @ToString.Exclude + @ElementCollection + private List log = new ArrayList<>(); + + public void error(@NonNull final Logger logger, @NonNull final String message) { + error(logger, message, null); + } + + public void error(@NonNull final Logger logger, @NonNull final String message, @Nullable final Exception e) { + if (e instanceof RuntimeException) { + this.log.add(new LogMessage(LogSeverity.ERROR, message + "\n stacktrace:\n" + stacktraceToString(e))); + logger.error(message, e); + } else { + this.log.add(new LogMessage(LogSeverity.ERROR, message)); + logger.error(message); + } + } + + @NonNull + public static String stacktraceToString(@NonNull final Exception e) { + final StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } + +} diff --git a/src/main/java/de/ph87/data/log/LogMessage.java b/src/main/java/de/ph87/data/log/LogMessage.java new file mode 100644 index 0000000..aed56a1 --- /dev/null +++ b/src/main/java/de/ph87/data/log/LogMessage.java @@ -0,0 +1,38 @@ +package de.ph87.data.log; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.ToString; + +import java.time.ZonedDateTime; + +@Getter +@ToString +@Embeddable +@NoArgsConstructor +public class LogMessage { + + @NonNull + @Column(nullable = false) + private ZonedDateTime date; + + @NonNull + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private LogSeverity severity; + + @NonNull + @Column(nullable = false) + private String message; + + public LogMessage(@NonNull final LogSeverity severity, @NonNull final String message) { + this.severity = severity; + this.message = message; + } + +} diff --git a/src/main/java/de/ph87/data/log/LogSeverity.java b/src/main/java/de/ph87/data/log/LogSeverity.java new file mode 100644 index 0000000..5ffee34 --- /dev/null +++ b/src/main/java/de/ph87/data/log/LogSeverity.java @@ -0,0 +1,5 @@ +package de.ph87.data.log; + +public enum LogSeverity { + ERROR, WARN, INFO, DEBUG +} diff --git a/src/main/java/de/ph87/data/mqtt/MqttInbound.java b/src/main/java/de/ph87/data/mqtt/MqttInbound.java new file mode 100644 index 0000000..b24daaa --- /dev/null +++ b/src/main/java/de/ph87/data/mqtt/MqttInbound.java @@ -0,0 +1,25 @@ +package de.ph87.data.mqtt; + +import lombok.Data; +import lombok.NonNull; + +import java.time.ZonedDateTime; + +@Data +public class MqttInbound { + + @NonNull + public final ZonedDateTime date = ZonedDateTime.now(); + + @NonNull + public final String topic; + + @NonNull + public final String payload; + + public MqttInbound(@NonNull final String topic, @NonNull final String payload) { + this.topic = topic; + this.payload = payload; + } + +} diff --git a/src/main/java/de/ph87/data/mqtt/MqttService.java b/src/main/java/de/ph87/data/mqtt/MqttService.java new file mode 100644 index 0000000..85193b5 --- /dev/null +++ b/src/main/java/de/ph87/data/mqtt/MqttService.java @@ -0,0 +1,89 @@ +package de.ph87.data.mqtt; + +import de.ph87.data.topic.TopicReceiver; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MqttService { + + private final TopicReceiver topicReceiver; + + private final Object lock = new Object(); + + private boolean stop = false; + + @PostConstruct + public void init() { + new Thread(this::stayConnected).start(); + } + + @PreDestroy + public void destroy() { + synchronized (lock) { + stop = true; + lock.notifyAll(); + } + } + + private void stayConnected() { + try { + while (!stop) { + connectOnce(); + } + } catch (InterruptedException e) { + log.error("Interrupted while waiting for connection", e); + } finally { + log.info("MQTT client stopped."); + } + } + + private void connectOnce() throws InterruptedException { + MqttClient client = null; + try { + log.info("MQTT connecting..."); + client = new MqttClient("tcp://10.0.0.50:1883", "DataDynamic", new MemoryPersistence()); + final MqttConnectOptions options = new MqttConnectOptions(); + options.setAutomaticReconnect(false); + options.setCleanSession(true); + options.setConnectionTimeout(5); + options.setKeepAliveInterval(2); + client.connect(options); + client.subscribe("#", (topic, message) -> topicReceiver.receive(new MqttInbound(topic, new String(message.getPayload())))); + log.info("MQTT connected."); + synchronized (lock) { + while (!stop && client.isConnected()) { + lock.wait(1000); + } + if (!client.isConnected()) { + log.info("MQTT disconnected."); + } + } + } catch (MqttException e) { + log.error("MQTT connection error: {}", e.getMessage()); + synchronized (lock) { + lock.wait(3000); + } + } finally { + if (client != null && client.isConnected()) { + log.info("MQTT disconnecting..."); + try { + client.disconnect(); + log.info("MQTT disconnected"); + } catch (MqttException e) { + log.error("Failed to cleanup connection: {}", e.getMessage()); + } + } + } + } + +} diff --git a/src/main/java/de/ph87/data/series/Series.java b/src/main/java/de/ph87/data/series/Series.java new file mode 100644 index 0000000..bbdd535 --- /dev/null +++ b/src/main/java/de/ph87/data/series/Series.java @@ -0,0 +1,84 @@ +package de.ph87.data.series; + +import de.ph87.data.log.AbstractEntityLog; +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.Setter; +import lombok.ToString; + +import java.time.ZonedDateTime; + +@Entity +@Getter +@ToString +@NoArgsConstructor +public class Series extends AbstractEntityLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Version + private long version; + + @NonNull + @Column(nullable = false, unique = true) + private String name; + + @Setter + @NonNull + @Column(nullable = false) + private String unit; + + @Setter + @Column(nullable = false) + private int decimals; + + @Column + @Nullable + private ZonedDateTime first = null; + + @Column + @Nullable + private ZonedDateTime last = null; + + @Nullable + @Column(name = "`value`") + private Double value = null; + + @Setter + @Column(nullable = false) + private int expectedEverySeconds = 5; + + @Setter + @NonNull + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private SeriesType type; + + public Series(@NonNull final String name, @NonNull final String unit, final int decimals, final int expectedEverySeconds, @NonNull final SeriesType type) { + this.name = name; + this.unit = unit; + this.decimals = decimals; + this.expectedEverySeconds = expectedEverySeconds; + this.type = type; + } + + public void update(@NonNull final ZonedDateTime timestamp, final double value) { + if (this.last == null || this.last.isBefore(timestamp)) { + this.last = timestamp; + this.value = value; + } + } + +} diff --git a/src/main/java/de/ph87/data/series/SeriesController.java b/src/main/java/de/ph87/data/series/SeriesController.java new file mode 100644 index 0000000..60feeab --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesController.java @@ -0,0 +1,39 @@ +package de.ph87.data.series; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("Series") +public class SeriesController { + + private final SeriesRepository seriesRepository; + + private final SeriesService seriesService; + + @GetMapping("findAll") + public List findAll() { + return seriesRepository.findAllDto(); + } + + @PostMapping("findByName") + public SeriesDto findByName(@NonNull @RequestBody final String name) { + return seriesRepository.findDtoByName(name); + } + + @PostMapping("points") + public List points(@NonNull @RequestBody final SeriesPointsRequest request) { + return seriesService.points(request); + } + +} diff --git a/src/main/java/de/ph87/data/series/SeriesDto.java b/src/main/java/de/ph87/data/series/SeriesDto.java new file mode 100644 index 0000000..2a9c2f4 --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesDto.java @@ -0,0 +1,48 @@ +package de.ph87.data.series; + +import de.ph87.data.websocket.IWebsocketMessage; +import jakarta.annotation.Nullable; +import lombok.Data; +import lombok.NonNull; + +import java.time.ZonedDateTime; + +@Data +public class SeriesDto implements IWebsocketMessage { + + public final long id; + + public final String name; + + @NonNull + public final String unit; + + public final int decimals; + + @Nullable + public final ZonedDateTime first; + + @Nullable + public final ZonedDateTime last; + + @Nullable + public final Double value; + + public final int expectedEverySeconds; + + @NonNull + public final SeriesType type; + + public SeriesDto(@NonNull final Series series) { + this.id = series.getId(); + this.name = series.getName(); + this.unit = series.getUnit(); + this.decimals = series.getDecimals(); + this.first = series.getFirst(); + this.last = series.getLast(); + this.value = series.getValue(); + this.expectedEverySeconds = series.getExpectedEverySeconds(); + this.type = series.getType(); + } + +} diff --git a/src/main/java/de/ph87/data/series/SeriesPoint.java b/src/main/java/de/ph87/data/series/SeriesPoint.java new file mode 100644 index 0000000..94fc92e --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesPoint.java @@ -0,0 +1,13 @@ +package de.ph87.data.series; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import java.io.IOException; + +@JsonSerialize(using = SeriesPointSerializer.class) +public interface SeriesPoint { + + void toJson(final JsonGenerator jsonGenerator) throws IOException; + +} diff --git a/src/main/java/de/ph87/data/series/SeriesPointSerializer.java b/src/main/java/de/ph87/data/series/SeriesPointSerializer.java new file mode 100644 index 0000000..b4e0e57 --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesPointSerializer.java @@ -0,0 +1,18 @@ +package de.ph87.data.series; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; + +public class SeriesPointSerializer extends JsonSerializer { + + @Override + public void serialize(final SeriesPoint seriesPoint, final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider) throws IOException { + jsonGenerator.writeStartArray(); + seriesPoint.toJson(jsonGenerator); + jsonGenerator.writeEndArray(); + } + +} diff --git a/src/main/java/de/ph87/data/series/SeriesPointsRequest.java b/src/main/java/de/ph87/data/series/SeriesPointsRequest.java new file mode 100644 index 0000000..1d349f3 --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesPointsRequest.java @@ -0,0 +1,45 @@ +package de.ph87.data.series; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import de.ph87.data.series.data.Interval; +import lombok.Data; +import lombok.NonNull; + +import java.time.ZonedDateTime; + +@Data +public class SeriesPointsRequest { + + public final long id; + + @NonNull + public final Interval interval; + + public final long offset; + + public final long duration; + + @NonNull + @JsonIgnore + public final ZonedDateTime first; + + @NonNull + @JsonIgnore + public final ZonedDateTime after; + + public SeriesPointsRequest( + @JsonProperty("id") final long id, + @JsonProperty("interval") final Interval interval, + @JsonProperty("offset") final long offset, + @JsonProperty("duration") final long duration + ) { + this.id = id; + this.interval = interval; + this.offset = offset; + this.duration = duration; + this.after = interval.align.apply(ZonedDateTime.now()).minus(interval.amount * (offset - 1), interval.unit); + this.first = this.after.minus(interval.amount * duration, interval.unit); + } + +} diff --git a/src/main/java/de/ph87/data/series/SeriesRepository.java b/src/main/java/de/ph87/data/series/SeriesRepository.java new file mode 100644 index 0000000..2cd8e89 --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesRepository.java @@ -0,0 +1,21 @@ +package de.ph87.data.series; + +import lombok.NonNull; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.ListCrudRepository; + +import java.util.List; +import java.util.Optional; + +public interface SeriesRepository extends ListCrudRepository { + + @NonNull + Optional findByName(@NonNull String seriesName); + + @Query("select new de.ph87.data.series.SeriesDto(s) from Series s where s.name = :name") + SeriesDto findDtoByName(@NonNull String name); + + @Query("select new de.ph87.data.series.SeriesDto(t) from Series t") + List findAllDto(); + +} diff --git a/src/main/java/de/ph87/data/series/SeriesService.java b/src/main/java/de/ph87/data/series/SeriesService.java new file mode 100644 index 0000000..01545a5 --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesService.java @@ -0,0 +1,38 @@ +package de.ph87.data.series; + +import de.ph87.data.series.data.bool.BoolService; +import de.ph87.data.series.data.delta.DeltaService; +import de.ph87.data.series.data.varying.VaryingService; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SeriesService { + + private final SeriesRepository seriesRepository; + + private final BoolService boolService; + + private final DeltaService deltaService; + + private final VaryingService varyingService; + + @NonNull + public List points(@NonNull final SeriesPointsRequest request) { + final Series series = seriesRepository.findById(request.id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + return switch (series.getType()) { + case BOOL -> boolService.points(series, request); + case DELTA -> deltaService.points(series, request); + case VARYING -> varyingService.points(series, request); + }; + } + +} diff --git a/src/main/java/de/ph87/data/series/SeriesType.java b/src/main/java/de/ph87/data/series/SeriesType.java new file mode 100644 index 0000000..c8c40f9 --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesType.java @@ -0,0 +1,5 @@ +package de.ph87.data.series; + +public enum SeriesType { + BOOL, DELTA, VARYING +} diff --git a/src/main/java/de/ph87/data/series/data/DataId.java b/src/main/java/de/ph87/data/series/data/DataId.java new file mode 100644 index 0000000..4e0d2ee --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/DataId.java @@ -0,0 +1,40 @@ +package de.ph87.data.series.data; + +import de.ph87.data.series.Series; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.ManyToOne; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.ToString; + +import java.time.ZonedDateTime; + +@Getter +@ToString +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor +public class DataId { + + @NonNull + @ManyToOne(optional = false) + private Series series; + + @NonNull + @Column(nullable = false) + private ZonedDateTime date; + + public DataId(@NonNull final Series series, @NonNull final ZonedDateTime date, @NonNull final Interval interval) { + this.series = series; + this.date = interval.align.apply(date); + } + + public DataId(@NonNull final Series series, @NonNull final ZonedDateTime date) { + this.series = series; + this.date = date; + } + +} diff --git a/src/main/java/de/ph87/data/series/data/Interval.java b/src/main/java/de/ph87/data/series/data/Interval.java new file mode 100644 index 0000000..2a7e623 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/Interval.java @@ -0,0 +1,29 @@ +package de.ph87.data.series.data; + +import lombok.NonNull; + +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.function.Function; + +public enum Interval { + FIVE(t -> t.truncatedTo(ChronoUnit.MINUTES).minusMinutes(t.getMinute() % 5), ChronoUnit.MINUTES, 5), + HOUR(t -> t.truncatedTo(ChronoUnit.HOURS), ChronoUnit.HOURS, 1), + DAY(t -> t.truncatedTo(ChronoUnit.DAYS), ChronoUnit.DAYS, 1), + WEEK(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfWeek().getValue() - 1), ChronoUnit.WEEKS, 1), + MONTH(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfMonth() - 1), ChronoUnit.MONTHS, 1), + YEAR(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfYear() - 1), ChronoUnit.YEARS, 1), + ; + + public final Function align; + + public final ChronoUnit unit; + + public final int amount; + + Interval(final Function align, @NonNull final ChronoUnit unit, final int amount) { + this.align = align; + this.unit = unit; + this.amount = amount; + } +} diff --git a/src/main/java/de/ph87/data/series/data/bool/Bool.java b/src/main/java/de/ph87/data/series/data/bool/Bool.java new file mode 100644 index 0000000..484ae97 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/bool/Bool.java @@ -0,0 +1,48 @@ +package de.ph87.data.series.data.bool; + +import de.ph87.data.series.data.DataId; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.Setter; +import lombok.ToString; + +import java.time.ZonedDateTime; + +@Entity +@Getter +@ToString +@NoArgsConstructor +public class Bool { + + @Id + @NonNull + private DataId id; + + @Version + private long version; + + @Column(nullable = false) + private boolean state; + + @Setter + @NonNull + @Column(nullable = false, name = "`end`") + private ZonedDateTime end; + + @Setter + @Column(nullable = false) + private boolean terminated; + + public Bool(@NonNull final DataId id, @NonNull final ZonedDateTime end, final boolean state, final boolean terminated) { + this.id = id; + this.end = end; + this.state = state; + this.terminated = terminated; + } + +} diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolDto.java b/src/main/java/de/ph87/data/series/data/bool/BoolDto.java new file mode 100644 index 0000000..7a39b08 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/bool/BoolDto.java @@ -0,0 +1,34 @@ +package de.ph87.data.series.data.bool; + +import de.ph87.data.series.SeriesDto; +import de.ph87.data.websocket.IWebsocketMessage; +import lombok.Data; +import lombok.NonNull; + +import java.time.ZonedDateTime; + +@Data +public class BoolDto implements IWebsocketMessage { + + @NonNull + public final SeriesDto series; + + @NonNull + public final ZonedDateTime date; + + public final boolean state; + + @NonNull + public final ZonedDateTime end; + + public final boolean terminated; + + public BoolDto(@NonNull final Bool bool) { + this.series = new SeriesDto(bool.getId().getSeries()); + this.date = bool.getId().getDate(); + this.end = bool.getEnd(); + this.state = bool.isState(); + this.terminated = bool.isTerminated(); + } + +} diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolPoint.java b/src/main/java/de/ph87/data/series/data/bool/BoolPoint.java new file mode 100644 index 0000000..96ae7c2 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/bool/BoolPoint.java @@ -0,0 +1,36 @@ +package de.ph87.data.series.data.bool; + +import com.fasterxml.jackson.core.JsonGenerator; +import de.ph87.data.series.SeriesPoint; +import lombok.NonNull; + +import java.io.IOException; +import java.time.ZonedDateTime; + +@SuppressWarnings("unused") // used by repository query +public class BoolPoint implements SeriesPoint { + + public final ZonedDateTime begin; + + public final ZonedDateTime end; + + public final boolean state; + + public final boolean terminated; + + public BoolPoint(@NonNull final Bool bool) { + this.begin = bool.getId().getDate(); + this.end = bool.getEnd(); + this.state = bool.isState(); + this.terminated = bool.isTerminated(); + } + + @Override + public void toJson(final JsonGenerator jsonGenerator) throws IOException { + jsonGenerator.writeNumber(begin.toEpochSecond()); + jsonGenerator.writeNumber(end.toEpochSecond()); + jsonGenerator.writeNumber(state ? 1 : 0); + jsonGenerator.writeNumber(terminated ? 1 : 0); + } + +} diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolRepo.java b/src/main/java/de/ph87/data/series/data/bool/BoolRepo.java new file mode 100644 index 0000000..00bcf90 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/bool/BoolRepo.java @@ -0,0 +1,18 @@ +package de.ph87.data.series.data.bool; + +import de.ph87.data.series.Series; +import de.ph87.data.series.data.DataId; +import lombok.NonNull; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface BoolRepo extends CrudRepository { + + @NonNull + @Query("select new de.ph87.data.series.data.bool.BoolPoint(e) from Bool e where e.id.series = :series and e.end >= :first and e.id.date < :after") + List points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after); + +} diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolService.java b/src/main/java/de/ph87/data/series/data/bool/BoolService.java new file mode 100644 index 0000000..c88124e --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/bool/BoolService.java @@ -0,0 +1,66 @@ +package de.ph87.data.series.data.bool; + +import de.ph87.data.series.Series; +import de.ph87.data.series.SeriesPointsRequest; +import de.ph87.data.series.data.DataId; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BoolService { + + private final BoolRepo boolRepo; + + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public void write(@NonNull final Series series, @NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end, final boolean state, final boolean terminated) { + final Bool bool = updateOrCreate(series, begin, end, state, terminated); + log.debug("Bool written: {}", bool); + applicationEventPublisher.publishEvent(new BoolDto(bool)); + } + + @NonNull + private Bool updateOrCreate(@NonNull final Series series, @NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end, final boolean state, final boolean terminated) { + final DataId id = new DataId(series, begin); + return boolRepo + .findById(id) + .stream() + .peek( + existing -> { + if (existing.isState() != state) { + id.getSeries().error(log, "Differing states: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing)); + return; + } + if (existing.getEnd().isAfter(end)) { + id.getSeries().error(log, "End ran backwards: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing)); + return; + } + if (existing.isTerminated() && (!terminated || !existing.getEnd().equals(end))) { + id.getSeries().error(log, "Already terminated: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing)); + return; + } + existing.setEnd(end); + existing.setTerminated(terminated); + }) + .findFirst() + .orElseGet( + () -> boolRepo.save(new Bool(id, end, state, terminated)) + ); + } + + @NonNull + public List points(@NonNull final Series series, @NonNull final SeriesPointsRequest request) { + return boolRepo.points(series, request.first, request.after); + } + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/Delta.java b/src/main/java/de/ph87/data/series/data/delta/Delta.java new file mode 100644 index 0000000..18a50a2 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/Delta.java @@ -0,0 +1,116 @@ +package de.ph87.data.series.data.delta; + +import de.ph87.data.series.data.DataId; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString +@MappedSuperclass +@NoArgsConstructor +public abstract class Delta { + + @Id + @NonNull + private DataId id; + + @Version + private long version; + + @Column(nullable = false) + private double first; + + @NonNull + @Column(nullable = false) + private double last; + + protected Delta(@NonNull final DataId id, final double value) { + this.id = id; + this.first = value; + this.last = value; + } + + public void update(final double value) { + this.last = value; + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "DeltaFive") + public static class Five extends Delta { + + public Five(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "DeltaHour") + public static class Hour extends Delta { + + public Hour(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "DeltaDay") + public static class Day extends Delta { + + public Day(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "DeltaWeek") + public static class Week extends Delta { + + public Week(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "DeltaMonth") + public static class Month extends Delta { + + public Month(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "DeltaYear") + public static class Year extends Delta { + + public Year(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java b/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java new file mode 100644 index 0000000..0fa4706 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java @@ -0,0 +1,87 @@ +package de.ph87.data.series.data.delta; + +import de.ph87.data.series.data.DataId; +import de.ph87.data.websocket.IWebsocketMessage; +import lombok.Data; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +@Data +public abstract class DeltaDto implements IWebsocketMessage { + + @NonNull + public final DataId id; + + public final double first; + + @NonNull + public final double last; + + protected DeltaDto(@NonNull final Delta delta) { + this.id = delta.getId(); + this.first = delta.getFirst(); + this.last = delta.getLast(); + } + + @Getter + @ToString + public static class Five extends DeltaDto { + + public Five(@NonNull final Delta.Five delta) { + super(delta); + } + + } + + @Getter + @ToString + public static class Hour extends DeltaDto { + + public Hour(@NonNull final Delta.Hour delta) { + super(delta); + } + + } + + @Getter + @ToString + public static class Day extends DeltaDto { + + public Day(@NonNull final Delta.Day delta) { + super(delta); + } + + } + + @Getter + @ToString + public static class Week extends DeltaDto { + + public Week(@NonNull final Delta.Week delta) { + super(delta); + } + + } + + @Getter + @ToString + public static class Month extends DeltaDto { + + public Month(@NonNull final Delta.Month delta) { + super(delta); + } + + } + + @Getter + @ToString + public static class Year extends DeltaDto { + + public Year(@NonNull final Delta.Year delta) { + super(delta); + } + + } + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaPoint.java b/src/main/java/de/ph87/data/series/data/delta/DeltaPoint.java new file mode 100644 index 0000000..9ee466f --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaPoint.java @@ -0,0 +1,32 @@ +package de.ph87.data.series.data.delta; + +import com.fasterxml.jackson.core.JsonGenerator; +import de.ph87.data.series.SeriesPoint; +import lombok.NonNull; + +import java.io.IOException; +import java.time.ZonedDateTime; + +@SuppressWarnings("unused") // used by repository query +public class DeltaPoint implements SeriesPoint { + + public final ZonedDateTime date; + + public final double first; + + public final double last; + + public DeltaPoint(@NonNull final Delta delta) { + this.date = delta.getId().getDate(); + this.first = delta.getFirst(); + this.last = delta.getLast(); + } + + @Override + public void toJson(final JsonGenerator jsonGenerator) throws IOException { + jsonGenerator.writeNumber(date.toEpochSecond()); + jsonGenerator.writeNumber(first); + jsonGenerator.writeNumber(last); + } + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepo.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepo.java new file mode 100644 index 0000000..3d7de5c --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepo.java @@ -0,0 +1,20 @@ +package de.ph87.data.series.data.delta; + +import de.ph87.data.series.Series; +import de.ph87.data.series.data.DataId; +import lombok.NonNull; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.NoRepositoryBean; + +import java.time.ZonedDateTime; +import java.util.List; + +@NoRepositoryBean +public interface DeltaRepo extends CrudRepository { + + @NonNull + @Query("select new de.ph87.data.series.data.delta.DeltaPoint(e) from #{#entityName} e where e.id.series = :series and e.id.date >= :first and e.id.date < :after") + List points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after); + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoDay.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoDay.java new file mode 100644 index 0000000..c526efc --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoDay.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.delta; + +public interface DeltaRepoDay extends DeltaRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoFive.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoFive.java new file mode 100644 index 0000000..4f99a1a --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoFive.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.delta; + +public interface DeltaRepoFive extends DeltaRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoHour.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoHour.java new file mode 100644 index 0000000..8dc4b2a --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoHour.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.delta; + +public interface DeltaRepoHour extends DeltaRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoMonth.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoMonth.java new file mode 100644 index 0000000..fa12a47 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoMonth.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.delta; + +public interface DeltaRepoMonth extends DeltaRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoWeek.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoWeek.java new file mode 100644 index 0000000..b7d0812 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoWeek.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.delta; + +public interface DeltaRepoWeek extends DeltaRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoYear.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoYear.java new file mode 100644 index 0000000..179716f --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoYear.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.delta; + +public interface DeltaRepoYear extends DeltaRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaService.java b/src/main/java/de/ph87/data/series/data/delta/DeltaService.java new file mode 100644 index 0000000..2b7066d --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaService.java @@ -0,0 +1,67 @@ +package de.ph87.data.series.data.delta; + +import de.ph87.data.series.Series; +import de.ph87.data.series.SeriesPointsRequest; +import de.ph87.data.series.data.DataId; +import de.ph87.data.series.data.Interval; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeltaService { + + private final DeltaRepoFive five; + + private final DeltaRepoHour hour; + + private final DeltaRepoDay day; + + private final DeltaRepoWeek week; + + private final DeltaRepoMonth month; + + private final DeltaRepoYear year; + + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public void write(@NonNull final Series series, @NonNull final ZonedDateTime date, final double value) { + write(series, five, Interval.FIVE, date, value, Delta.Five::new, DeltaDto.Five::new); + write(series, hour, Interval.HOUR, date, value, Delta.Hour::new, DeltaDto.Hour::new); + write(series, day, Interval.DAY, date, value, Delta.Day::new, DeltaDto.Day::new); + write(series, week, Interval.WEEK, date, value, Delta.Week::new, DeltaDto.Week::new); + write(series, month, Interval.MONTH, date, value, Delta.Month::new, DeltaDto.Month::new); + write(series, year, Interval.YEAR, date, value, Delta.Year::new, DeltaDto.Year::new); + } + + private void write(@NonNull final Series series, @NonNull final DeltaRepo repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction create, @NonNull final Function toDto) { + final DataId id = new DataId(series, date, interval); + final DELTA delta = repo.findById(id).stream().peek(existing -> existing.update(value)).findFirst().orElseGet(() -> repo.save(create.apply(id, value))); + log.debug("Delta written: {}", delta); + applicationEventPublisher.publishEvent(toDto.apply(delta)); + } + + @NonNull + public List points(@NonNull final Series series, @NonNull final SeriesPointsRequest request) { + return switch (request.interval) { + case FIVE -> five.points(series, request.first, request.after); + case HOUR -> hour.points(series, request.first, request.after); + case DAY -> day.points(series, request.first, request.after); + case WEEK -> week.points(series, request.first, request.after); + case MONTH -> month.points(series, request.first, request.after); + case YEAR -> year.points(series, request.first, request.after); + }; + } + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/Varying.java b/src/main/java/de/ph87/data/series/data/varying/Varying.java new file mode 100644 index 0000000..8f1c16e --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/Varying.java @@ -0,0 +1,126 @@ +package de.ph87.data.series.data.varying; + +import de.ph87.data.series.data.DataId; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString +@MappedSuperclass +@NoArgsConstructor +public abstract class Varying { + + @Id + @NonNull + private DataId id; + + @Version + private long version; + + @Column(nullable = false) + private double min; + + @Column(nullable = false) + private double max; + + @Column(nullable = false) + private double avg; + + @Column(nullable = false) + private int count; + + protected Varying(@NonNull final DataId id, final double value) { + this.id = id; + this.min = value; + this.max = value; + this.avg = value; + this.count = 1; + } + + public void update(final double value) { + this.min = Math.min(this.min, value); + this.max = Math.max(this.max, value); + this.avg = (this.avg * this.count + value) / (this.count + 1); + this.count++; + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "VaryingFive") + public static class Five extends Varying { + + public Five(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "VaryingHour") + public static class Hour extends Varying { + + public Hour(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "VaryingDay") + public static class Day extends Varying { + + public Day(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "VaryingWeek") + public static class Week extends Varying { + + public Week(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "VaryingMonth") + public static class Month extends Varying { + + public Month(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + + @Getter + @ToString + @NoArgsConstructor + @Entity(name = "VaryingYear") + public static class Year extends Varying { + + public Year(@NonNull final DataId id, @NonNull final double value) { + super(id, value); + } + + } + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java b/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java new file mode 100644 index 0000000..0e31545 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java @@ -0,0 +1,98 @@ +package de.ph87.data.series.data.varying; + +import de.ph87.data.series.SeriesDto; +import de.ph87.data.websocket.IWebsocketMessage; +import lombok.Data; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.time.ZonedDateTime; + +@Data +public abstract class VaryingDto implements IWebsocketMessage { + + @NonNull + public final SeriesDto seriesDto; + + @NonNull + public final ZonedDateTime date; + + public final double min; + + public final double max; + + public final double avg; + + public final int count; + + protected VaryingDto(@NonNull final Varying varying) { + this.seriesDto = new SeriesDto(varying.getId().getSeries()); + this.date = varying.getId().getDate(); + this.min = varying.getMin(); + this.max = varying.getMax(); + this.avg = varying.getAvg(); + this.count = varying.getCount(); + } + + @Getter + @ToString + public static class Five extends VaryingDto { + + public Five(@NonNull final Varying.Five varying) { + super(varying); + } + + } + + @Getter + @ToString + public static class Hour extends VaryingDto { + + public Hour(@NonNull final Varying.Hour varying) { + super(varying); + } + + } + + @Getter + @ToString + public static class Day extends VaryingDto { + + public Day(@NonNull final Varying.Day varying) { + super(varying); + } + + } + + @Getter + @ToString + public static class Week extends VaryingDto { + + public Week(@NonNull final Varying.Week varying) { + super(varying); + } + + } + + @Getter + @ToString + public static class Month extends VaryingDto { + + public Month(@NonNull final Varying.Month varying) { + super(varying); + } + + } + + @Getter + @ToString + public static class Year extends VaryingDto { + + public Year(@NonNull final Varying.Year varying) { + super(varying); + } + + } + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingPoint.java b/src/main/java/de/ph87/data/series/data/varying/VaryingPoint.java new file mode 100644 index 0000000..37fe3d5 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingPoint.java @@ -0,0 +1,36 @@ +package de.ph87.data.series.data.varying; + +import com.fasterxml.jackson.core.JsonGenerator; +import de.ph87.data.series.SeriesPoint; +import lombok.NonNull; + +import java.io.IOException; +import java.time.ZonedDateTime; + +@SuppressWarnings("unused") // used by repository query +public class VaryingPoint implements SeriesPoint { + + public final ZonedDateTime date; + + public final double min; + + public final double max; + + public final double avg; + + public VaryingPoint(@NonNull final Varying varying) { + this.date = varying.getId().getDate(); + this.min = varying.getMin(); + this.max = varying.getMax(); + this.avg = varying.getAvg(); + } + + @Override + public void toJson(final JsonGenerator jsonGenerator) throws IOException { + jsonGenerator.writeNumber(date.toEpochSecond()); + jsonGenerator.writeNumber(min); + jsonGenerator.writeNumber(max); + jsonGenerator.writeNumber(avg); + } + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepo.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepo.java new file mode 100644 index 0000000..fa8a70b --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepo.java @@ -0,0 +1,20 @@ +package de.ph87.data.series.data.varying; + +import de.ph87.data.series.Series; +import de.ph87.data.series.data.DataId; +import lombok.NonNull; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.NoRepositoryBean; + +import java.time.ZonedDateTime; +import java.util.List; + +@NoRepositoryBean +public interface VaryingRepo extends CrudRepository { + + @NonNull + @Query("select new de.ph87.data.series.data.varying.VaryingPoint(e) from #{#entityName} e where e.id.series = :series and e.id.date >= :first and e.id.date < :after") + List points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after); + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoDay.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoDay.java new file mode 100644 index 0000000..0ba2c73 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoDay.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.varying; + +public interface VaryingRepoDay extends VaryingRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoFive.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoFive.java new file mode 100644 index 0000000..57ca719 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoFive.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.varying; + +public interface VaryingRepoFive extends VaryingRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoHour.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoHour.java new file mode 100644 index 0000000..8769b05 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoHour.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.varying; + +public interface VaryingRepoHour extends VaryingRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoMonth.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoMonth.java new file mode 100644 index 0000000..d57f4c4 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoMonth.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.varying; + +public interface VaryingRepoMonth extends VaryingRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoWeek.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoWeek.java new file mode 100644 index 0000000..47091ad --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoWeek.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.varying; + +public interface VaryingRepoWeek extends VaryingRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoYear.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoYear.java new file mode 100644 index 0000000..fefbef8 --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoYear.java @@ -0,0 +1,5 @@ +package de.ph87.data.series.data.varying; + +public interface VaryingRepoYear extends VaryingRepo { + +} diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingService.java b/src/main/java/de/ph87/data/series/data/varying/VaryingService.java new file mode 100644 index 0000000..49164bf --- /dev/null +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingService.java @@ -0,0 +1,67 @@ +package de.ph87.data.series.data.varying; + +import de.ph87.data.series.Series; +import de.ph87.data.series.SeriesPointsRequest; +import de.ph87.data.series.data.DataId; +import de.ph87.data.series.data.Interval; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; + +@Slf4j +@Service +@RequiredArgsConstructor +public class VaryingService { + + private final VaryingRepoFive five; + + private final VaryingRepoHour hour; + + private final VaryingRepoDay day; + + private final VaryingRepoWeek week; + + private final VaryingRepoMonth month; + + private final VaryingRepoYear year; + + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public void write(@NonNull final Series series, @NonNull final ZonedDateTime date, final double value) { + write(series, five, Interval.FIVE, date, value, Varying.Five::new, VaryingDto.Five::new); + write(series, hour, Interval.HOUR, date, value, Varying.Hour::new, VaryingDto.Hour::new); + write(series, day, Interval.DAY, date, value, Varying.Day::new, VaryingDto.Day::new); + write(series, week, Interval.WEEK, date, value, Varying.Week::new, VaryingDto.Week::new); + write(series, month, Interval.MONTH, date, value, Varying.Month::new, VaryingDto.Month::new); + write(series, year, Interval.YEAR, date, value, Varying.Year::new, VaryingDto.Year::new); + } + + private void write(@NonNull final Series series, @NonNull final VaryingRepo repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction create, @NonNull final Function toDto) { + final DataId id = new DataId(series, date, interval); + final VARYING varying = repo.findById(id).stream().peek(existing -> existing.update(value)).findFirst().orElseGet(() -> repo.save(create.apply(id, value))); + log.debug("Varying written: {}", varying); + applicationEventPublisher.publishEvent(toDto.apply(varying)); + } + + @NonNull + public List points(@NonNull final Series series, @NonNull final SeriesPointsRequest request) { + return switch (request.interval) { + case FIVE -> five.points(series, request.first, request.after); + case HOUR -> hour.points(series, request.first, request.after); + case DAY -> day.points(series, request.first, request.after); + case WEEK -> week.points(series, request.first, request.after); + case MONTH -> month.points(series, request.first, request.after); + case YEAR -> year.points(series, request.first, request.after); + }; + } + +} diff --git a/src/main/java/de/ph87/data/topic/TimestampType.java b/src/main/java/de/ph87/data/topic/TimestampType.java new file mode 100644 index 0000000..bc74417 --- /dev/null +++ b/src/main/java/de/ph87/data/topic/TimestampType.java @@ -0,0 +1,5 @@ +package de.ph87.data.topic; + +public enum TimestampType { + EPOCH_MILLISECONDS, EPOCH_SECONDS +} diff --git a/src/main/java/de/ph87/data/topic/Topic.java b/src/main/java/de/ph87/data/topic/Topic.java new file mode 100644 index 0000000..21efd48 --- /dev/null +++ b/src/main/java/de/ph87/data/topic/Topic.java @@ -0,0 +1,85 @@ +package de.ph87.data.topic; + +import de.ph87.data.log.AbstractEntityLog; +import de.ph87.data.topic.query.TopicQuery; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.Setter; +import lombok.ToString; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@ToString +@NoArgsConstructor +public class Topic extends AbstractEntityLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Version + private long version; + + @NonNull + @Column(nullable = false, unique = true) + private String name; + + @Setter + @Column(nullable = false) + private boolean enabled = true; + + @NonNull + @Column(nullable = false) + private ZonedDateTime first; + + @NonNull + @Column(nullable = false) + private ZonedDateTime last; + + @Column(nullable = false) + private int count; + + @Setter + @NonNull + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TimestampType timestampType = TimestampType.EPOCH_SECONDS; + + @Setter + @NonNull + @Column(nullable = false) + private String timestampQuery = ""; + + @NonNull + @ToString.Exclude + @ElementCollection(fetch = FetchType.EAGER) + private List queries = new ArrayList<>(); + + public Topic(@NonNull final String name) { + this.name = name; + this.first = ZonedDateTime.now(); + this.last = this.first; + this.count = 1; + } + + public void update() { + this.last = ZonedDateTime.now(); + this.count++; + } + +} diff --git a/src/main/java/de/ph87/data/topic/TopicController.java b/src/main/java/de/ph87/data/topic/TopicController.java new file mode 100644 index 0000000..2fa19d7 --- /dev/null +++ b/src/main/java/de/ph87/data/topic/TopicController.java @@ -0,0 +1,45 @@ +package de.ph87.data.topic; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("Topic") +public class TopicController { + + private final TopicService topicService; + + private final TopicRepository topicRepository; + + @GetMapping("findAll") + public List findAll() { + return topicRepository.findAllDto(); + } + + @PostMapping("{id}/setEnabled") + public TopicDto setEnabled(@PathVariable final long id, @RequestBody final boolean enabled) { + return topicService.setEnabled(id, enabled); + } + + @PostMapping("{id}/setTimestampQuery") + public TopicDto setTimestampQuery(@PathVariable final long id, @NonNull @RequestBody final String timestampQuery) { + return topicService.setTimestampQuery(id, timestampQuery); + } + + @PostMapping("{id}/setTimestampType") + public TopicDto setTimestampType(@PathVariable final long id, @NonNull @RequestBody final String timestampType) { + return topicService.setTimestampType(id, TimestampType.valueOf(timestampType)); + } + +} diff --git a/src/main/java/de/ph87/data/topic/TopicDto.java b/src/main/java/de/ph87/data/topic/TopicDto.java new file mode 100644 index 0000000..0e45cdd --- /dev/null +++ b/src/main/java/de/ph87/data/topic/TopicDto.java @@ -0,0 +1,50 @@ +package de.ph87.data.topic; + +import de.ph87.data.topic.query.TopicQueryDto; +import de.ph87.data.websocket.IWebsocketMessage; +import lombok.Data; +import lombok.NonNull; + +import java.time.ZonedDateTime; +import java.util.List; + +@Data +public class TopicDto implements IWebsocketMessage { + + public final long id; + + @NonNull + public final String name; + + @NonNull + public final ZonedDateTime first; + + @NonNull + public final ZonedDateTime last; + + public final long count; + + public final boolean enabled; + + @NonNull + public final TimestampType timestampType; + + @NonNull + public final String timestampQuery; + + @NonNull + public final List queries; + + public TopicDto(@NonNull final Topic topic) { + this.id = topic.getId(); + this.name = topic.getName(); + this.first = topic.getFirst(); + this.last = topic.getLast(); + this.count = topic.getCount(); + this.enabled = topic.isEnabled(); + this.timestampType = topic.getTimestampType(); + this.timestampQuery = topic.getTimestampQuery(); + this.queries = topic.getQueries().stream().map(TopicQueryDto::new).toList(); + } + +} diff --git a/src/main/java/de/ph87/data/topic/TopicReceiver.java b/src/main/java/de/ph87/data/topic/TopicReceiver.java new file mode 100644 index 0000000..2921180 --- /dev/null +++ b/src/main/java/de/ph87/data/topic/TopicReceiver.java @@ -0,0 +1,129 @@ +package de.ph87.data.topic; + +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import de.ph87.data.mqtt.MqttInbound; +import de.ph87.data.series.Series; +import de.ph87.data.series.SeriesDto; +import de.ph87.data.series.SeriesType; +import de.ph87.data.series.data.bool.BoolService; +import de.ph87.data.series.data.delta.DeltaService; +import de.ph87.data.series.data.varying.VaryingService; +import de.ph87.data.topic.query.TopicQuery; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TopicReceiver { + + private final TopicRepository topicRepository; + + private final BoolService boolService; + + private final DeltaService deltaService; + + private final VaryingService varyingService; + + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public void receive(@NonNull final MqttInbound inbound) { + final Topic topic = updateOrCreate(inbound.topic); + if (!topic.isEnabled()) { + log.debug("Topic is not enabled: topic={}", topic); + return; + } + if (topic.getTimestampQuery().isEmpty()) { + log.debug("Topic timestampQuery is not set: topic={}", topic); + return; + } + if (topic.getQueries().isEmpty()) { + log.debug("Topic queries not set: topic={}", topic); + return; + } + + log.debug("Parsing Topic payload: topic={}", topic); + final DocumentContext json; + try { + json = JsonPath.parse(inbound.payload); + } catch (Exception e) { + topic.error(log, "Error parsing JSON: %s\n topic=%s\n inbound=%s".formatted(e.toString(), topic, inbound), e); + return; + } + + log.debug("Executing Topic timestampQuery: topic={}", topic); + final ZonedDateTime date; + try { + date = queryTimestamp(json, topic.getTimestampQuery(), topic.getTimestampType()); + } catch (Exception e) { + topic.error(log, "Error executing Topic timestampQuery: %s\n topic=%s\n inbound=%s".formatted(e.toString(), topic, inbound), e); + return; + } + + topic.getQueries().forEach(query -> query(topic, inbound, json, date, query)); + } + + private void query(@NonNull final Topic topic, @NonNull final MqttInbound inbound, @NonNull final DocumentContext json, @NonNull final ZonedDateTime date, @NonNull final TopicQuery query) { + log.debug("Executing TopicQuery: topicQuery={}", query); + try { + final Series series = query.getSeries(); + if (series == null) { + log.debug("TopicQuery Series not set: topic={}", topic); + return; + } + if (query.getValueQuery().isEmpty()) { + log.debug("TopicQuery valueQuery not set: topic={}", topic); + return; + } + if (series.getType() == SeriesType.BOOL) { + if (query.getBeginQuery().isEmpty()) { + log.debug("TopicQuery beginQuery not set: topic={}", topic); + return; + } + if (query.getTerminatedQuery().isEmpty()) { + log.debug("TopicQuery terminatedQuery not set: topic={}", topic); + return; + } + } + final double value = query.getFunction().apply(json.read(query.getValueQuery(), Double.class)) * query.getFactor(); + series.update(date, value); + applicationEventPublisher.publishEvent(new SeriesDto(series)); + + switch (series.getType()) { + case BOOL -> { + final ZonedDateTime begin = queryTimestamp(json, query.getBeginQuery(), topic.getTimestampType()); + final boolean terminated = json.read(query.getTerminatedQuery(), Boolean.class); + boolService.write(series, begin, date, value > 0, terminated); + } + case DELTA -> deltaService.write(series, date, value); + case VARYING -> varyingService.write(series, date, value); + } + } catch (Exception e) { + topic.error(log, "Error executing TopicQuery: %s\n topic=%s\n query=%s\n inbound=%s".formatted(e.toString(), topic, query, inbound), e); + } + } + + @NonNull + private Topic updateOrCreate(@NonNull final String name) { + return topicRepository.findByName(name).stream().peek(Topic::update).findFirst().orElseGet(() -> topicRepository.save(new Topic(name))); + } + + @NonNull + private static ZonedDateTime queryTimestamp(@NonNull final DocumentContext json, @NonNull final String query, @NonNull final TimestampType type) { + return switch (type) { + case TimestampType.EPOCH_SECONDS -> ZonedDateTime.ofInstant(Instant.ofEpochSecond(json.read(query, Long.class)), ZoneId.systemDefault()); + case TimestampType.EPOCH_MILLISECONDS -> ZonedDateTime.ofInstant(Instant.ofEpochMilli(json.read(query, Long.class)), ZoneId.systemDefault()); + }; + } + +} diff --git a/src/main/java/de/ph87/data/topic/TopicRepository.java b/src/main/java/de/ph87/data/topic/TopicRepository.java new file mode 100644 index 0000000..c325c77 --- /dev/null +++ b/src/main/java/de/ph87/data/topic/TopicRepository.java @@ -0,0 +1,18 @@ +package de.ph87.data.topic; + +import lombok.NonNull; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.ListCrudRepository; + +import java.util.List; +import java.util.Optional; + +public interface TopicRepository extends ListCrudRepository { + + @NonNull + Optional findByName(@NonNull String name); + + @Query("select new de.ph87.data.topic.TopicDto(t) from Topic t") + List findAllDto(); + +} diff --git a/src/main/java/de/ph87/data/topic/TopicService.java b/src/main/java/de/ph87/data/topic/TopicService.java new file mode 100644 index 0000000..ba2e4ee --- /dev/null +++ b/src/main/java/de/ph87/data/topic/TopicService.java @@ -0,0 +1,46 @@ +package de.ph87.data.topic; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.function.Consumer; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TopicService { + + private final TopicRepository topicRepository; + + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public TopicDto setEnabled(final long id, final boolean enabled) { + return set(id, t -> t.setEnabled(enabled)); + } + + @Transactional + public TopicDto setTimestampQuery(final long id, @NonNull final String timestampQuery) { + return set(id, t -> t.setTimestampQuery(timestampQuery)); + } + + @Transactional + public TopicDto setTimestampType(final long id, @NonNull final TimestampType timestampType) { + return set(id, t -> t.setTimestampType(timestampType)); + } + + @NonNull + private TopicDto set(final long id, @NonNull final Consumer modifier) { + final Topic topic = topicRepository.findById(id).orElseThrow(); + modifier.accept(topic); + log.info("Topic CHANGED: {}", topic); + final TopicDto dto = new TopicDto(topic); + applicationEventPublisher.publishEvent(dto); + return dto; + } + +} diff --git a/src/main/java/de/ph87/data/topic/query/TopicQuery.java b/src/main/java/de/ph87/data/topic/query/TopicQuery.java new file mode 100644 index 0000000..ab97d7a --- /dev/null +++ b/src/main/java/de/ph87/data/topic/query/TopicQuery.java @@ -0,0 +1,66 @@ +package de.ph87.data.topic.query; + +import de.ph87.data.series.Series; +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString +@Embeddable +@NoArgsConstructor +public class TopicQuery { + + @Nullable + @ManyToOne + private Series series; + + @NonNull + @Column(nullable = false) + private String valueQuery = ""; + + @NonNull + @Column(nullable = false) + private String beginQuery = ""; + + @NonNull + @Column(nullable = false) + private String terminatedQuery = ""; + + @NonNull + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TopicQueryFunction function = TopicQueryFunction.NONE; + + @Column(nullable = false) + private double factor; + + public TopicQuery(@Nullable final Series series, @NonNull final String valueQuery) { + this(series, valueQuery, "", "", TopicQueryFunction.NONE, 1); + } + + public TopicQuery(@Nullable final Series series, @NonNull final String valueQuery, final double factor) { + this(series, valueQuery, "", "", TopicQueryFunction.NONE, factor); + } + + public TopicQuery(@Nullable final Series series, @NonNull final String valueQuery, @NonNull final TopicQueryFunction function) { + this(series, valueQuery, "", "", function, 1); + } + + public TopicQuery(@Nullable final Series series, @NonNull final String valueQuery, @NonNull final String beginQuery, @NonNull final String terminatedQuery, @NonNull final TopicQueryFunction function, final double factor) { + this.series = series; + this.valueQuery = valueQuery; + this.beginQuery = beginQuery; + this.terminatedQuery = terminatedQuery; + this.function = function; + this.factor = factor; + } + +} diff --git a/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java b/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java new file mode 100644 index 0000000..e18a029 --- /dev/null +++ b/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java @@ -0,0 +1,41 @@ +package de.ph87.data.topic.query; + +import de.ph87.data.series.SeriesDto; +import jakarta.annotation.Nullable; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import static de.ph87.data.Helpers.map; + +@Getter +@ToString +public class TopicQueryDto { + + @Nullable + public final SeriesDto series; + + @NonNull + public final String valueQuery; + + @NonNull + public final String beginQuery; + + @NonNull + public final String terminatedQuery; + + @NonNull + public final TopicQueryFunction function; + + public final double factor; + + public TopicQueryDto(@NonNull final TopicQuery topicQuery) { + this.series = map(topicQuery.getSeries(), SeriesDto::new); + this.valueQuery = topicQuery.getValueQuery(); + this.beginQuery = topicQuery.getBeginQuery(); + this.terminatedQuery = topicQuery.getTerminatedQuery(); + this.function = topicQuery.getFunction(); + this.factor = topicQuery.getFactor(); + } + +} diff --git a/src/main/java/de/ph87/data/topic/query/TopicQueryFunction.java b/src/main/java/de/ph87/data/topic/query/TopicQueryFunction.java new file mode 100644 index 0000000..3704395 --- /dev/null +++ b/src/main/java/de/ph87/data/topic/query/TopicQueryFunction.java @@ -0,0 +1,23 @@ +package de.ph87.data.topic.query; + +import lombok.NonNull; + +import java.util.function.Function; + +public enum TopicQueryFunction { + NONE(v -> v), + ONLY_POSITIVE(v -> v > 0 ? v : 0), + ONLY_NEGATIVE_BUT_NEGATE(v -> v < 0 ? -v : 0), + ; + + private final Function function; + + TopicQueryFunction(@NonNull Function function) { + this.function = function; + } + + public double apply(final double value) { + return function.apply(value); + } + +} diff --git a/src/main/java/de/ph87/data/user/ConsumerWithException.java b/src/main/java/de/ph87/data/user/ConsumerWithException.java deleted file mode 100644 index eefb0d4..0000000 --- a/src/main/java/de/ph87/data/user/ConsumerWithException.java +++ /dev/null @@ -1,8 +0,0 @@ -package de.ph87.data.user; - -@FunctionalInterface -public interface ConsumerWithException { - - void accept(final T t) throws E; - -} diff --git a/src/main/java/de/ph87/data/user/Principal.java b/src/main/java/de/ph87/data/user/Principal.java deleted file mode 100644 index 5755c77..0000000 --- a/src/main/java/de/ph87/data/user/Principal.java +++ /dev/null @@ -1,23 +0,0 @@ -package de.ph87.data.user; - -import lombok.Data; -import lombok.NonNull; - -@Data -public class Principal { - - public final long id; - - @NonNull - public final String token; - - @NonNull - public final UserDto user; - - public Principal(@NonNull final String token, @NonNull final User user) { - this.id = user.getId(); - this.token = token; - this.user = new UserDto(user); - } - -} diff --git a/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java b/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java deleted file mode 100644 index 176fbb1..0000000 --- a/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java +++ /dev/null @@ -1,65 +0,0 @@ -package de.ph87.data.user; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.MethodParameter; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; -import org.springframework.web.server.ResponseStatusException; - -import java.util.Arrays; - -@Slf4j -@Component -@RequiredArgsConstructor -public class PrincipalArgumentResolver implements HandlerMethodArgumentResolver { - - public static final String AUTH_TOKEN_COOKIE_NAME = "PATRIX-DATA-MULTI-AUTH-TOKEN"; - - private final UserService userService; - - @Override - public boolean supportsParameter(final MethodParameter parameter) { - return parameter.getParameterType() == Principal.class; - } - - @Override - public Principal resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { - final boolean required = !parameter.hasParameterAnnotation(PrincipalNotRequired.class); - final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); - if (request == null) { - throw new RuntimeException("HttpServletRequest is required"); - } - log.debug("Principal: path={}", request.getRequestURL().toString()); - - final Principal principal; - if (request.getCookies() == null) { - log.debug("Principal: No cookies received."); - principal = null; - } else { - final String token = Arrays.stream(request.getCookies()).filter(c -> AUTH_TOKEN_COOKIE_NAME.equals(c.getName())).findFirst().map(Cookie::getValue).orElse(""); - if (token.isEmpty()) { - log.debug("Principal: Token not set."); - principal = null; - } else { - principal = userService.findPrincipalByToken(token).orElse(null); - } - } - if (principal == null) { - if (required) { - log.warn("Principal: Not set but REQUIRED"); - throw new ResponseStatusException(HttpStatus.FORBIDDEN); - } else { - log.debug("Principal: Not set but NOT required"); - } - } - return principal; - } - -} diff --git a/src/main/java/de/ph87/data/user/PrincipalNotRequired.java b/src/main/java/de/ph87/data/user/PrincipalNotRequired.java deleted file mode 100644 index 22b4321..0000000 --- a/src/main/java/de/ph87/data/user/PrincipalNotRequired.java +++ /dev/null @@ -1,9 +0,0 @@ -package de.ph87.data.user; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -@Retention(RetentionPolicy.RUNTIME) -public @interface PrincipalNotRequired { - -} diff --git a/src/main/java/de/ph87/data/user/User.java b/src/main/java/de/ph87/data/user/User.java deleted file mode 100644 index 1f2d39c..0000000 --- a/src/main/java/de/ph87/data/user/User.java +++ /dev/null @@ -1,62 +0,0 @@ -package de.ph87.data.user; - -import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.Version; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.NonNull; -import lombok.Setter; -import lombok.ToString; - -import java.time.ZonedDateTime; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -@Entity -@Getter -@Table(name = "`user`") -@ToString(onlyExplicitlyIncluded = true) -@NoArgsConstructor -public class User { - - @Id - @ToString.Include - @GeneratedValue(strategy = GenerationType.IDENTITY) - private long id; - - @Version - @ToString.Include - private long version; - - @NonNull - @ToString.Include - @Column(nullable = false, unique = true) - private String uuid = UUID.randomUUID().toString(); - - @NonNull - @ToString.Include - @Column(nullable = false, unique = true) - private String username; - - @Setter - @NonNull - @Column(nullable = false) - private String password; - - @NonNull - @ElementCollection - private Map tokens = new HashMap<>(); - - public User(final @NonNull UserCreate create, final @NonNull String password) { - this.username = create.getUsername(); - this.password = password; - } - -} diff --git a/src/main/java/de/ph87/data/user/UserController.java b/src/main/java/de/ph87/data/user/UserController.java deleted file mode 100644 index 0e5b245..0000000 --- a/src/main/java/de/ph87/data/user/UserController.java +++ /dev/null @@ -1,76 +0,0 @@ -package de.ph87.data.user; - -import jakarta.annotation.Nullable; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletResponse; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.CrossOrigin; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - -import static de.ph87.data.user.PrincipalArgumentResolver.AUTH_TOKEN_COOKIE_NAME; -import static de.ph87.data.user.Helpers.map; - -@CrossOrigin -@RestController -@RequiredArgsConstructor -@RequestMapping("User") -public class UserController { - - private final UserService userService; - - @NonNull - @PostMapping("create") - public UserDto create(@RequestBody final UserCreate create, final HttpServletResponse response) { - try { - final Principal principal = userService.create(create); - response.addCookie(new Cookie(AUTH_TOKEN_COOKIE_NAME, principal.token)); - return principal.user; - } catch (UserDuplicateError | UserInsecurePassword e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); - } - } - - @Nullable - @GetMapping("whoAmI") - public UserDto whoAmI(@PrincipalNotRequired @Nullable final Principal principal) { - return map(principal, Principal::getUser); - } - - @NonNull - @PostMapping("login") - public UserDto login(@PrincipalNotRequired @Nullable final Principal oldPrincipal, @RequestBody final UserLogin login, final HttpServletResponse response) { - if (oldPrincipal != null) { - userService.logout(oldPrincipal); - } - try { - final Principal principal = userService.login(login); - response.addCookie(new Cookie(AUTH_TOKEN_COOKIE_NAME, principal.token)); - return principal.user; - } catch (UserNotFound | UserWrongPassword e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); - } - } - - @GetMapping("logout") - public void logout(final Principal principal) { - userService.logout(principal); - } - - @NonNull - @PostMapping("setPassword") - public UserDto setPassword(@NonNull final Principal principal, @RequestBody final String password) { - try { - return userService.setPassword(principal, password); - } catch (UserInsecurePassword e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); - } - } - -} diff --git a/src/main/java/de/ph87/data/user/UserCreate.java b/src/main/java/de/ph87/data/user/UserCreate.java deleted file mode 100644 index 2be6f04..0000000 --- a/src/main/java/de/ph87/data/user/UserCreate.java +++ /dev/null @@ -1,19 +0,0 @@ -package de.ph87.data.user; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NonNull; -import lombok.ToString; - -@Getter -@ToString -@AllArgsConstructor -public class UserCreate { - - @NonNull - private String username; - - @NonNull - private String password; - -} diff --git a/src/main/java/de/ph87/data/user/UserDto.java b/src/main/java/de/ph87/data/user/UserDto.java deleted file mode 100644 index e6d011a..0000000 --- a/src/main/java/de/ph87/data/user/UserDto.java +++ /dev/null @@ -1,30 +0,0 @@ -package de.ph87.data.user; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Data; -import lombok.NonNull; - -@Data -public class UserDto { - - @NonNull - public final String uuid; - - @NonNull - public final String username; - - public UserDto(@NonNull final User user) { - this.uuid = user.getUuid(); - this.username = user.getUsername(); - } - - @SuppressWarnings("unused") // used in tests - private UserDto( - @JsonProperty("uuid") @NonNull final String uuid, - @JsonProperty("username") @NonNull final String username - ) { - this.uuid = uuid; - this.username = username; - } - -} diff --git a/src/main/java/de/ph87/data/user/UserDuplicateError.java b/src/main/java/de/ph87/data/user/UserDuplicateError.java deleted file mode 100644 index 7e29d9d..0000000 --- a/src/main/java/de/ph87/data/user/UserDuplicateError.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.ph87.data.user; - -public class UserDuplicateError extends Exception { - -} diff --git a/src/main/java/de/ph87/data/user/UserInsecurePassword.java b/src/main/java/de/ph87/data/user/UserInsecurePassword.java deleted file mode 100644 index 04390cb..0000000 --- a/src/main/java/de/ph87/data/user/UserInsecurePassword.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.ph87.data.user; - -public class UserInsecurePassword extends Exception { - -} diff --git a/src/main/java/de/ph87/data/user/UserLogin.java b/src/main/java/de/ph87/data/user/UserLogin.java deleted file mode 100644 index d537df9..0000000 --- a/src/main/java/de/ph87/data/user/UserLogin.java +++ /dev/null @@ -1,20 +0,0 @@ -package de.ph87.data.user; - -import lombok.Data; -import lombok.NonNull; - -@Data -public class UserLogin { - - @NonNull - public final String username; - - @NonNull - public final String password; - - public UserLogin(@NonNull final String username, @NonNull final String password) { - this.username = username; - this.password = password; - } - -} diff --git a/src/main/java/de/ph87/data/user/UserNotFound.java b/src/main/java/de/ph87/data/user/UserNotFound.java deleted file mode 100644 index 1c54274..0000000 --- a/src/main/java/de/ph87/data/user/UserNotFound.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.ph87.data.user; - -public class UserNotFound extends Exception { - -} diff --git a/src/main/java/de/ph87/data/user/UserRepository.java b/src/main/java/de/ph87/data/user/UserRepository.java deleted file mode 100644 index d422bd5..0000000 --- a/src/main/java/de/ph87/data/user/UserRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package de.ph87.data.user; - -import lombok.NonNull; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.ListCrudRepository; - -import java.util.Optional; - -public interface UserRepository extends ListCrudRepository { - - Optional findByUsername(@NonNull String username); - - @Query("select u from User u join u.tokens t where key(t) = :token") - Optional findByToken(@NonNull String token); - -} diff --git a/src/main/java/de/ph87/data/user/UserService.java b/src/main/java/de/ph87/data/user/UserService.java deleted file mode 100644 index b1a2b42..0000000 --- a/src/main/java/de/ph87/data/user/UserService.java +++ /dev/null @@ -1,119 +0,0 @@ -package de.ph87.data.user; - -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Duration; -import java.time.ZonedDateTime; -import java.util.Optional; -import java.util.UUID; - -@Slf4j -@Service -@RequiredArgsConstructor -public class UserService { - - private static final Duration TOKEN_TIMEOUT = Duration.ofDays(3); - - private final UserRepository userRepository; - - private final PasswordEncoder passwordEncoder; - - @NonNull - @Transactional - public Principal create(final @NonNull UserCreate create) throws UserDuplicateError, UserInsecurePassword { - final Optional existing = userRepository.findByUsername(create.getUsername()); - if (existing.isPresent()) { - log.warn("create: Duplicate username: existing={}", existing.get()); - throw new UserDuplicateError(); - } - final User user = userRepository.save(new User(create, encodePassword(create.getPassword()))); - log.info("create: User created: user={}", user); - return createToken(user); - } - - @NonNull - @Transactional - public Principal login(@NonNull final UserLogin login) throws UserNotFound, UserWrongPassword { - final User user = userRepository.findByUsername(login.username).orElse(null); - if (user == null) { - passwordEncoder.matches(login.password, ""); // make a dummy check to fake runtime for bruteforce attacks - log.warn("login: User not found: username=\"{}\"", login.username); - throw new UserNotFound(); - } - if (!passwordEncoder.matches(login.password, user.getPassword())) { - log.warn("login: Wrong password: user={}", user); - throw new UserWrongPassword(); - } - log.info("login: User logged in: user={}", user); - return createToken(user); - } - - @NonNull - private static Principal createToken(@NonNull final User user) { - final String token = UUID.randomUUID().toString(); - user.getTokens().put(token, ZonedDateTime.now()); - return new Principal(token, user); - } - - @NonNull - @Transactional - public UserDto setPassword(@NonNull final Principal principal, @NonNull final String password) throws UserInsecurePassword { - return set(principal, u -> u.setPassword(encodePassword(password))); - } - - @NonNull - private UserDto set(@NonNull final Principal principal, @NonNull final ConsumerWithException modifier) throws E { - final User user = userRepository.findById(principal.id).orElseThrow(); - modifier.accept(user); - return new UserDto(user); - } - - @NonNull - private String encodePassword(@NonNull final String password) throws UserInsecurePassword { - if (password.length() < 12) { - throw new UserInsecurePassword(); - } - return passwordEncoder.encode(password); - } - - @NonNull - @Transactional - public Optional findPrincipalByToken(@NonNull final String token) { - final Optional userOptional = userRepository.findByToken(token); - if (userOptional.isEmpty()) { - log.debug("findByToken: No user found by given token!"); - return Optional.empty(); - } - - final User user = userOptional.get(); - final ZonedDateTime last = user.getTokens().get(token); - if (last == null) { - throw new RuntimeException("Fetched User by token, but token isn't present in users tokens!"); - } - - final ZonedDateTime earliest = ZonedDateTime.now().minus(TOKEN_TIMEOUT); - if (last.isBefore(earliest)) { - user.getTokens().remove(token); - log.debug("findByToken: Token expired: user={}", user); - return Optional.empty(); - } - - log.info("findByToken: Renewed: user={}", user); - user.getTokens().put(token, ZonedDateTime.now()); - return Optional.of(new Principal(token, user)); - } - - @Transactional - public void logout(@NonNull final Principal principal) { - set(principal, user -> { - user.getTokens().remove(principal.token); - log.info("logout: user={}", user); - }); - } - -} diff --git a/src/main/java/de/ph87/data/user/UserWrongPassword.java b/src/main/java/de/ph87/data/user/UserWrongPassword.java deleted file mode 100644 index 2c85829..0000000 --- a/src/main/java/de/ph87/data/user/UserWrongPassword.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.ph87.data.user; - -public class UserWrongPassword extends Exception { - -} diff --git a/src/main/java/de/ph87/data/user/WebConfig.java b/src/main/java/de/ph87/data/user/WebConfig.java deleted file mode 100644 index 2a74a7c..0000000 --- a/src/main/java/de/ph87/data/user/WebConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -package de.ph87.data.user; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class WebConfig implements WebMvcConfigurer { - - private final PrincipalArgumentResolver principalArgumentResolver; - - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(principalArgumentResolver); - } - -} - diff --git a/src/main/java/de/ph87/data/websocket/IWebsocketMessage.java b/src/main/java/de/ph87/data/websocket/IWebsocketMessage.java new file mode 100644 index 0000000..164eb70 --- /dev/null +++ b/src/main/java/de/ph87/data/websocket/IWebsocketMessage.java @@ -0,0 +1,30 @@ +package de.ph87.data.websocket; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.NonNull; + +public interface IWebsocketMessage { + + String DTO_SUFFIX = "Dto"; + + @NonNull + @JsonIgnore + default String getWebsocketTopic() { + return getNameWithoutDtoSuffix(); + } + + @NonNull + default String getNameWithoutDtoSuffix() { + final String name = getClass().getSimpleName(); + if (name.endsWith(DTO_SUFFIX)) { + return name.substring(0, name.length() - DTO_SUFFIX.length()); + } + return name; + } + + @SuppressWarnings("unused") // json + default String get_type_() { + return getNameWithoutDtoSuffix(); + } + +} diff --git a/src/main/java/de/ph87/data/websocket/WebSocketConfig.java b/src/main/java/de/ph87/data/websocket/WebSocketConfig.java new file mode 100644 index 0000000..1e0d317 --- /dev/null +++ b/src/main/java/de/ph87/data/websocket/WebSocketConfig.java @@ -0,0 +1,40 @@ +package de.ph87.data.websocket; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@CrossOrigin +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + public static final String DESTINATION = ""; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/websocket").setAllowedOrigins("*"); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker(DESTINATION).setHeartbeatValue(new long[]{2000, 2000}).setTaskScheduler(heartBeatScheduler()); + } + + @Bean + public ThreadPoolTaskScheduler heartBeatScheduler() { + final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setThreadNamePrefix("wss-heartbeat-"); + scheduler.setPoolSize(1); + scheduler.setRemoveOnCancelPolicy(true); + scheduler.setAwaitTerminationSeconds(5); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + return scheduler; + } + +} diff --git a/src/main/java/de/ph87/data/websocket/WebSocketService.java b/src/main/java/de/ph87/data/websocket/WebSocketService.java new file mode 100644 index 0000000..10f6148 --- /dev/null +++ b/src/main/java/de/ph87/data/websocket/WebSocketService.java @@ -0,0 +1,23 @@ +package de.ph87.data.websocket; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebSocketService { + + private final SimpMessageSendingOperations simpMessageSendingOperations; + + @TransactionalEventListener(IWebsocketMessage.class) + public void send(@NonNull final IWebsocketMessage message) { + log.debug("Websocket: topic={}, payload={}", message.getWebsocketTopic(), message); + simpMessageSendingOperations.convertAndSend(message.getWebsocketTopic(), message); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..6c7646f --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,6 @@ +logging.level.root=WARN +logging.level.de.ph87=INFO +logging.level.calimero=OFF +#- +spring.main.banner-mode=off +spring.jpa.open-in-view=false diff --git a/src/test/java/de/ph87/data/user/UserControllerTest.java b/src/test/java/de/ph87/data/user/UserControllerTest.java deleted file mode 100644 index a652064..0000000 --- a/src/test/java/de/ph87/data/user/UserControllerTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package de.ph87.data.user; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.Cookie; -import lombok.NonNull; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SuppressWarnings("SameParameterValue") -@SpringBootTest -@AutoConfigureMockMvc -public class UserControllerTest { - - private static final String USERNAME = "test-username"; - - private static final String PASSWORD = "test-password"; - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @Test - void crud() throws Exception { - Cookie[] cookies = new Cookie[0]; - anonymous(cookies, "/User/whoAmI"); - cookies = create(cookies); - online(cookies, USERNAME); - anonymous(cookies, "/User/logout"); - anonymous(cookies, "/User/whoAmI"); - cookies = login(cookies, USERNAME, PASSWORD); - online(cookies, USERNAME); - } - - private void anonymous(final Cookie[] cookies, final String path) throws Exception { - final MockHttpServletRequestBuilder request = get(path); - if (cookies.length > 0) { - request.cookie(cookies); - } - final MockHttpServletResponse response = mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse(); - assertEquals("", response.getContentAsString()); - response.getCookies(); - } - - private Cookie[] create(final Cookie[] cookies) throws Exception { - final MockHttpServletRequestBuilder create = post("/User/create"); - if (cookies.length > 0) { - create.cookie(cookies); - } - create.contentType(MediaType.APPLICATION_JSON); - create.content(objectMapper.writeValueAsString(new UserCreate(USERNAME, PASSWORD))); - return assertUser(create, USERNAME); - } - - private Cookie[] login(final Cookie[] cookies, final String username, final String password) throws Exception { - final MockHttpServletRequestBuilder request = post("/User/login"); - if (cookies.length > 0) { - request.cookie(cookies); - } - request.contentType(MediaType.APPLICATION_JSON); - request.content(objectMapper.writeValueAsString(new UserLogin(username, password))); - return assertUser(request, username); - } - - private void online(final Cookie[] cookies, final String username) throws Exception { - final MockHttpServletRequestBuilder request = get("/User/whoAmI"); - if (cookies.length > 0) { - request.cookie(cookies); - } - assertUser(request, username); - } - - @NonNull - private Cookie[] assertUser(final MockHttpServletRequestBuilder request, @NonNull final String username) throws Exception { - final MockHttpServletResponse response = mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse(); - final String payload = response.getContentAsString(); - assertNotNull(payload); - assertNotEquals("", payload); - final UserDto userDto = objectMapper.readValue(payload, UserDto.class); - assertEquals(36, userDto.uuid.length()); - assertEquals(username, userDto.username); - return response.getCookies(); - } - -} \ No newline at end of file