From 409d4c3d5bf8567181dc9c3d15b0bae241b8bea1 Mon Sep 17 00:00:00 2001 From: Paul Griffith Date: Fri, 3 Feb 2023 09:46:49 -0800 Subject: [PATCH 1/2] Extremely simple tag history servlet --- .../imdc/extensions/gateway/GatewayHook.kt | 10 +- .../imdc/extensions/gateway/HistoryServlet.kt | 153 ++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt diff --git a/gateway/src/main/kotlin/org/imdc/extensions/gateway/GatewayHook.kt b/gateway/src/main/kotlin/org/imdc/extensions/gateway/GatewayHook.kt index e4d9b4b..c60a9e2 100644 --- a/gateway/src/main/kotlin/org/imdc/extensions/gateway/GatewayHook.kt +++ b/gateway/src/main/kotlin/org/imdc/extensions/gateway/GatewayHook.kt @@ -27,11 +27,15 @@ class GatewayHook : AbstractGatewayModuleHook() { } PyDatasetBuilder.register() + + context.webResourceManager.addServlet(HistoryServlet.PATH, HistoryServlet::class.java) } - override fun startup(activationState: LicenseState) {} + override fun startup(activationState: LicenseState) = Unit + override fun shutdown() { PyDatasetBuilder.unregister() + context.webResourceManager.removeServlet(HistoryServlet.PATH) } override fun initializeScriptManager(manager: ScriptManager) { @@ -52,6 +56,6 @@ class GatewayHook : AbstractGatewayModuleHook() { } } - override fun isFreeModule(): Boolean = true - override fun isMakerEditionCompatible(): Boolean = true + override fun isFreeModule() = true + override fun isMakerEditionCompatible() = true } diff --git a/gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt b/gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt new file mode 100644 index 0000000..5056397 --- /dev/null +++ b/gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt @@ -0,0 +1,153 @@ +package org.imdc.extensions.gateway + +import com.inductiveautomation.ignition.common.QualifiedPathUtils +import com.inductiveautomation.ignition.common.StreamingDatasetWriter +import com.inductiveautomation.ignition.common.gson.stream.JsonWriter +import com.inductiveautomation.ignition.common.model.values.QualityCode +import com.inductiveautomation.ignition.common.sqltags.history.AggregationMode +import com.inductiveautomation.ignition.common.sqltags.history.BasicTagHistoryQueryParams +import com.inductiveautomation.ignition.common.sqltags.history.ReturnFormat +import com.inductiveautomation.ignition.common.util.LoggerEx +import com.inductiveautomation.ignition.gateway.model.GatewayContext +import org.apache.http.entity.ContentType +import java.io.PrintWriter +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Date +import javax.servlet.http.HttpServlet +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class HistoryServlet : HttpServlet() { + private lateinit var context: GatewayContext + + override fun init() { + context = servletContext.getAttribute(GatewayContext.SERVLET_CONTEXT_KEY) as GatewayContext + } + + override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { + resp.contentType = ContentType.APPLICATION_JSON.toString() + resp.writer.use { writer -> + val historyQuery: BasicTagHistoryQueryParams = + try { + val paths = + req.getParameterValues("path") + ?: throw IllegalArgumentException("Must specify at least one path") + val startDate = + req.getParameter("startDate")?.toDate() ?: Date.from(Instant.now().minus(8, ChronoUnit.HOURS)) + val endDate = req.getParameter("endDate")?.toDate() ?: Date() + val returnSize = req.getParameter("returnSize")?.toInt() ?: -1 + val aggregationMode = + req.getParameter("aggregationMode")?.let(AggregationMode::valueOf) ?: AggregationMode.Average + val aliases = req.getParameter("aliases")?.split(',') + + BasicTagHistoryQueryParams( + paths.map(QualifiedPathUtils::toPathFromHistoricalString), + startDate, + endDate, + returnSize, + aggregationMode, + ReturnFormat.Wide, + aliases, + emptyList(), + ) + } catch (e: Exception) { + resp.status = HttpServletResponse.SC_BAD_REQUEST + e.printStackTrace(PrintWriter(writer)) + return + } + + try { + context.tagHistoryManager.queryHistory( + historyQuery, + StreamingJsonWriter( + JsonWriter(writer), + ), + ) + } catch (e: Exception) { + resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR + logger.error("Unexpected exception writing JSON content to servlet", e) + } + } + } + + class StreamingJsonWriter(private val jsonWriter: JsonWriter) : StreamingDatasetWriter { + private lateinit var names: Array + private lateinit var types: Array> + + private val test = DateTimeFormatter.ISO_INSTANT + + override fun initialize( + columnNames: Array, + columnTypes: Array>, + hasQuality: Boolean, + expectedRows: Int, + ) { + this.names = columnNames + this.types = columnTypes + + jsonWriter.beginArray() + } + + override fun write(data: Array, quality: Array): Unit = jsonWriter.run { + writeObject { + for (index in data.indices) { + name(names[index]) + when (val value = data[index]) { + is Number -> { + when (types[index]) { + Float::class.java, Double::class.java -> value(value.toDouble()) + else -> value(value.toLong()) + } + } + + is Date -> { + value(test.format(value.toInstant())) + } + + is String -> value(value) + is Boolean -> value(value) + + null -> nullValue() + } + } + } + } + + override fun finish() { + jsonWriter.endArray() + } + + override fun finishWithError(exception: java.lang.Exception) = throw exception + + private inline fun JsonWriter.writeObject(block: JsonWriter.() -> Unit) { + beginObject() + block() + endObject() + } + } + + companion object { + const val PATH = "history-extension" + + private val logger = LoggerEx.newBuilder().build(HistoryServlet::class.java) + + private val parsingStrategies = listOf<(String) -> Date>( + SimpleDateFormat.getDateTimeInstance()::parse, + SimpleDateFormat.getInstance()::parse, + { Date(it.toLong()) }, + ) + + private fun String.toDate(): Date? { + return parsingStrategies.firstNotNullOfOrNull { strategy -> + try { + strategy.invoke(this) + } catch (e: Exception) { + null + } + } + } + } +} From b4d08a5e6446626d91acad9ba5e02b5480493052 Mon Sep 17 00:00:00 2001 From: Paul Griffith Date: Fri, 3 Feb 2023 09:52:31 -0800 Subject: [PATCH 2/2] Add trial expiration handling --- .../kotlin/org/imdc/extensions/gateway/HistoryServlet.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt b/gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt index 5056397..725639f 100644 --- a/gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt +++ b/gateway/src/main/kotlin/org/imdc/extensions/gateway/HistoryServlet.kt @@ -66,6 +66,9 @@ class HistoryServlet : HttpServlet() { JsonWriter(writer), ), ) + } catch (e: TrialExpiredException) { + resp.status = HttpServletResponse.SC_PAYMENT_REQUIRED + logger.error("Tag historian module reported trial expired", e) } catch (e: Exception) { resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR logger.error("Unexpected exception writing JSON content to servlet", e) @@ -73,6 +76,8 @@ class HistoryServlet : HttpServlet() { } } + class TrialExpiredException : Exception() + class StreamingJsonWriter(private val jsonWriter: JsonWriter) : StreamingDatasetWriter { private lateinit var names: Array private lateinit var types: Array> @@ -92,6 +97,10 @@ class HistoryServlet : HttpServlet() { } override fun write(data: Array, quality: Array): Unit = jsonWriter.run { + if (quality.any { it.`is`(QualityCode.Bad_TrialExpired) }) { + throw TrialExpiredException() + } + writeObject { for (index in data.indices) { name(names[index])