From 6f8667913b6451fff3bb28399019673492d773b1 Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:47:44 -0400 Subject: [PATCH 01/17] Initial changes for AWS SQS module --- pstatus-report-sink-ktor/build.gradle | 1 + .../ocio/processingstatusapi/Application.kt | 9 +- .../processingstatusapi/plugins/AWSSQS.kt | 118 ++++++++++++++++++ .../plugins/AWSSQSProcessor.kt | 57 +++++++++ .../utils/SchemaValidation.kt | 3 +- .../src/main/resources/application.conf | 9 ++ 6 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index e997e71f..cd1c5d46 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -42,6 +42,7 @@ dependencies { implementation("com.azure:azure-messaging-servicebus:7.13.3") implementation("com.azure:azure-cosmos:4.55.0") implementation("com.rabbitmq:amqp-client:5.21.0") + implementation("aws.sdk.kotlin:sqs:1.0.0") implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") implementation("com.google.code.gson:gson:2.10.1") implementation("io.insert-koin:koin-core:3.5.6") diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt index 29a00fab..3308db69 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt @@ -51,6 +51,11 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp } } + MessageSystem.AWS.toString() -> { + single(createdAtStart = true) { + //AWSQServiceConfiguration(environment.config, configurationPath = "aws") + } + } } } return modules(listOf(cosmosModule , configModule)) @@ -87,7 +92,9 @@ fun Application.module() { rabbitMQModule() } - MessageSystem.AWS -> TODO() + MessageSystem.AWS -> { + awsSQSModule() + } null -> TODO() } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt new file mode 100644 index 00000000..ade0cd5b --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt @@ -0,0 +1,118 @@ +package gov.cdc.ocio.processingstatusapi.plugins + +import aws.sdk.kotlin.runtime.AwsServiceException +import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider +import aws.sdk.kotlin.services.sqs.SqsClient +import aws.sdk.kotlin.services.sqs.model.DeleteMessageRequest +import aws.sdk.kotlin.services.sqs.model.QueueDoesNotExist + +import aws.sdk.kotlin.services.sqs.model.ReceiveMessageRequest +import aws.sdk.kotlin.services.sqs.model.SqsException + +import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation +import io.ktor.server.application.* +import io.ktor.server.application.hooks.* +import io.ktor.server.config.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.apache.qpid.proton.TimeoutException + + +class AWSQServiceConfiguration(config: ApplicationConfig, configurationPath: String? = null) { + private val configPath = if (configurationPath != null) "$configurationPath." else "" + val queueURL: String = config.tryGetString("${configPath}sqs.url") ?: "" + private val accessKeyID = config.tryGetString("${configPath}access_key_id") ?: "" + private val secretAccessKey = config.tryGetString("${configPath}secret_access_key") ?: "" + private val region = config.tryGetString("${configPath}region") ?: "us-east-1" + + fun createSQSClient(): SqsClient{ + + return SqsClient{ credentialsProvider = StaticCredentialsProvider { + accessKeyId = this@AWSQServiceConfiguration.accessKeyID + secretAccessKey = this@AWSQServiceConfiguration.secretAccessKey + }; region = this@AWSQServiceConfiguration.region } + } +} + +val AWSSQSPlugin = createApplicationPlugin( + name = "AWSSQS", + configurationPath = "aws", + createConfiguration = ::AWSQServiceConfiguration +) { + lateinit var sqsClient: SqsClient + lateinit var queueUrl: String + + try { + sqsClient = pluginConfig.createSQSClient() + queueUrl = pluginConfig.queueURL + SchemaValidation.logger.info("Connection to the AWS SQS was successfully established") + } catch (e: SqsException) { + SchemaValidation.logger.error("Failed to create AWS SQS client ${e.message}") + } catch (e: QueueDoesNotExist) { + SchemaValidation.logger.error("AWS SQS URL provided does not exist ${e.message}") + } catch (e: TimeoutException) { + SchemaValidation.logger.error("Timeout occurred ${e.message}") + } catch (e: Exception) { + SchemaValidation.logger.error("Unexpected error occurred ${e.message}") + } + + fun consumeMessages() { + SchemaValidation.logger.info("Consuming messages from AWS SQS") + runBlocking(Dispatchers.Default) { + while (true) { + try { + val receiveMessageRequest = ReceiveMessageRequest { + this.queueUrl = queueUrl + } + val response = sqsClient.receiveMessage(receiveMessageRequest) + response.messages?.forEach { message -> + SchemaValidation.logger.info("Received message from AWS SQS: ${message.body}") + + message.body?.let { AWSSQSProcessor().validateMessage(it) } + val deleteMessageRequest = DeleteMessageRequest { + this.queueUrl = queueUrl + this.receiptHandle = message.receiptHandle + } + sqsClient.deleteMessage(deleteMessageRequest) + + } + } catch (e: Exception) { + SchemaValidation.logger.error("Something went wrong while processing the request ${e.message}") + } catch (e: AwsServiceException) { + SchemaValidation.logger.error("AWS service exception occurred: ${e.message}") + } + } + } + + } + on(MonitoringEvent(ApplicationStarted)) { application -> + application.log.info("Application started successfully.") + consumeMessages() + } + + on(MonitoringEvent(ApplicationStopped)) { application -> + application.log.info("Application stopped successfully.") + cleanupResourcesAndUnsubscribe(application, sqsClient) + } +} + +/** + * We need to clean up the resources and unsubscribe from application life events. + * + * @param application The Ktor instance, provides access to the environment monitor used + * for unsubscribing from events. + * @param sqsClient `sqsClient` used to receive/delete messages from AWS SQS + */ +private fun cleanupResourcesAndUnsubscribe(application: Application, sqsClient: SqsClient) { + application.log.info("Closing SQS client") + sqsClient.close() + application.environment.monitor.unsubscribe(ApplicationStarted) {} + application.environment.monitor.unsubscribe(ApplicationStopped) {} +} + +/** + * The main application module which runs always + */ +fun Application.awsSQSModule() { + install(AWSSQSPlugin) +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt new file mode 100644 index 00000000..b8c07719 --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt @@ -0,0 +1,57 @@ +package gov.cdc.ocio.processingstatusapi.plugins + +import com.google.gson.JsonSyntaxException +import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException +import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException +import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage +import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation +import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.gson +import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.logger + +/** + * The AWS SQS service is additional interface for receiving and validating reports. + */ +class AWSSQSProcessor { + + @Throws(BadRequestException::class) + fun validateMessage(messageAsString: String){ + try { + logger.info { "Received message from AWS SQS: $messageAsString" } + + val message = SchemaValidation().checkAndReplaceDeprecatedFields(messageAsString) + logger.info { "SQS message after checking for depreciated fields $message" } + /** + * If validation is disabled and message is not a valid json, sends it to DLQ. + * Otherwise, proceeds with schema validation. + */ + val isValidationDisabled = System.getenv("DISABLE_VALIDATION")?.toBoolean() ?: false + val isReportValidJson = SchemaValidation().isJsonValid(message) + + if (isValidationDisabled) { + if (!isReportValidJson) { + logger.error { "Message is not in correct JSON format." } + SchemaValidation().sendToDeadLetter("Validation failed.The message is not in JSON format.") + return + } + }else{ + if (isReportValidJson){ + logger.info { "The message is in the correct JSON format. Proceed with schema validation" } + SchemaValidation().validateJsonSchema(message) + }else{ + logger.error { "Validation is enabled, but the message is not in correct JSON format." } + SchemaValidation().sendToDeadLetter("The message is not in correct JSON format.") + return + } + } + logger.info { "The message is valid creating report."} + SchemaValidation().createReport(gson.fromJson(message, CreateReportSBMessage::class.java)) + + }catch (e: BadRequestException) { + logger.error("Failed to validate message received from AWS SQS: ${e.message}") + throw e + } catch (e: JsonSyntaxException) { + logger.error("Failed to parse message received from AWS SQS: ${e.localizedMessage}") + throw BadStateException("Unable to interpret the create report message") + } + } +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt index 2ba59585..96789e9f 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt @@ -312,6 +312,7 @@ class SchemaValidation { createReportMessage.content!!, // it was Content I changed to ContentAsString createReportMessage.jurisdiction, createReportMessage.senderId, + createReportMessage.dataProducerId, createReportMessage.dispositionType, Source.SERVICEBUS ) @@ -353,7 +354,6 @@ class SchemaValidation { if (invalidData.isNotEmpty()) { //This should not run for unit tests if (System.getProperty("isTestEnvironment") != "true") { - // Write the content of the dead-letter reports to CosmosDb ReportManager().createDeadLetterReport( createReportMessage.uploadId, createReportMessage.dataStreamId, @@ -368,6 +368,7 @@ class SchemaValidation { createReportMessage.content, createReportMessage.jurisdiction, createReportMessage.senderId, + createReportMessage.dataProducerId, invalidData, validationSchemaFileNames ) diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index 1be4b69b..d06f3b56 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -28,6 +28,15 @@ azure { container_name = "Reports" } } +aws { + sqs { + url = ${AWS_SQS_URL} + } + access_key_id = ${AWS_ACCESS_KEY_ID} + secret_access_key = ${AWS_SECRET_ACCESS_KEY} + region = ${AWS_REGION} +} + rabbitMQ { host = ${RABBITMQ_HOST} port = ${RABBITMQ_PORT} From 81ad53b3b0ad4668482c2244c9d8ddef0a3f9d48 Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Wed, 4 Sep 2024 14:23:43 -0400 Subject: [PATCH 02/17] Add header comments to AWSSQServiceConfiguration class and consumeMessages() function; begin README.md update with AWS SQS configuration details --- pstatus-report-sink-ktor/README.md | 31 +++++++++++++++---- .../processingstatusapi/plugins/AWSSQS.kt | 31 +++++++++++++------ .../plugins/AWSSQSProcessor.kt | 9 ++++-- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/pstatus-report-sink-ktor/README.md b/pstatus-report-sink-ktor/README.md index b8956125..074e4b5e 100644 --- a/pstatus-report-sink-ktor/README.md +++ b/pstatus-report-sink-ktor/README.md @@ -30,7 +30,12 @@ For RabbitMQ(Local Runs) only, set the following environment variables: - `RABBITMQ_REPORT_QUEUE_NAME` - Your RabbitMQ queue name bound to the desired exchange topic. - `RABBITMQ_VIRTUAL_HOST` - if not provided, default virtual host `/` will be used. -For AWS SNS/SQS only, set the following environment variables: TODO +For AWS SNS/SQS only, set the following environment variables: +- `AWS_SQS_URL` - URL of the Amazon Simple Queue Service(SQS) queue that the Ktor module will interact with to receive, process and delete messages. +- `AWS_ACCESS_KEY_ID` - The Access Key ID for an IAM user with permissions to receive and delete messages from specified SQS queue. +- `AWS_SECRET_ACCESS_KEY` - The secret access key for an IAM user with permissions to receive and delete messages from the specified SQS queue. This key is used for authentication and secure access to the queue. +- `AWS_REGION` (Optional) - The AWS region where your SQS queue is located, if not provided, default region `us-east-1` will be used + # Publish to CDC's ImageHub @@ -45,12 +50,12 @@ gradle jib The location of the deployment will be to the `docker-dev2` repository under the folder `/v2/dex/pstatus`. # Report Delivery Mechanisms -Reports may be provided in one of three ways - either through calls into the Processing Status (PS) API as GraphQL mutations, by way of an Azure Service Bus, or using RabbitMQ. There are pros and cons of each summarized below. +Reports may be provided in one of four ways - either through calls into the Processing Status (PS) API as GraphQL mutations, by way of an Azure Service Bus, AWS SNS/SQS or using RabbitMQ. There are pros and cons of each summarized below. -| Azure Service Bus | GraphQL | RabbitMQ(Local Runs) | -|---------------------|--------------------------|---------------------------------------------| -| Fire and forget [1] | Confirmation of delivery | Fire and forget [1], publisher confirms [2] | -| Fast | Slower | Fast and lightweight | +| Azure Service Bus | AWS SQS | GraphQL | RabbitMQ(Local Runs) | +|---------------------|---------|--------------------------|---------------------------------------------| +| Fire and forget [1] | | Confirmation of delivery | Fire and forget [1], publisher confirms [2] | +| Fast | | Slower | Fast and lightweight | [1] Failed reports are sent to a Report dead-letter that can be queried to find out the reason(s) for its rejection. When using ASB there is no direct feedback mechanism to the report provider of the rejection. [2] Publisher confirms mode can be enabled, which provides asynchronous way to confirm that message has been received. @@ -160,7 +165,21 @@ factory.newConnection().use { connection: com.rabbitmq.client.ConnectionFactory } } ``` +### AWS SNS/SQS +The reports may be sent to PS API AWS SQS queue. +#### How to send reports to AWS SNS/SQS +There are two ways reports can be sent through AWS Console and programmatically. +1. Using AWS Console: + - Navigate to AWS Management Console in your browser. + - Access the SNS Topic Subscription page, where you can view and manage SNS topics and their associated subscriptions. + - Select the desired SNS topic that is configured to send messages to your queue. You can directly `publish messages` to a topic. +2. Sending reports programmatically: +```kotlin +val report = MyDEXReport().apply { + // set the report fields +} +``` # Checking on Reports GraphQL queries are available to look for reports, whether they were accepted by PS API or not. If a report can't be ingested, typically due to failed validations, then it will go to deadletter. The deadletter'd reports can be searched for and the reason(s) for its failure examined. diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt index ade0cd5b..c59558fe 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt @@ -17,8 +17,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.apache.qpid.proton.TimeoutException - -class AWSQServiceConfiguration(config: ApplicationConfig, configurationPath: String? = null) { +/** + * The `AWSSQServiceConfiguration` class configures and initializes connection AWS SQS based on settings provided in an `ApplicationConfig`. + * This class extracts necessary AWS credentials and configuration details, such as the SQS queue URL, access key, secret key, and region, + * using the provided configuration path as a prefix. + * @param config `ApplicationConfig` containing the configuration settings for AWS SQS. + * @param configurationPath represents prefix used to locate environment variables specific to AWS within the configuration. + */ +class AWSSQServiceConfiguration(config: ApplicationConfig, configurationPath: String? = null) { private val configPath = if (configurationPath != null) "$configurationPath." else "" val queueURL: String = config.tryGetString("${configPath}sqs.url") ?: "" private val accessKeyID = config.tryGetString("${configPath}access_key_id") ?: "" @@ -28,16 +34,16 @@ class AWSQServiceConfiguration(config: ApplicationConfig, configurationPath: Str fun createSQSClient(): SqsClient{ return SqsClient{ credentialsProvider = StaticCredentialsProvider { - accessKeyId = this@AWSQServiceConfiguration.accessKeyID - secretAccessKey = this@AWSQServiceConfiguration.secretAccessKey - }; region = this@AWSQServiceConfiguration.region } + accessKeyId = this@AWSSQServiceConfiguration.accessKeyID + secretAccessKey = this@AWSSQServiceConfiguration.secretAccessKey + }; region = this@AWSSQServiceConfiguration.region } } } val AWSSQSPlugin = createApplicationPlugin( name = "AWSSQS", configurationPath = "aws", - createConfiguration = ::AWSQServiceConfiguration + createConfiguration = ::AWSSQServiceConfiguration ) { lateinit var sqsClient: SqsClient lateinit var queueUrl: String @@ -55,10 +61,17 @@ val AWSSQSPlugin = createApplicationPlugin( } catch (e: Exception) { SchemaValidation.logger.error("Unexpected error occurred ${e.message}") } - + /** + * The `consumeMessages` function continuously listens for and processes messages from an AWS SQS queue. + * This function runs in a blocking coroutine, retrieving messages from the queue, validating them using + * `AWSSQSProcessor`, and then deleting the processed messages from the queue. + * + * @throws Exception + * @throws AwsServiceException + */ fun consumeMessages() { SchemaValidation.logger.info("Consuming messages from AWS SQS") - runBlocking(Dispatchers.Default) { + runBlocking(Dispatchers.IO) { while (true) { try { val receiveMessageRequest = ReceiveMessageRequest { @@ -101,7 +114,7 @@ val AWSSQSPlugin = createApplicationPlugin( * * @param application The Ktor instance, provides access to the environment monitor used * for unsubscribing from events. - * @param sqsClient `sqsClient` used to receive/delete messages from AWS SQS + * @param sqsClient `sqsClient` used to receive and then delete messages from AWS SQS */ private fun cleanupResourcesAndUnsubscribe(application: Application, sqsClient: SqsClient) { application.log.info("Closing SQS client") diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt index b8c07719..2251895b 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt @@ -9,10 +9,15 @@ import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.gson import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.logger /** - * The AWS SQS service is additional interface for receiving and validating reports. + * The AWS SQS service is an additional interface for receiving and validating reports. */ class AWSSQSProcessor { - + /** + * Validates a message received from AWS SQS queue + * @param messageAsString String + * @throws BadRequestException + * @throws JsonSyntaxException + */ @Throws(BadRequestException::class) fun validateMessage(messageAsString: String){ try { From 8f9ebab26f90583eaeebbdd3ad3f75bed58b37a1 Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Wed, 4 Sep 2024 17:12:31 -0400 Subject: [PATCH 03/17] rename message class names and update Kdoc comments with missing properties - Renamed ServiceBusMessage.kt to MessageBase.kt for broader applicability across messaging systems. - Updated codebase references to reflect these changes. - Added missing properties to the header comment in CreateReportMessage.kt. --- .../{ServiceBusMessage.kt => MessageBase.kt} | 12 ++++++----- ...ortSBMessage.kt => CreateReportMessage.kt} | 9 +++++--- .../plugins/AWSSQSProcessor.kt | 4 ++-- .../plugins/RabbitMQProcessor.kt | 4 ++-- .../plugins/ServiceBusProcessor.kt | 4 ++-- .../utils/SchemaValidation.kt | 21 +++++++++---------- 6 files changed, 29 insertions(+), 25 deletions(-) rename pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/{ServiceBusMessage.kt => MessageBase.kt} (60%) rename pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/{CreateReportSBMessage.kt => CreateReportMessage.kt} (86%) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ServiceBusMessage.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/MessageBase.kt similarity index 60% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ServiceBusMessage.kt rename to pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/MessageBase.kt index ca11bb34..2561afda 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ServiceBusMessage.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/MessageBase.kt @@ -19,13 +19,15 @@ enum class DispositionType { } /** - * Base class for all service bus messages. Contains all the common required parameters for all service bus messages. - * Note the ServiceBusMessage class must be *open* not *abstract* as it will need to be initially created to determine - * the type. + * Base class for all messages, supporting various messaging systems such as Azure Service Bus, AWS SQS and RabbitMQ. + * This class contains common required parameters for handling messages across these systems. * - * @property dispositionType DispositionType + * Note that the `MessageBase` class must be *open* not *abstract* as it may need to be instantiated to determine + * the type of message at runtime. + * + * @property dispositionType DispositionType The action or state of the message, defaulting to ADD. */ -open class ServiceBusMessage { +open class MessageBase { @SerializedName("disposition_type") // Default is to add diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportSBMessage.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportMessage.kt similarity index 86% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportSBMessage.kt rename to pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportMessage.kt index 1a4ef830..f2cb4437 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportSBMessage.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportMessage.kt @@ -1,7 +1,7 @@ package gov.cdc.ocio.processingstatusapi.models.reports import com.google.gson.annotations.SerializedName -import gov.cdc.ocio.processingstatusapi.models.ServiceBusMessage +import gov.cdc.ocio.processingstatusapi.models.MessageBase import java.util.* @@ -11,15 +11,18 @@ import java.util.* * @property uploadId String? * @property dataStreamId String? * @property dataStreamRoute String? - * @property dataStreamRoute String? + * @property dexIngestDateTime Date? * @property messageMetadata MessageMetadata? * @property StageInfo StageInfo? * @property tags String? * @property data Lost? + * @property jurisdiction String? + * @property senderId String? + * @property dataProducerId String? * @property contentType String? * @property content Any? */ -class CreateReportSBMessage: ServiceBusMessage() { +class CreateReportMessage: MessageBase() { @SerializedName("upload_id") var uploadId: String? = null diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt index 2251895b..d891a797 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt @@ -3,7 +3,7 @@ package gov.cdc.ocio.processingstatusapi.plugins import com.google.gson.JsonSyntaxException import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException -import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage +import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportMessage import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.gson import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.logger @@ -49,7 +49,7 @@ class AWSSQSProcessor { } } logger.info { "The message is valid creating report."} - SchemaValidation().createReport(gson.fromJson(message, CreateReportSBMessage::class.java)) + SchemaValidation().createReport(gson.fromJson(message, CreateReportMessage::class.java)) }catch (e: BadRequestException) { logger.error("Failed to validate message received from AWS SQS: ${e.message}") diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/RabbitMQProcessor.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/RabbitMQProcessor.kt index d9501b5e..6e921fac 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/RabbitMQProcessor.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/RabbitMQProcessor.kt @@ -2,7 +2,7 @@ package gov.cdc.ocio.processingstatusapi.plugins import com.google.gson.JsonSyntaxException import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException -import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage +import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportMessage import gov.cdc.ocio.processingstatusapi.utils.* import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.gson @@ -48,7 +48,7 @@ class RabbitMQProcessor { } } SchemaValidation.logger.info { "The message is valid creating report."} - SchemaValidation().createReport(gson.fromJson(message, CreateReportSBMessage::class.java)) + SchemaValidation().createReport(gson.fromJson(message, CreateReportMessage::class.java)) } catch (e: BadRequestException) { SchemaValidation.logger.error(e) { "Failed to validate rabbitMQ message ${e.message}" } }catch(e: JsonSyntaxException){ diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt index 2b4b1e46..68b29a25 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt @@ -4,7 +4,7 @@ import com.azure.messaging.servicebus.ServiceBusReceivedMessage import com.google.gson.JsonSyntaxException import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException -import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage +import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportMessage import gov.cdc.ocio.processingstatusapi.utils.* import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.gson import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.logger @@ -42,7 +42,7 @@ class ServiceBusProcessor { } else SchemaValidation().validateJsonSchema(sbMessage) logger.info { "The message is valid creating report."} - SchemaValidation().createReport(gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) + SchemaValidation().createReport(gson.fromJson(sbMessage, CreateReportMessage::class.java)) } catch (e: BadRequestException) { logger.error("Failed to validate service bus message ${e.message}") throw e diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt index 96789e9f..20208cba 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt @@ -14,7 +14,7 @@ import com.networknt.schema.SpecVersion import com.networknt.schema.ValidationMessage import gov.cdc.ocio.processingstatusapi.ReportManager import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException -import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage +import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportMessage import gov.cdc.ocio.processingstatusapi.models.reports.MessageMetadata import gov.cdc.ocio.processingstatusapi.models.reports.Source import gov.cdc.ocio.processingstatusapi.models.reports.StageInfo @@ -36,7 +36,6 @@ class SchemaValidation { .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .create() val logger = KotlinLogging.logger {} - lateinit var reason: String } /** @@ -64,9 +63,9 @@ class SchemaValidation { //for backward compatability following schema version will be loaded if report_schema_version is not found val defaultSchemaVersion = "0.0.1" - val createReportMessage: CreateReportSBMessage + val createReportMessage: CreateReportMessage try { - createReportMessage = gson.fromJson(message, CreateReportSBMessage::class.java) + createReportMessage = gson.fromJson(message, CreateReportMessage::class.java) //convert to Json val reportJsonNode = objectMapper.readTree(message) // get schema version, and use appropriate base schema version @@ -176,10 +175,10 @@ class SchemaValidation { * @return CreateReportSBMessage The message that contains details about the report to be processed * The message may come from Azure Service Bus, AWS SQS or RabbitMQ. */ - private fun safeParseMessageAsReport(messageBody: String): CreateReportSBMessage { + private fun safeParseMessageAsReport(messageBody: String): CreateReportMessage { val objectMapper = jacksonObjectMapper() val jsonNode = objectMapper.readTree(messageBody) - val malformedReportSBMessage = CreateReportSBMessage().apply { + val malformedReportMessage = CreateReportMessage().apply { // Attempt to get each element of the json structure if available uploadId = runCatching { jsonNode.get("upload_id") }.getOrNull()?.asText() dataStreamId = runCatching { jsonNode.get("data_stream_id") }.getOrNull()?.asText() @@ -229,7 +228,7 @@ class SchemaValidation { else -> contentAsNode?.asText() }}.getOrNull() } - return malformedReportSBMessage + return malformedReportMessage } /** @@ -247,7 +246,7 @@ class SchemaValidation { * */ private fun validateSchemaContent(schemaFileName: String, jsonNode: JsonNode, schemaFile: File, objectMapper: ObjectMapper, - invalidData: MutableList, validationSchemaFileNames: MutableList, createReportMessage: CreateReportSBMessage + invalidData: MutableList, validationSchemaFileNames: MutableList, createReportMessage: CreateReportMessage ) { logger.info("Schema file base: $schemaFileName") logger.info("schemaFileNames: $validationSchemaFileNames") @@ -290,7 +289,7 @@ class SchemaValidation { * @throws BadRequestException * @throws Exception */ - fun createReport(createReportMessage: CreateReportSBMessage) { + fun createReport(createReportMessage: CreateReportMessage) { try { val uploadId = createReportMessage.uploadId var stageName = createReportMessage.stageInfo?.action @@ -349,7 +348,7 @@ class SchemaValidation { private fun sendToDeadLetter( invalidData: MutableList, validationSchemaFileNames: MutableList, - createReportMessage: CreateReportSBMessage + createReportMessage: CreateReportMessage ) { if (invalidData.isNotEmpty()) { //This should not run for unit tests @@ -390,7 +389,7 @@ class SchemaValidation { reason: String, invalidData: MutableList, validationSchemaFileNames: MutableList, - createReportMessage: CreateReportSBMessage + createReportMessage: CreateReportMessage ) { logger.error(reason) invalidData.add(reason) From ccd27ad32f2cc56b99636947d43bc39a5b8e784e Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Thu, 5 Sep 2024 13:45:21 -0400 Subject: [PATCH 04/17] updates error messages --- .../cdc/ocio/processingstatusapi/utils/SchemaValidation.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt index 20208cba..ddb2cbee 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt @@ -256,9 +256,9 @@ class SchemaValidation { val schemaValidationMessages: Set = schema.validate(jsonNode) if (schemaValidationMessages.isEmpty()) { - logger.info("JSON is valid against the content schema $schema.") + logger.info("The report has been successfully validated against the JSON schema:$schemaFileName.") } else { - val reason ="JSON is invalid against the content schema $schemaFileName." + val reason ="The report could not be validated against the JSON schema: $schemaFileName." schemaValidationMessages.forEach { invalidData.add(it.message) } processError(reason, invalidData,validationSchemaFileNames,createReportMessage) } From 4416363afaa0d3f0db113372aaa4d3ff8b23b4ac Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Thu, 5 Sep 2024 13:47:39 -0400 Subject: [PATCH 05/17] removed while loop and added maxNumberOfMessages to 5 --- .../kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt index c59558fe..65f11287 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt @@ -72,10 +72,10 @@ val AWSSQSPlugin = createApplicationPlugin( fun consumeMessages() { SchemaValidation.logger.info("Consuming messages from AWS SQS") runBlocking(Dispatchers.IO) { - while (true) { try { val receiveMessageRequest = ReceiveMessageRequest { this.queueUrl = queueUrl + maxNumberOfMessages = 5 } val response = sqsClient.receiveMessage(receiveMessageRequest) response.messages?.forEach { message -> @@ -94,7 +94,6 @@ val AWSSQSPlugin = createApplicationPlugin( } catch (e: AwsServiceException) { SchemaValidation.logger.error("AWS service exception occurred: ${e.message}") } - } } } From e6f02012d43ebb06e3111a0cdf3ce684fa934097 Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Thu, 5 Sep 2024 16:26:16 -0400 Subject: [PATCH 06/17] Add Health check for unsupported message system, update azure service bus library log unsupported message system from config and added health check for it Removed com.microsoft.azure:azure-servicebus:3.6.7 and replaced it with com.azure:azure-messaging-servicebus:7.15.0. Updated Healthcheck.kt to use serviceBusException from the com.azure:azure-messaging-servicebus:7.15.0 Added com.sun.activation:javax.activation:1.2.0 to support MimeType checking. Updated some library versions to address vulnerabilities. Added while loop back to AWSSQS.kt --- pstatus-report-sink-ktor/build.gradle | 6 +++--- .../ocio/processingstatusapi/Application.kt | 2 +- .../ocio/processingstatusapi/HealthCheck.kt | 20 ++++++++++++++++++- .../processingstatusapi/plugins/AWSSQS.kt | 2 ++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index cd1c5d46..e111a4e0 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -51,6 +51,7 @@ dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") implementation "com.microsoft.azure.functions:azure-functions-java-library:3.0.0" + implementation 'com.sun.activation:javax.activation:1.2.0' implementation 'com.microsoft.azure:applicationinsights-core:3.4.19' implementation 'com.azure:azure-cosmos:4.55.0' implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2" @@ -62,12 +63,11 @@ dependencies { implementation 'com.google.code.gson:gson:2.10.1' implementation group: 'io.github.microutils', name: 'kotlin-logging-jvm', version: '3.0.5' implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.36' - implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.3.11' + implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.5.7' implementation group: 'ch.qos.logback.contrib', name: 'logback-json-classic', version: '0.1.5' implementation group: 'ch.qos.logback.contrib', name: 'logback-jackson', version: '0.1.5' implementation 'com.azure:azure-messaging-servicebus:7.15.0' - implementation 'com.microsoft.azure:azure-servicebus:3.6.7' implementation 'com.azure:azure-identity:1.8.0' implementation 'org.danilopianini:khttp:1.3.1' @@ -83,7 +83,7 @@ dependencies { agent "io.opentelemetry.javaagent:opentelemetry-javaagent:1.29.0" testImplementation("org.mockito.kotlin:mockito-kotlin:4.0.0") - testImplementation "org.testng:testng:7.4.0" + testImplementation 'org.testng:testng:7.7.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" testImplementation "org.mockito:mockito-inline:3.11.2" testImplementation "io.mockk:mockk:1.13.9" diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt index 3308db69..934b4038 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt @@ -95,7 +95,7 @@ fun Application.module() { MessageSystem.AWS -> { awsSQSModule() } - null -> TODO() + else -> log.error("Invalid message system configuration") } install(Koin) { diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt index 65f18c66..81e6c7b9 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt @@ -1,8 +1,8 @@ package gov.cdc.ocio.processingstatusapi import com.azure.core.exception.ResourceNotFoundException +import com.azure.messaging.servicebus.ServiceBusException import com.azure.messaging.servicebus.administration.ServiceBusAdministrationClientBuilder -import com.microsoft.azure.servicebus.primitives.ServiceBusException import com.rabbitmq.client.Connection import gov.cdc.ocio.processingstatusapi.cosmos.CosmosClientManager import gov.cdc.ocio.processingstatusapi.cosmos.CosmosConfiguration @@ -57,6 +57,14 @@ class HealthCheckRabbitMQ: HealthCheckSystem() { override val service: String = "RabbitMQ" } +/** + * Concrete implementation of the Unsupported message system + * + */ +class HealthCheckUnsupportedMessageSystem: HealthCheckSystem() { + override val service: String = "Messaging System" +} + /** * Run health checks for the service. * @@ -106,6 +114,7 @@ class HealthQueryService: KoinComponent { val cosmosDBHealth = HealthCheckCosmosDb() lateinit var rabbitMQHealth: HealthCheckRabbitMQ lateinit var serviceBusHealth: HealthCheckServiceBus + lateinit var unsupportedMessageSystem: HealthCheckUnsupportedMessageSystem val time = measureTimeMillis { @@ -138,6 +147,12 @@ class HealthQueryService: KoinComponent { logger.error("RabbitMQ is not healthy: ${ex.message}") } } + else -> { + unsupportedMessageSystem = HealthCheckUnsupportedMessageSystem() + unsupportedMessageSystem.status = "DOWN" + unsupportedMessageSystem.healthIssues = "message system $msgType is not supported" + logger.error("Unsupported message system $msgType config.") + } } } return HealthCheck().apply { @@ -151,6 +166,9 @@ class HealthQueryService: KoinComponent { MessageSystem.RABBITMQ.toString() -> { dependencyHealthChecks.add(rabbitMQHealth) } + else ->{ + dependencyHealthChecks.add(unsupportedMessageSystem) + } } } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt index 65f11287..6fb18770 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt @@ -72,6 +72,7 @@ val AWSSQSPlugin = createApplicationPlugin( fun consumeMessages() { SchemaValidation.logger.info("Consuming messages from AWS SQS") runBlocking(Dispatchers.IO) { + while (true) { try { val receiveMessageRequest = ReceiveMessageRequest { this.queueUrl = queueUrl @@ -94,6 +95,7 @@ val AWSSQSPlugin = createApplicationPlugin( } catch (e: AwsServiceException) { SchemaValidation.logger.error("AWS service exception occurred: ${e.message}") } + } } } From cc27438e949a57177bf680f1604cb64a77ca0ae7 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 11 Sep 2024 09:40:34 -0400 Subject: [PATCH 07/17] Promoting the temporal workflow orchestrator to its own microservice --- .../.gitignore | 42 ++++ .../build.gradle.kts | 89 +++++++ .../gradle.properties | 5 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + .../gradlew | 234 ++++++++++++++++++ .../gradlew.bat | 89 +++++++ .../settings.gradle.kts | 5 + .../src/main/kotlin/Application.kt | 29 +++ .../src/main/kotlin/HealthCheck.kt | 110 ++++++++ .../src/main/kotlin/Routes.kt | 92 +++++++ .../kotlin/activity/NotificationActivity.kt | 28 +++ .../activity/NotificationActivityImpl.kt | 52 ++++ .../src/main/kotlin/cache/InMemoryCache.kt | 98 ++++++++ .../main/kotlin/cache/InMemoryCacheService.kt | 35 +++ .../src/main/kotlin/email/EmailDispatcher.kt | 96 +++++++ .../src/main/kotlin/model/ErrorDetail.kt | 12 + .../kotlin/model/NotificationSubscription.kt | 11 + .../src/main/kotlin/model/Subscirption.kt | 113 +++++++++ ...opErrorsNotificationSubscriptionService.kt | 51 ++++ ...ErrorsNotificationUnSubscriptionService.kt | 36 +++ .../DeadLineCheckSubscriptionService.kt | 52 ++++ .../DeadLineCheckUnSubscriptionService.kt | 35 +++ ...adErrorsNotificationSubscriptionService.kt | 54 ++++ ...ErrorsNotificationUnSubscriptionService.kt | 35 +++ .../main/kotlin/temporal/WorkflowEngine.kt | 74 ++++++ ...DataStreamTopErrorsNotificationWorkflow.kt | 22 ++ ...StreamTopErrorsNotificationWorkflowImpl.kt | 96 +++++++ .../kotlin/workflow/NotificationWorkflow.kt | 21 ++ .../workflow/NotificationWorkflowImpl.kt | 69 ++++++ .../UploadErrorsNotificationWorkflow.kt | 22 ++ .../UploadErrorsNotificationWorkflowImpl.kt | 77 ++++++ .../src/main/resources/application.conf | 13 + 33 files changed, 1803 insertions(+) create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/.gitignore create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/build.gradle.kts create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradle.properties create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradle/wrapper/gradle-wrapper.jar create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradle/wrapper/gradle-wrapper.properties create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradlew create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradlew.bat create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/settings.gradle.kts create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Application.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/HealthCheck.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Routes.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivity.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCache.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCacheService.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/email/EmailDispatcher.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/ErrorDetail.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/NotificationSubscription.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/Subscirption.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/temporal/WorkflowEngine.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt create mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/resources/application.conf diff --git a/pstatus-notifications-workflow-orchestrator-ktor/.gitignore b/pstatus-notifications-workflow-orchestrator-ktor/.gitignore new file mode 100644 index 00000000..b63da455 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/build.gradle.kts b/pstatus-notifications-workflow-orchestrator-ktor/build.gradle.kts new file mode 100644 index 00000000..95815fba --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/build.gradle.kts @@ -0,0 +1,89 @@ + + +buildscript { + repositories { + mavenCentral() + } + +} +plugins { + kotlin("jvm") version "1.9.23" + id("com.google.cloud.tools.jib") version "3.3.0" + id ("io.ktor.plugin") version "2.3.11" + id ("maven-publish") + id ("java-library") + id ("org.jetbrains.kotlin.plugin.serialization") version "1.8.20" +} +repositories { + mavenCentral() +} + +group "gov.cdc.ocio" +version "0.0.1" + +dependencies { + implementation("io.temporal:temporal-sdk:1.15.1") + implementation("com.sendgrid:sendgrid-java:4.9.2") + implementation ("io.ktor:ktor-server-core:2.3.2") + implementation ("io.ktor:ktor-server-netty:2.3.2") + implementation ("io.ktor:ktor-server-content-negotiation:2.3.2") + implementation ("io.ktor:ktor-serialization-kotlinx-json:2.3.2") + implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2") + implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.7.2") + implementation ("io.github.microutils:kotlin-logging-jvm:3.0.5") + implementation ("com.google.code.gson:gson:2.10.1") + implementation ("io.github.microutils:kotlin-logging-jvm:3.0.5") + implementation ("org.slf4j:slf4j-api:1.7.36") + implementation ("ch.qos.logback:logback-classic:1.4.12") + implementation ("io.insert-koin:koin-core:3.5.6") + implementation ("io.insert-koin:koin-ktor:3.5.6") + implementation ("com.sun.mail:javax.mail:1.6.2") + implementation ("com.expediagroup:graphql-kotlin-ktor-server:7.1.1") + implementation ("com.graphql-java:graphql-java-extended-scalars:22.0") + implementation ("joda-time:joda-time:2.12.7") + implementation ("org.apache.commons:commons-lang3:3.3.1") + implementation ("com.expediagroup:graphql-kotlin-server:6.0.0") + implementation ("com.expediagroup:graphql-kotlin-schema-generator:6.0.0") + implementation ("io.ktor:ktor-server-netty:2.1.0") + implementation ("io.ktor:ktor-client-content-negotiation:2.1.0") + testImplementation(kotlin("test")) + +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(20) +} +repositories{ + mavenLocal() + mavenCentral() +} + +ktor { + docker { + localImageName.set("pstatus-notifications-workflow-ktor") + } +} + +jib { + from { + auth { + username = System.getenv("DOCKERHUB_USERNAME") ?: "" + password = System.getenv("DOCKERHUB_TOKEN") ?: "" + } + } + to { + image = "imagehub.cdc.gov:6989/dex/pstatus/notifications-workflow-service" + auth { + username = System.getenv("IMAGEHUB_USERNAME") ?: "" + password = System.getenv("IMAGEHUB_PASSWORD") ?: "" + } + } +} + +repositories{ + mavenCentral() +} + diff --git a/pstatus-notifications-workflow-orchestrator-ktor/gradle.properties b/pstatus-notifications-workflow-orchestrator-ktor/gradle.properties new file mode 100644 index 00000000..67793d39 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/gradle.properties @@ -0,0 +1,5 @@ + +ktor_version=2.3.10 +kotlin_version=1.9.24 +logback_version=1.4.14 +kotlin.code.style=official \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/gradle/wrapper/gradle-wrapper.jar b/pstatus-notifications-workflow-orchestrator-ktor/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/pstatus-notifications-workflow-orchestrator-ktor/gradlew.bat b/pstatus-notifications-workflow-orchestrator-ktor/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pstatus-notifications-workflow-orchestrator-ktor/settings.gradle.kts b/pstatus-notifications-workflow-orchestrator-ktor/settings.gradle.kts new file mode 100644 index 00000000..3aff9817 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" +} +rootProject.name = "temporal-workflow-orchestrator-service-poc" + diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Application.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Application.kt new file mode 100644 index 00000000..6c4c7063 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Application.kt @@ -0,0 +1,29 @@ +package gov.cdc.ocio.processingnotifications + +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.routing.* + +fun main(args: Array) { + embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true) +} + +fun Application.module() { + + install(ContentNegotiation) { + jackson() + } + routing { + subscribeDeadlineCheckRoute() + unsubscribeDeadlineCheck() + subscribeUploadErrorsNotification() + unsubscribeUploadErrorsNotification() + subscribeDataStreamTopErrorsNotification() + unsubscribesDataStreamTopErrorsNotification() + healthCheckRoute() + } + +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/HealthCheck.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/HealthCheck.kt new file mode 100644 index 00000000..eb308a44 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/HealthCheck.kt @@ -0,0 +1,110 @@ +package gov.cdc.ocio.processingnotifications + +import io.temporal.api.workflowservice.v1.GetSystemInfoRequest +import io.temporal.serviceclient.WorkflowServiceStubs +import io.temporal.serviceclient.WorkflowServiceStubsOptions +import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import kotlin.system.measureTimeMillis + +/** + * Abstract class used for modeling the health issues of an individual service. + * + * @property status String + * @property healthIssues String? + * @property service String + */ +abstract class HealthCheckSystem { + + var status: String = "DOWN" + var healthIssues: String? = "" + open val service: String = "" +} + +/** + * Concrete implementation of the Temporal Server health check. + * + * @property service String + */ +class HealthCheckTemporalServer: HealthCheckSystem() { + override val service: String = "Temporal Server" +} +/** + * Run health checks for the service. + * + * @property status String? + * @property totalChecksDuration String? + * @property dependencyHealthChecks MutableList + */ +class HealthCheck { + + var status: String = "DOWN" + var totalChecksDuration: String? = null + var dependencyHealthChecks = mutableListOf() +} + +/** + * Service for querying the health of the temporal server and its dependencies. + * + * @property logger KLogger + + */ +class TemporalHealthCheckService: KoinComponent { + private val logger = KotlinLogging.logger {} + private val serviceOptions = WorkflowServiceStubsOptions.newBuilder() + .setTarget(System.getenv().get("")) // Temporal server address + .build() + private val serviceStubs = WorkflowServiceStubs.newServiceStubs(serviceOptions) + + /** + * Returns a HealthCheck object with the overall health of temporal server and its dependencies. + * + * @return HealthCheck + */ + fun getHealth(): HealthCheck { + val temporalHealth = HealthCheckTemporalServer() + val time = measureTimeMillis { + try { + val isDown= serviceStubs.isShutdown || serviceStubs.isTerminated + if(isDown) + { + temporalHealth.status ="DOWN" + temporalHealth.healthIssues= "Temporal Server is down or terminated" + } + else { + // serviceStubs.healthCheck() - issue finding the proper version for grpc-health-check + // Simple call to get the server capabilities to test if it's up + serviceStubs.blockingStub() + .getSystemInfo(GetSystemInfoRequest.getDefaultInstance()).capabilities + temporalHealth.status = "UP" + } + } catch (ex: Exception) { + temporalHealth.status ="DOWN" + temporalHealth.healthIssues= ex.message + logger.error("Temporal Server is not healthy: ${ex.message}") + } + } + + return HealthCheck().apply { + status = temporalHealth.status + totalChecksDuration = formatMillisToHMS(time) + dependencyHealthChecks.add(temporalHealth) + } + } + + /** + * Format the time in milliseconds to 00:00:00.000 format. + * + * @param millis Long + * @return String + */ + private fun formatMillisToHMS(millis: Long): String { + val seconds = millis / 1000 + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + val remainingMillis = millis % 1000 + + return "%02d:%02d:%02d.%03d".format(hours, minutes, remainingSeconds, remainingMillis / 10) + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Routes.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Routes.kt new file mode 100644 index 00000000..9d93677a --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Routes.kt @@ -0,0 +1,92 @@ +@file:Suppress("PLUGIN_IS_NOT_ENABLED") +package gov.cdc.ocio.processingnotifications + +import gov.cdc.ocio.processingnotifications.model.* +import gov.cdc.ocio.processingnotifications.service.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +/** + * Route to subscribe for DeadlineCheck subscription + */ +fun Route.subscribeDeadlineCheckRoute() { + post("/subscribe/deadlineCheck") { + val subscription = call.receive() + val deadlineCheckSubscription = DeadlineCheckSubscription(subscription.dataStreamId, subscription.dataStreamRoute, subscription.jurisdiction, + subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) + val result = DeadLineCheckSubscriptionService().run(deadlineCheckSubscription) + call.respond(result) + + } +} +/** + * Route to unsubscribe for DeadlineCheck subscription + */ +fun Route.unsubscribeDeadlineCheck() { + post("/unsubscribe/deadlineCheck") { + val subscription = call.receive() + val result = DeadLineCheckUnSubscriptionService().run(subscription.subscriptionId) + call.respond(result) + } +} + + +/** + * Route to subscribe for upload errors notification subscription + */ +fun Route.subscribeUploadErrorsNotification() { + post("/subscribe/uploadErrorsNotification") { + val subscription = call.receive() + val uploadErrorsNotificationSubscription = UploadErrorsNotificationSubscription(subscription.dataStreamId, subscription.dataStreamRoute, + subscription.jurisdiction, + subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) + val result = UploadErrorsNotificationSubscriptionService().run(uploadErrorsNotificationSubscription) + call.respond(result) + + } +} +/** + * Route to unsubscribe for upload errors subscription notification + */ +fun Route.unsubscribeUploadErrorsNotification() { + post("/unsubscribe/uploadErrorsNotification") { + val subscription = call.receive() + val result = UploadErrorsNotificationUnSubscriptionService().run(subscription.subscriptionId) + call.respond(result) + } +} +/** + * Route to subscribe for top data stream errors notification subscription + */ +fun Route.subscribeDataStreamTopErrorsNotification() { + post("/subscribe/dataStreamTopErrorsNotification") { + val subscription = call.receive() + val dataStreamTopErrorsNotificationSubscription = DataStreamTopErrorsNotificationSubscription(subscription.dataStreamId, subscription.dataStreamRoute, + subscription.jurisdiction, + subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) + val result = DataStreamTopErrorsNotificationSubscriptionService().run(dataStreamTopErrorsNotificationSubscription) + call.respond(result) + + } +} +/** + * Route to unsubscribe for top data stream errors notification subscription + */ +fun Route.unsubscribesDataStreamTopErrorsNotification() { + post("/unsubscribe/dataStreamTopErrorsNotification") { + val subscription = call.receive() + val result = DataStreamTopErrorsNotificationUnSubscriptionService().run(subscription.subscriptionId) + call.respond(result) + } +} + +/** + Route to subscribe for Temporal Server health check + */ +fun Route.healthCheckRoute() { + get("/health") { + call.respond(TemporalHealthCheckService().getHealth()) + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivity.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivity.kt new file mode 100644 index 00000000..90f13e13 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivity.kt @@ -0,0 +1,28 @@ +package gov.cdc.ocio.processingnotifications.activity + +import io.temporal.activity.ActivityInterface +import io.temporal.activity.ActivityMethod + +/** + * Interface which defines the activity methods + */ +@ActivityInterface +interface NotificationActivities { + @ActivityMethod + fun sendNotification( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + deliveryReference: String + ) + @ActivityMethod + fun sendUploadErrorsNotification( + error:String, + deliveryReference: String + ) + @ActivityMethod + fun sendDataStreamTopErrorsNotification( + error:String, + deliveryReference: String + ) +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt new file mode 100644 index 00000000..2830e2c4 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt @@ -0,0 +1,52 @@ +package gov.cdc.ocio.processingnotifications.activity + +import gov.cdc.ocio.processingnotifications.email.EmailDispatcher +import mu.KotlinLogging + +/** + * Implementation class for sending email notifications for various notifications + */ +class NotificationActivitiesImpl : NotificationActivities { + private val emailService: EmailDispatcher = EmailDispatcher() + private val logger = KotlinLogging.logger {} + + /** + * Send notification method which uses the email service to send email when an upload fails + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param deliveryReference String + */ + override fun sendNotification( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + deliveryReference: String + ) { + val msg ="Upload deadline over. Failed to get the upload for dataStreamId: $dataStreamId, jurisdiction: $jurisdiction.Sending the notification to $deliveryReference " + logger.info(msg) + emailService.sendEmail("TEST EMAIL- UPLOAD DEADLINE CHECK EXPIRED",msg, deliveryReference) + } + /** + * Send notification method which uses the email service to send email when there are errors in the upload file + * @param error String + * @param deliveryReference String + */ + + override fun sendUploadErrorsNotification(error: String, deliveryReference: String) { + val msg ="Errors while upload. $error" + logger.info(msg) + emailService.sendEmail("TEST EMAIL-UPLOAD ERRORS NOTIFICATION",msg, deliveryReference) + } + + /** + * Send notification method which uses the email service to send email with the digest counts of the top errors in an upload + * @param error String + * @param deliveryReference String + */ + + override fun sendDataStreamTopErrorsNotification(error: String, deliveryReference: String) { + logger.info(error) + emailService.sendEmail("TEST EMAIL-DATA STREAM TOP ERRORS NOTIFICATION",error, deliveryReference) + } +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCache.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCache.kt new file mode 100644 index 00000000..40eec547 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCache.kt @@ -0,0 +1,98 @@ +package gov.cdc.ocio.processingnotifications.cache + +import gov.cdc.ocio.processingnotifications.model.* +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.collections.HashMap + +/** + * This class represents InMemoryCache to maintain state of the data at any given point for + * subscription of rules and subscriber for the rules + */ +object InMemoryCache { + private val readWriteLock = ReentrantReadWriteLock() + /* + Cache to store "SubscriptionId -> Subscriber Info (Email or Url and type of subscription)" + subscriberCache = HashMap() + */ + private val subscriberCache = HashMap>() + + + /** + * If Success, this method updates Two Caches for New Subscription: + * a. First Cache with subscription Rule and respective subscriptionId, + * if it doesn't exist,or it returns existing subscription id. + * b. Second cache is subscriber cache where the subscription id is mapped to emailId of subscriber + * or websocket url with the type of subscription + * + + * @return String + */ + fun updateCacheForSubscription(workflowId:String, baseSubscription: BaseSubscription): WorkflowSubscriptionResult { + // val uuid = generateUniqueSubscriptionId() + try { + + updateSubscriberCache(workflowId, + NotificationSubscriptionResponse(subscriptionId = workflowId, subscription = baseSubscription)) + return WorkflowSubscriptionResult(subscriptionId = workflowId, message = "Successfully subscribed for $workflowId", deliveryReference = baseSubscription.deliveryReference) + } + catch (e: Exception){ + return WorkflowSubscriptionResult(subscriptionId = workflowId, message = e.message, deliveryReference = baseSubscription.deliveryReference) + } + + } + + fun updateCacheForUnSubscription(workflowId:String): WorkflowSubscriptionResult { + try { + + unsubscribeSubscriberCache(workflowId) + return WorkflowSubscriptionResult(subscriptionId = workflowId, message = "Successfully unsubscribed Id = $workflowId", deliveryReference = "") + } + catch (e: Exception){ + return WorkflowSubscriptionResult(subscriptionId = workflowId, message = e.message,"") + } + + } + + /** + * This method adds to the subscriber cache the new entry of subscriptionId to the NotificationSubscriber + * + * @param subscriptionId String + + */ + private fun updateSubscriberCache(subscriptionId: String, + notificationSubscriptionResponse: NotificationSubscriptionResponse) { + //logger.debug("Subscriber added in subscriber cache") + readWriteLock.writeLock().lock() + try { + subscriberCache.putIfAbsent(subscriptionId, mutableListOf()) + subscriberCache[subscriptionId]?.add(notificationSubscriptionResponse) + } finally { + readWriteLock.writeLock().unlock() + } + } + + /** + * This method unsubscribes the subscriber from the subscriber cache + * by removing the Map[subscriptionId, NotificationSubscriber] + * entry from cache but keeps the susbscriptionRule in subscription + * cache for any other existing subscriber needs. + * + * @param subscriptionId String + * @return Boolean + */ + private fun unsubscribeSubscriberCache(subscriptionId: String): Boolean { + if (subscriberCache.containsKey(subscriptionId)) { + val subscribers = subscriberCache[subscriptionId]?.filter { it.subscriptionId == subscriptionId }.orEmpty().toMutableList() + + readWriteLock.writeLock().lock() + try { + subscriberCache.remove(subscriptionId, subscribers) + } finally { + readWriteLock.writeLock().unlock() + } + return true + } else { + throw Exception("Subscription doesn't exist") + } + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCacheService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCacheService.kt new file mode 100644 index 00000000..75d972fa --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCacheService.kt @@ -0,0 +1,35 @@ +package gov.cdc.ocio.processingnotifications.cache + + +import gov.cdc.ocio.processingnotifications.model.BaseSubscription +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult + +/** + * This class is a service that interacts with InMemory Cache in order to subscribe/unsubscribe users + */ +class InMemoryCacheService { + + /** + * This method creates a hash of the rule keys (dataStreamId, stageName, dataStreamRoute, statusType) + * to use as a key for SubscriptionRuleCache and creates a new or existing subscription (if exist) + * and creates a new entry in subscriberCache for the user with the susbscriptionRuleKey + * + + */ + fun updateSubscriptionPreferences(workflowId:String, baseSubscription: BaseSubscription): WorkflowSubscriptionResult { + try { + return InMemoryCache.updateCacheForSubscription(workflowId,baseSubscription) + } catch (e: Exception) { + throw e + } + } + + fun updateDeadlineCheckUnSubscriptionPreferences(workflowId:String): WorkflowSubscriptionResult { + try { + return InMemoryCache.updateCacheForUnSubscription(workflowId) + } catch (e: Exception) { + throw e + } + } + +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/email/EmailDispatcher.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/email/EmailDispatcher.kt new file mode 100644 index 00000000..bdba51d7 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/email/EmailDispatcher.kt @@ -0,0 +1,96 @@ +package gov.cdc.ocio.processingnotifications.email + +import mu.KotlinLogging +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.Socket +import javax.mail.internet.MimeMessage +import java.util.* +import javax.mail.Message +import javax.mail.Session +import javax.mail.Transport +import javax.mail.internet.InternetAddress + +/** + * The class which dispatches the email using SMTP + */ +class EmailDispatcher { + private val logger = KotlinLogging.logger {} + + /** + * Method to send email which checks the SMTP status and then invokes sendEmail + * @param subject String + * @param body String + * @param toEmail String + */ + fun sendEmail(subject:String,body:String, toEmail:String) { + try{ + + if(!checkSMTPStatusWithoutCredentials()) return + // TODO : Change this into properties + val toEmalId = toEmail + val props = System.getProperties() + props["mail.smtp.host"] = "smtpgw.cdc.gov" + props["mail.smtp.port"] = 25 + val session = Session.getInstance(props, null) + sendEmail(session, toEmalId, subject,body) + } catch(e: Exception) { + logger.error("Unable to send email ${e.message}") + } + + } + /** + * Method to send email + * @param session Session + * @param toEmail String + * @param subject String + * @param body String + */ + + private fun sendEmail(session: Session?, toEmail: String?, subject: String?, body: String?) { + try { + val msg = MimeMessage(session) + val replyToEmail = "donotreply@cdc.gov" + val replyToName = "DoNOtReply (DEX Team)" + //set message headers + msg.addHeader("Content-type", "text/HTML; charset=UTF-8") + msg.addHeader("format", "flowed") + msg.addHeader("Content-Transfer-Encoding", "8bit") + + //TODO - Change the from and replyTo address after the new licensed account is created + // Get the email addresses from the property + msg.setFrom(InternetAddress(replyToEmail, replyToName)) + msg.replyTo = InternetAddress.parse(replyToEmail, false) + msg.setSubject(subject, "UTF-8") + msg.setText(body, "UTF-8") + msg.sentDate = Date() + msg.setRecipients(Message.RecipientType.TO, InternetAddress.parse(toEmail, false)) + Transport.send(msg) + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Method to check the status of the SMTP server + */ + private fun checkSMTPStatusWithoutCredentials(): Boolean { + // This is to get the status from curl statement to see if server is connected + try { + + val smtpServer = "smtpgw.cdc.gov" + val port = 25 + val socket = Socket(smtpServer, port) + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + // Read the server response + val response = reader.readLine() + println("Server response: $response") + // Close the socket + socket.close() + return response !=null + } catch (e: Exception) { + logger.error("Unable to send email. Error is ${e.message} \n. Stack trace : ${e.printStackTrace()}") + } + return false + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/ErrorDetail.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/ErrorDetail.kt new file mode 100644 index 00000000..0ebd380c --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/ErrorDetail.kt @@ -0,0 +1,12 @@ +package gov.cdc.ocio.processingnotifications.model + +/** + * Error Detail class + * @property description String + * @property count Int + */ +data class ErrorDetail( + val description: String, + val count: Int +) + diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/NotificationSubscription.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/NotificationSubscription.kt new file mode 100644 index 00000000..85e90387 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/NotificationSubscription.kt @@ -0,0 +1,11 @@ +package gov.cdc.ocio.processingnotifications.model +/** + * Notification subscription response class + * @param subscriptionId String + * @param subscription BaseSubscription + */ + +class NotificationSubscriptionResponse(val subscriptionId: String, + val subscription: BaseSubscription) + + diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/Subscirption.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/Subscirption.kt new file mode 100644 index 00000000..4f372f9b --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/Subscirption.kt @@ -0,0 +1,113 @@ +package gov.cdc.ocio.processingnotifications.model + +/** + * Base class for subscription + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ +open class BaseSubscription(open val dataStreamId: String, + open val dataStreamRoute: String, + open val jurisdiction: String, + open val daysToRun: List, + open val timeToRun: String, + open val deliveryReference: String) { +} + +/** + * DeadlineCheckSubscription data class which is serialized back and forth when we need to unsubscribe the workflow by the subscriptionId + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ +data class DeadlineCheckSubscription( + override val dataStreamId: String, + override val dataStreamRoute: String, + override val jurisdiction: String, + override val daysToRun: List, + override val timeToRun: String, + override val deliveryReference: String) + : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) + +/** + * DeadlineCheckUnSubscription data class which is serialized back and forth when we need to unsubscribe the workflow by the subscriptionId + * @param subscriptionId String + */ +data class DeadlineCheckUnSubscription(val subscriptionId:String) + +/** + * The resultant class for subscription of email/webhooks + * @param subscriptionId String + * @param message String + * @param deliveryReference String + */ +data class WorkflowSubscriptionResult( + var subscriptionId: String? = null, + var message: String? = "", + var deliveryReference:String +) + +/** + * Upload errors notification Subscription data class which is serialized back and forth from graphQL to this service + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + * daysToRun:["Mon","Tue","Wed"] + * timeToRun:"45 16 * *" - this should be the format + */ +data class UploadErrorsNotificationSubscription( override val dataStreamId: String, + override val dataStreamRoute: String, + override val jurisdiction: String, + override val daysToRun: List, + override val timeToRun: String, + override val deliveryReference: String) : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) + + +/** + * Upload errors notification unSubscription data class which is serialized back and forth from graphQL to this service + * @param subscriptionId String + */ +data class UploadErrorsNotificationUnSubscription(val subscriptionId:String) + +/** Data stream top errors notification subscription class which is serialized back and forth from graphQL to this service +* @param dataStreamId String +* @param dataStreamRoute String +* @param jurisdiction String +* @param daysToRun List +* @param timeToRun String +* @param deliveryReference String +* daysToRun:["Mon","Tue","Wed"] +* timeToRun:"45 16 * *" - this should be the format + */ +data class DataStreamTopErrorsNotificationSubscription( override val dataStreamId: String, + override val dataStreamRoute: String, + override val jurisdiction: String, + override val daysToRun: List, + override val timeToRun: String, + override val deliveryReference: String) : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) + +/** + * Data stream errors notification unSubscription data class which is serialized back and forth from graphQL to this service + * @param subscriptionId String + */ +data class DataStreamTopErrorsNotificationUnSubscription(val subscriptionId:String) + +/** + * Get Cron expression based on the daysToRun and timeToRun parameters + * @param daysToRun List + * + */ +fun getCronExpression(daysToRun: List, timeToRun: String):String{ + val daysToRunInStr =daysToRun.joinToString(separator = ",") + val cronExpression= "$timeToRun $daysToRunInStr" + return cronExpression +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt new file mode 100644 index 00000000..0e5fb88f --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt @@ -0,0 +1,51 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.DataStreamTopErrorsNotificationSubscription +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import gov.cdc.ocio.processingnotifications.workflow.DataStreamTopErrorsNotificationWorkflowImpl +import gov.cdc.ocio.processingnotifications.workflow.DataStreamTopErrorsNotificationWorkflow +import io.temporal.client.WorkflowClient +import mu.KotlinLogging + +/** + * The main class which sets up and subscribes the workflow execution + * for digest counts and the frequency with which each of the top 5 errors occur + */ + +class DataStreamTopErrorsNotificationSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine:WorkflowEngine = WorkflowEngine() + private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() + + /** + * The main method which gets called from the route which executes and kicks off the + * workflow execution for digest counts and the frequency with which each of the top 5 errors occur + * @param subscription DataStreamTopErrorsNotificationSubscription + */ + fun run(subscription: DataStreamTopErrorsNotificationSubscription): + WorkflowSubscriptionResult { + try { + val dataStreamId = subscription.dataStreamId + val dataStreamRoute = subscription.dataStreamRoute + val jurisdiction = subscription.jurisdiction + val daysToRun = subscription.daysToRun + val timeToRun = subscription.timeToRun + val deliveryReference= subscription.deliveryReference + val taskQueue = "dataStreamTopErrorsNotificationTaskQueue" + + val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, + DataStreamTopErrorsNotificationWorkflowImpl::class.java ,notificationActivitiesImpl, DataStreamTopErrorsNotificationWorkflow::class.java) + + val execution = WorkflowClient.start(workflow::checkDataStreamTopErrorsAndNotify, dataStreamId, dataStreamRoute, jurisdiction,daysToRun, timeToRun, deliveryReference) + return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) + } + catch (e:Exception){ + logger.error("Error occurred while subscribing for digest counts and top errors : ${e.message}") + } + throw Exception("Error occurred while subscribing for the workflow engine for digest counts and top errors") + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt new file mode 100644 index 00000000..95fb3d23 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt @@ -0,0 +1,36 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import mu.KotlinLogging + +/** + * The main class which subscribes the workflow execution + * for digest counts and top errors and its frequency for each upload + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + + */ +class DataStreamTopErrorsNotificationUnSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine: WorkflowEngine = WorkflowEngine() + + /** + * The main function which is used to cancel the workflow based on the workflowID + * @param subscriptionId String + * @return WorkflowSubscriptionResult + */ + fun run(subscriptionId: String): + WorkflowSubscriptionResult { + try { + workflowEngine.cancelWorkflow(subscriptionId) + return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) + } + catch (e:Exception){ + logger.error("Error occurred while unsubscribing and canceling the workflow for digest counts and top errors with workflowId $subscriptionId: ${e.message}") + } + throw Exception("Error occurred while canceling the workflow engine for digest counts and top for workflow Id $subscriptionId") + } +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt new file mode 100644 index 00000000..ec3b9b85 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt @@ -0,0 +1,52 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.DeadlineCheckSubscription +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import gov.cdc.ocio.processingnotifications.workflow.NotificationWorkflow +import gov.cdc.ocio.processingnotifications.workflow.NotificationWorkflowImpl +import io.temporal.client.WorkflowClient +import mu.KotlinLogging + +/** + * The main class which subscribes the workflow execution + * for upload deadline check + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + * @property notificationActivitiesImpl NotificationActivitiesImpl + */ +class DeadLineCheckSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine: WorkflowEngine = WorkflowEngine() + private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() + + /** + * The main method which executes workflow for uploadDeadline check + * @param subscription DeadlineCheckSubscription + * @return WorkflowSubscriptionResult + */ + fun run(subscription: DeadlineCheckSubscription): + WorkflowSubscriptionResult { + try { + val dataStreamId = subscription.dataStreamId + val dataStreamRoute = subscription.dataStreamRoute + val jurisdiction = subscription.jurisdiction + val daysToRun = subscription.daysToRun + val timeToRun = subscription.timeToRun + val deliveryReference= subscription.deliveryReference + val taskQueue = "notificationTaskQueue" + + val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, + NotificationWorkflowImpl::class.java ,notificationActivitiesImpl, NotificationWorkflow::class.java) + val execution = WorkflowClient.start(workflow::checkUploadAndNotify, jurisdiction, dataStreamId, dataStreamRoute, daysToRun, timeToRun, deliveryReference) + return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) + } + catch (e:Exception){ + logger.error("Error occurred while subscribing workflow for upload deadline: ${e.message}") + } + throw Exception("Error occurred while executing workflow engine to subscribe for upload deadline") + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt new file mode 100644 index 00000000..f07bd87f --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt @@ -0,0 +1,35 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import mu.KotlinLogging + +/** + * The main class which unsubscribes the workflow execution + * for upload errors + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + */ +class DeadLineCheckUnSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine: WorkflowEngine = WorkflowEngine() + + /** + * The main method which cancels the workflow based on the workflow Id + * @param subscriptionId String + */ + + fun run(subscriptionId: String): + WorkflowSubscriptionResult { + try { + workflowEngine.cancelWorkflow(subscriptionId) + return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) + } + catch (e:Exception){ + logger.error("Error occurred while unsubscribing and canceling the workflow for upload deadline with workflowId $subscriptionId: ${e.message}") + } + throw Exception("Error occurred while canceling the workflow execution engine for upload deadline check for workflow Id $subscriptionId") + } +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt new file mode 100644 index 00000000..35695ee2 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt @@ -0,0 +1,54 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.UploadErrorsNotificationSubscription +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import gov.cdc.ocio.processingnotifications.workflow.UploadErrorsNotificationWorkflow +import gov.cdc.ocio.processingnotifications.workflow.UploadErrorsNotificationWorkflowImpl + +import io.temporal.client.WorkflowClient +import mu.KotlinLogging + +/** + * The main class which subscribes the workflow execution + * for upload errors + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + * @property notificationActivitiesImpl NotificationActivitiesImpl + */ +class UploadErrorsNotificationSubscriptionService { + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine:WorkflowEngine = WorkflowEngine() + private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() + private val logger = KotlinLogging.logger {} + /** + * The main method which executes workflow engine to check for upload errors and notify + * @param subscription UploadErrorsNotificationSubscription + * @return WorkflowSubscriptionResult + */ + + fun run(subscription: UploadErrorsNotificationSubscription): + WorkflowSubscriptionResult { + try { + val dataStreamId = subscription.dataStreamId + val dataStreamRoute = subscription.dataStreamRoute + val jurisdiction = subscription.jurisdiction + val daysToRun = subscription.daysToRun + val timeToRun = subscription.timeToRun + val deliveryReference= subscription.deliveryReference + val taskQueue = "uploadErrorsNotificationTaskQueue" + + val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, + UploadErrorsNotificationWorkflowImpl::class.java ,notificationActivitiesImpl, UploadErrorsNotificationWorkflow::class.java) + + val execution = WorkflowClient.start(workflow::checkUploadErrorsAndNotify, dataStreamId, dataStreamRoute, jurisdiction,daysToRun, timeToRun, deliveryReference) + return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) + } + catch (e:Exception){ + logger.error("Error occurred while checking for errors in upload: ${e.message}") + } + throw Exception("Error occurred while executing workflow engine to subscribe for errors in upload") + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt new file mode 100644 index 00000000..0eb2b142 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt @@ -0,0 +1,35 @@ +package gov.cdc.ocio.processingnotifications.service + + +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import mu.KotlinLogging + +/** + * The main class which unsubscribes the workflow execution + * for upload errors + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + */ +class UploadErrorsNotificationUnSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine: WorkflowEngine = WorkflowEngine() + + /** + * The main method which cancels a workflow based on the workflow Id + * @param subscriptionId String + */ + fun run(subscriptionId: String): + WorkflowSubscriptionResult { + try { + workflowEngine.cancelWorkflow(subscriptionId) + return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) + } + catch (e:Exception){ + logger.error("Error occurred while checking for upload deadline: ${e.message}") + } + throw Exception("Error occurred while canceling the execution of workflow engine to cancel workflow for workflow Id $subscriptionId") + } +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/temporal/WorkflowEngine.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/temporal/WorkflowEngine.kt new file mode 100644 index 00000000..7bb445eb --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/temporal/WorkflowEngine.kt @@ -0,0 +1,74 @@ +package gov.cdc.ocio.processingnotifications.temporal + +import gov.cdc.ocio.processingnotifications.model.getCronExpression +import io.temporal.client.WorkflowClient +import io.temporal.client.WorkflowOptions +import io.temporal.client.WorkflowStub +import io.temporal.serviceclient.WorkflowServiceStubs +import io.temporal.worker.WorkerFactory +import mu.KotlinLogging + +/** + * Workflow engine class which creates a grpC client instance of the temporal server + * using which it registers the workflow and the activity implementation + * Also,using the workflow options the client creates a new workflow stub + * Note : CRON expression is used to set the schedule + */ +class WorkflowEngine { + private val logger = KotlinLogging.logger {} + + fun setupWorkflow( + taskName:String, + daysToRun:List, timeToRun:String, + workflowImpl: Class, activitiesImpl:T2, workflowImplInterface:Class):T3{ + try { + val service = WorkflowServiceStubs.newLocalServiceStubs() + val client = WorkflowClient.newInstance(service) + val factory = WorkerFactory.newInstance(client) + + val worker = factory.newWorker(taskName) + worker.registerWorkflowImplementationTypes(workflowImpl) + worker.registerActivitiesImplementations(activitiesImpl) + logger.info("Workflow and Activity successfully registered") + factory.start() + + val workflowOptions = WorkflowOptions.newBuilder() + .setTaskQueue(taskName) + .setCronSchedule(getCronExpression(daysToRun,timeToRun)) // Cron schedule: 15 5 * * 1-5 - Every week day at 5:15a + .build() + + val workflow = client.newWorkflowStub( + workflowImplInterface, + workflowOptions + ) + logger.info("Workflow successfully started") + return workflow + } + catch (ex: Exception){ + logger.error("Error while creating workflow: ${ex.message}") + } + throw Exception("WorkflowEngine instantiation failed. Please try again") + } + + /** + * Cancel the workflow based on the workflowId + * @param workflowId String + */ + fun cancelWorkflow(workflowId:String){ + try { + val service = WorkflowServiceStubs.newLocalServiceStubs() + val client = WorkflowClient.newInstance(service) + + // Retrieve the workflow by its ID + val workflow: WorkflowStub = client.newUntypedWorkflowStub(workflowId) + // Cancel the workflow + workflow.cancel() + logger.info("WorkflowID:$workflowId successfully cancelled") + } + catch (ex: Exception){ + logger.error("Error while canceling the workflow : ${ex.message}") + } + throw Exception("Workflow cancellation failed. Please try again") + + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt new file mode 100644 index 00000000..4e023846 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt @@ -0,0 +1,22 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import io.temporal.workflow.WorkflowInterface +import io.temporal.workflow.WorkflowMethod + +/** + * The interface which defines the digest counts and top errors during an upload and its frequency + */ +@WorkflowInterface +interface DataStreamTopErrorsNotificationWorkflow { + + @WorkflowMethod + fun checkDataStreamTopErrorsAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) + +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt new file mode 100644 index 00000000..38f45a20 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt @@ -0,0 +1,96 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivities +import gov.cdc.ocio.processingnotifications.model.ErrorDetail +import io.temporal.activity.ActivityOptions +import io.temporal.common.RetryOptions +import io.temporal.workflow.Workflow +import mu.KotlinLogging +import java.time.Duration + +/** + * The implementation class which determines the digest counts and top errors during an upload and its frequency + * @property activities T + */ +class DataStreamTopErrorsNotificationWorkflowImpl : DataStreamTopErrorsNotificationWorkflow { + private val logger = KotlinLogging.logger {} + private val activities = Workflow.newActivityStub( + NotificationActivities::class.java, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(3) // Set retry options if needed + .build() + ) + .build() + ) + + //TODO : This should come from db in real application + val errorList = listOf( + "DataStreamId missing", + "DataStreamRoute missing", + "Jurisdiction missing", + "DataStreamId missing", + "Jurisdiction missing", + "DataStreamRoute missing", + "DataStreamRoute missing", + "DataStreamId missing", + "DataStreamId missing", + "DataStreamRoute missing", + "DataStreamId missing", + "DataStreamId missing", + ) + + /** + * The function which determines the digest counts and top errors during an upload and its frequency + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ + override fun checkDataStreamTopErrorsAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) { + try { + // Logic to check if the upload occurred*/ + val (totalCount, topErrors) = getTopErrors(errorList) + val errors = topErrors.filter{it.description.isNotEmpty()}.joinToString() + if (topErrors.isNotEmpty()) { + activities.sendDataStreamTopErrorsNotification("There are $totalCount errors \n These are the top errors : \n $errors \n",deliveryReference) + } + } catch (e: Exception) { + logger.error("Error occurred while checking for counts and top errors and frequency in an upload: ${e.message}") + } + } + + /** + * Function which actually does find the counts and the erroneous fields and its frequency + * @param errors List + * @return Pair + */ + + private fun getTopErrors(errors: List): Pair> { + // Group the errors by description and count their occurrences + val errorCounts = errors.groupingBy { it }.eachCount() + + // Convert the grouped data into a list of ErrorDetail objects + val errorDetails = errorCounts.map { (description, count) -> + ErrorDetail(description, count) + } + // Sort the errors by their count in descending order and take the top 5 + val topErrors = errorDetails.sortedByDescending { it.count }.take(5) + + // Return the total count of errors and the top 5 errors + return Pair(errors.size, topErrors) + } + +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt new file mode 100644 index 00000000..19420635 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt @@ -0,0 +1,21 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import io.temporal.workflow.WorkflowInterface +import io.temporal.workflow.WorkflowMethod + +/** + * The interface which define the upload error and notify method + */ +@WorkflowInterface +interface NotificationWorkflow { + @WorkflowMethod + fun checkUploadAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) + +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt new file mode 100644 index 00000000..70cf9faf --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt @@ -0,0 +1,69 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivities +import io.temporal.activity.ActivityOptions +import io.temporal.common.RetryOptions +import io.temporal.workflow.Workflow +import mu.KotlinLogging +import java.time.Duration + +/** + * The implementation class for notifying if an upload has not occurred within a specified time + * @property activities T + */ +class NotificationWorkflowImpl : NotificationWorkflow { + private val logger = KotlinLogging.logger {} + private val activities = Workflow.newActivityStub( + NotificationActivities::class.java, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(3) // Set retry options if needed + .build() + ) + .build() + ) + /** + * The function which gets invoked by the temporal WF engine which checks whether upload has occurred within a specified time or not + * invokes the activity, if there are errors + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ + override fun checkUploadAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) { + + try { + // Logic to check if the upload occurred*/ + val uploadOccurred = checkUpload(dataStreamId, jurisdiction) + if (!uploadOccurred) { + activities.sendNotification(dataStreamId, dataStreamRoute, jurisdiction, deliveryReference) + } + } catch (e: Exception) { + logger.error("Error occurred while checking for upload deadline: ${e.message}") + } + + } + /** + * The actual function which checks for whether the upload has occurred or not within a specified time + * @param dataStreamId String + * @param jurisdiction String + */ + private fun checkUpload(dataStreamId: String, jurisdiction: String): Boolean { + // add check logic here + return false + } + + +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt new file mode 100644 index 00000000..eb0eadff --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt @@ -0,0 +1,22 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import io.temporal.workflow.WorkflowInterface +import io.temporal.workflow.WorkflowMethod + +/** + * Interface that defines the upload errors and notify + */ +@WorkflowInterface +interface UploadErrorsNotificationWorkflow { + + @WorkflowMethod + fun checkUploadErrorsAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) + +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt new file mode 100644 index 00000000..cc2782ce --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt @@ -0,0 +1,77 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivities +import io.temporal.activity.ActivityOptions +import io.temporal.common.RetryOptions +import io.temporal.workflow.Workflow +import mu.KotlinLogging +import java.time.Duration + +/** + * The implementation class for errors on missing fields from a upload + * @property activities T + */ +class UploadErrorsNotificationWorkflowImpl : UploadErrorsNotificationWorkflow { + private val logger = KotlinLogging.logger {} + private val activities = Workflow.newActivityStub( + NotificationActivities::class.java, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(3) // Set retry options if needed + .build() + ) + .build() + ) +/** + * The function which gets invoked by the temporal WF engine and which checks for the errors in the upload and + * invokes the activity, if there are errors + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ + override fun checkUploadErrorsAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) { + try { + // Logic to check if the upload occurred*/ + val error = checkUploadErrors(dataStreamId, dataStreamRoute, jurisdiction) + if (error.isNotEmpty()) { + activities.sendUploadErrorsNotification(error,deliveryReference) + } + } catch (e: Exception) { + logger.error("Error occurred while checking for errors in upload. Errors are : ${e.message}") + } + } + + /** + * Thw actual function which checks for errors in the fields used for upload + * @param dataStreamId String + * @param dataStreamRoute String + * * @param jurisdiction String + */ + + private fun checkUploadErrors(dataStreamId: String, dataStreamRoute: String, jurisdiction: String): String { + var error = "" + if (dataStreamId.isEmpty()) { + error = "DataStreamId is missing from the upload." + } + if (dataStreamRoute.isEmpty()) { + error += "DataStreamRoute is missing from the upload." + } + if (jurisdiction.isEmpty()) { + error += "Jurisdiction is missing from the upload" + } + return error + } +} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/resources/application.conf b/pstatus-notifications-workflow-orchestrator-ktor/src/main/resources/application.conf new file mode 100644 index 00000000..b5464773 --- /dev/null +++ b/pstatus-notifications-workflow-orchestrator-ktor/src/main/resources/application.conf @@ -0,0 +1,13 @@ +ktor { + deployment { + port = 8081 + host = 0.0.0.0 + } + + application { + modules = [gov.cdc.ocio.processingnotifications.ApplicationKt.module] + } + + version = "0.0.1" + } + From c44113a2438a375561052f5ef97500fc2768bbb6 Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Wed, 11 Sep 2024 10:33:28 -0400 Subject: [PATCH 08/17] Update AWS SQS module to use CoroutineScope instead of runBlocking Replaced runBlocking with CoroutineScope in AWS SQS module to ensure non-blocking execution, which was blocking health endpoint to fail for AWS SQS. Refactored HealthCheck.kt by adding private functions to check and update health status for all supported services Added compileHealthChecks() to compile and return health status of all services in a single response. --- .../ocio/processingstatusapi/Application.kt | 2 +- .../ocio/processingstatusapi/HealthCheck.kt | 218 +++++++++++++----- .../processingstatusapi/plugins/AWSSQS.kt | 12 +- 3 files changed, 172 insertions(+), 60 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt index 934b4038..4de90777 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt @@ -53,7 +53,7 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp } MessageSystem.AWS.toString() -> { single(createdAtStart = true) { - //AWSQServiceConfiguration(environment.config, configurationPath = "aws") + AWSSQServiceConfiguration(environment.config, configurationPath = "aws") } } } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt index 81e6c7b9..e365bf76 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt @@ -1,12 +1,16 @@ package gov.cdc.ocio.processingstatusapi +import aws.sdk.kotlin.services.sqs.SqsClient import com.azure.core.exception.ResourceNotFoundException import com.azure.messaging.servicebus.ServiceBusException import com.azure.messaging.servicebus.administration.ServiceBusAdministrationClientBuilder import com.rabbitmq.client.Connection +import gov.cdc.ocio.processingstatusapi.HealthCheck.Companion.STATUS_UNSUPPORTED +import gov.cdc.ocio.processingstatusapi.HealthCheck.Companion.STATUS_UP import gov.cdc.ocio.processingstatusapi.cosmos.CosmosClientManager import gov.cdc.ocio.processingstatusapi.cosmos.CosmosConfiguration import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException +import gov.cdc.ocio.processingstatusapi.plugins.AWSSQServiceConfiguration import gov.cdc.ocio.processingstatusapi.plugins.AzureServiceBusConfiguration import gov.cdc.ocio.processingstatusapi.plugins.RabbitMQServiceConfiguration import mu.KotlinLogging @@ -26,7 +30,7 @@ abstract class HealthCheckSystem { var status: String = "DOWN" - var healthIssues: String? = "" + private var healthIssues: String? = "" open val service: String = "" } @@ -56,13 +60,21 @@ class HealthCheckServiceBus: HealthCheckSystem() { class HealthCheckRabbitMQ: HealthCheckSystem() { override val service: String = "RabbitMQ" } +/** + * Concrete implementation of the AWS SQS health check. + * + * @property service String + */ +class HealthCheckAWSSQS: HealthCheckSystem() { + override val service: String = "AWS SQS" +} + /** * Concrete implementation of the Unsupported message system - * */ class HealthCheckUnsupportedMessageSystem: HealthCheckSystem() { - override val service: String = "Messaging System" + override val service: String = "Messaging Service" } /** @@ -74,7 +86,13 @@ class HealthCheckUnsupportedMessageSystem: HealthCheckSystem() { */ class HealthCheck { - var status: String = "DOWN" + companion object{ + const val STATUS_UP = "UP" + const val STATUS_DOWN = "DOWN" + const val STATUS_UNSUPPORTED = "UNSUPPORTED" + } + + var status: String = STATUS_DOWN var totalChecksDuration: String? = null @@ -88,7 +106,9 @@ class HealthCheck { * @property cosmosConfiguration CosmosConfiguration * @property azureServiceBusConfiguration AzureServiceBusConfiguration * @property rabbitMQServiceConfiguration RabbitMQServiceConfiguration + * @property awsSqsServiceConfiguration AWSSQServiceConfiguration */ + class HealthQueryService: KoinComponent { private val logger = KotlinLogging.logger {} @@ -99,6 +119,8 @@ class HealthQueryService: KoinComponent { private val rabbitMQServiceConfiguration by inject() + private val awsSqsServiceConfiguration by inject() + private val msgType: String by inject() @@ -108,70 +130,34 @@ class HealthQueryService: KoinComponent { * @return HealthCheck */ fun getHealth(): HealthCheck { - var cosmosDBHealthy = false - var serviceBusHealthy = false - var rabbitMQHealthy = false val cosmosDBHealth = HealthCheckCosmosDb() - lateinit var rabbitMQHealth: HealthCheckRabbitMQ - lateinit var serviceBusHealth: HealthCheckServiceBus - lateinit var unsupportedMessageSystem: HealthCheckUnsupportedMessageSystem - + var rabbitMQHealth: HealthCheckRabbitMQ? = null + var serviceBusHealth: HealthCheckServiceBus? = null + var awsSQSHealth:HealthCheckAWSSQS? = null + var unsupportedMessageSystem: HealthCheckUnsupportedMessageSystem? = null val time = measureTimeMillis { - try { - cosmosDBHealthy = isCosmosDBHealthy(config = cosmosConfiguration) - cosmosDBHealth.status = "UP" - } catch (ex: Exception) { - cosmosDBHealth.healthIssues = ex.message - logger.error("CosmosDB is not healthy: ${ex.message}") - } + checkCosmosDBHealth(cosmosDBHealth) // selectively check health of the messaging service based on msgType when (msgType) { MessageSystem.AZURE_SERVICE_BUS.toString() -> { - serviceBusHealth = HealthCheckServiceBus() - try { - serviceBusHealthy = isServiceBusHealthy(config = azureServiceBusConfiguration) - serviceBusHealth.status = "UP" - } catch (ex: Exception) { - serviceBusHealth.healthIssues = ex.message - logger.error("Azure Service Bus is not healthy: ${ex.message}") - } + serviceBusHealth = checkAzureServiceBusHealth() } + MessageSystem.RABBITMQ.toString() -> { - rabbitMQHealth = HealthCheckRabbitMQ() - try { - rabbitMQHealthy = isRabbitMQHealthy(config = rabbitMQServiceConfiguration) - rabbitMQHealth.status = "UP" - } catch (ex: Exception) { - rabbitMQHealth.healthIssues = ex.message - logger.error("RabbitMQ is not healthy: ${ex.message}") - } - } - else -> { - unsupportedMessageSystem = HealthCheckUnsupportedMessageSystem() - unsupportedMessageSystem.status = "DOWN" - unsupportedMessageSystem.healthIssues = "message system $msgType is not supported" - logger.error("Unsupported message system $msgType config.") - } - } - } - return HealthCheck().apply { - status = if (cosmosDBHealthy && (serviceBusHealthy || rabbitMQHealthy)) "UP" else "DOWN" - totalChecksDuration = formatMillisToHMS(time) - dependencyHealthChecks.add(cosmosDBHealth) - when (msgType) { - MessageSystem.AZURE_SERVICE_BUS.toString() -> { - dependencyHealthChecks.add(serviceBusHealth) + rabbitMQHealth = checkRabbitMQHealth() } - MessageSystem.RABBITMQ.toString() -> { - dependencyHealthChecks.add(rabbitMQHealth) + + MessageSystem.AWS.toString() -> { + awsSQSHealth = checkAWSSQSHealth() } - else ->{ - dependencyHealthChecks.add(unsupportedMessageSystem) + + else -> { + unsupportedMessageSystem = checkUnsupportedMessageSystem() } } - } + return compileHealthChecks(cosmosDBHealth, serviceBusHealth, rabbitMQHealth, awsSQSHealth, unsupportedMessageSystem,time) } /** @@ -187,6 +173,111 @@ class HealthQueryService: KoinComponent { true } + /** + * Checks and sets cosmosDBHealth status + * + * @param cosmosDBHealth The health check object for CosmosDB to update the status + */ + private fun checkCosmosDBHealth(cosmosDBHealth: HealthCheckCosmosDb){ + try { + if (isCosmosDBHealthy(cosmosConfiguration)) { + cosmosDBHealth.status = STATUS_UP + } + }catch (ex: Exception){ + logger.error("Cosmos DB is not healthy $ex.message") + } + } + + /** + * Checks and sets azureServiceBusHealth status + * + * @return HealthCheckServiceBus object with updated status + */ + private fun checkAzureServiceBusHealth():HealthCheckServiceBus{ + val serviceBusHealth = HealthCheckServiceBus() + try { + if (isServiceBusHealthy(azureServiceBusConfiguration)) { + serviceBusHealth.status = STATUS_UP + } + }catch (ex: Exception){ + logger.error("Azure Service Bus is not healthy $ex.message") + } + return serviceBusHealth + } + + /** + * Checks and sets rabbitMQHealth status + * + * @return HealthCheckRabbitMQ object with updated status + */ + private fun checkRabbitMQHealth():HealthCheckRabbitMQ { + val rabbitMQHealth = HealthCheckRabbitMQ() + try { + if (isRabbitMQHealthy(rabbitMQServiceConfiguration)) { + rabbitMQHealth.status = STATUS_UP + } + }catch (ex: Exception){ + logger.error("RabbitMQ is not healthy $ex.message") + } + return rabbitMQHealth + } + + /** + * Checks and sets AWSSQSHealth status + * + * @return HealthCheckAWSSQS object with updated status + */ + private fun checkAWSSQSHealth(): HealthCheckAWSSQS { + val awSSQSHealth = HealthCheckAWSSQS() + try { + if (isAWSSQSHealthy(awsSqsServiceConfiguration)) { + awSSQSHealth.status = STATUS_UP + } + + }catch (ex: Exception){ + logger.error("AWS SQS is not healthy $ex.message") + } + return awSSQSHealth + } + + /** + * Creates a `HealthCheckUnsupportedMessageSystem` object indicating that the provided message system is unsupported. + * This function is used to handle cases where unsupported `MSG_SYSTEM` is configured + * + * @return HealthCheckUnsupportedMessageSystem object with status and service for unsupported message service + */ + private fun checkUnsupportedMessageSystem(): HealthCheckUnsupportedMessageSystem { + val unsupportedMessageSystem = HealthCheckUnsupportedMessageSystem() + unsupportedMessageSystem.status = STATUS_UNSUPPORTED + return unsupportedMessageSystem + } + + /** + * Compiles health checks for supported services + * @param cosmosDBHealth Health check status for Cosmos DB + * @param azureServiceBusHealth Health check status for Azure Service Bus Health + * @param rabbitMQHealth Health check status for RabbitMQ health + * @param unsupportedMessageSystem Health check status for unsupported message system + * @param totalTime Total duration in milliseconds it took to retrieve health check statuses + * @return HealthCheck compiled Health check object with aggregated health check results + */ + private fun compileHealthChecks(cosmosDBHealth: HealthCheckCosmosDb, + azureServiceBusHealth: HealthCheckServiceBus?, + rabbitMQHealth: HealthCheckRabbitMQ?, + awsSQSHealth: HealthCheckAWSSQS?, + unsupportedMessageSystem: HealthCheckUnsupportedMessageSystem?, + totalTime:Long):HealthCheck{ + return HealthCheck().apply { + status = if (cosmosDBHealth.status == STATUS_UP && (azureServiceBusHealth?.status == STATUS_UP || rabbitMQHealth?.status == STATUS_UP || awsSQSHealth?.status == STATUS_UP)) STATUS_UP else HealthCheck.STATUS_DOWN + totalChecksDuration = formatMillisToHMS(totalTime) + dependencyHealthChecks.add(cosmosDBHealth) + azureServiceBusHealth?.let { dependencyHealthChecks.add(it) } + rabbitMQHealth?.let { dependencyHealthChecks.add(it) } + awsSQSHealth?.let { dependencyHealthChecks.add(it) } + unsupportedMessageSystem?.let { dependencyHealthChecks.add(it) } + } + } + /** * Check whether service bus is healthy. * @@ -223,6 +314,23 @@ class HealthQueryService: KoinComponent { } } + /** + * Check whether AWS SQS is healthy. + * + * @return Boolean + */ + @Throws(BadStateException::class) + private fun isAWSSQSHealthy(config: AWSSQServiceConfiguration): Boolean { + val sqsClient: SqsClient? + return try { + sqsClient = config.createSQSClient() + sqsClient.close() + true + }catch (e: Exception){ + throw Exception("Failed to establish connection to AWS SQS service.") + } + } + /** * Format the time in milliseconds to 00:00:00.000 format. * diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt index 6fb18770..e3ef7a4c 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt @@ -13,7 +13,9 @@ import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation import io.ktor.server.application.* import io.ktor.server.application.hooks.* import io.ktor.server.config.* +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.apache.qpid.proton.TimeoutException @@ -41,7 +43,7 @@ class AWSSQServiceConfiguration(config: ApplicationConfig, configurationPath: St } val AWSSQSPlugin = createApplicationPlugin( - name = "AWSSQS", + name = "AWS SQS", configurationPath = "aws", createConfiguration = ::AWSSQServiceConfiguration ) { @@ -71,7 +73,7 @@ val AWSSQSPlugin = createApplicationPlugin( */ fun consumeMessages() { SchemaValidation.logger.info("Consuming messages from AWS SQS") - runBlocking(Dispatchers.IO) { + CoroutineScope(Dispatchers.IO).launch { while (true) { try { val receiveMessageRequest = ReceiveMessageRequest { @@ -88,16 +90,18 @@ val AWSSQSPlugin = createApplicationPlugin( this.receiptHandle = message.receiptHandle } sqsClient.deleteMessage(deleteMessageRequest) + SchemaValidation.logger.info("Deleted message from AWS SQS: ${message.body}") } - } catch (e: Exception) { - SchemaValidation.logger.error("Something went wrong while processing the request ${e.message}") } catch (e: AwsServiceException) { + SchemaValidation.logger.error("Something went wrong while processing the request ${e.message}") + } catch (e: Exception) { SchemaValidation.logger.error("AWS service exception occurred: ${e.message}") } } } + } on(MonitoringEvent(ApplicationStarted)) { application -> application.log.info("Application started successfully.") From a526ddd1287f7c57a87a5549877f9f7bbd897bca Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:59:01 -0400 Subject: [PATCH 09/17] Add source:AWS, RabbitMQ or Azure Service Bus to stage and dead letter reports --- .../ocio/processingstatusapi/ReportManager.kt | 101 ++++++++++-------- .../ocio/processingstatusapi/models/Report.kt | 4 + .../models/reports/CreateReportMessage.kt | 3 + .../models/reports/NotificationReport.kt | 4 +- .../processingstatusapi/plugins/AWSSQS.kt | 2 +- .../plugins/AWSSQSProcessor.kt | 5 +- .../plugins/RabbitMQProcessor.kt | 5 +- .../plugins/ServiceBusProcessor.kt | 5 +- .../utils/SchemaValidation.kt | 14 ++- 9 files changed, 83 insertions(+), 60 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/ReportManager.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/ReportManager.kt index 9d88deb1..c108d125 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/ReportManager.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/ReportManager.kt @@ -79,7 +79,7 @@ class ReportManager: KoinComponent { senderId:String?, dataProducerId: String?, dispositionType: DispositionType, - source: Source + source: Source? ): String { if (System.getProperty("isTestEnvironment") != "true") { return createReport( @@ -124,21 +124,23 @@ class ReportManager: KoinComponent { * @param source Source * @return String - report identifier */ - private fun createReport(uploadId: String, - dataStreamId: String, - dataStreamRoute: String, - dexIngestDateTime: Date, - messageMetadata: MessageMetadata?, - stageInfo: StageInfo?, - tags: Map?, - data:Map?, - contentType: String, - content: Any?, - jurisdiction: String?, - senderId:String?, - dataProducerId: String?, - dispositionType: DispositionType, - source: Source): String { + private fun createReport( + uploadId: String, + dataStreamId: String, + dataStreamRoute: String, + dexIngestDateTime: Date, + messageMetadata: MessageMetadata?, + stageInfo: StageInfo?, + tags: Map?, + data:Map?, + contentType: String, + content: Any?, + jurisdiction: String?, + senderId:String?, + dataProducerId: String?, + dispositionType: DispositionType, + source: Source? + ): String { when (dispositionType) { DispositionType.REPLACE -> { @@ -225,20 +227,22 @@ class ReportManager: KoinComponent { * @throws BadStateException */ @Throws(BadStateException::class) - private fun createStageReport(uploadId: String, - dataStreamId: String, - dataStreamRoute: String, - dexIngestDateTime: Date, - messageMetadata: MessageMetadata?, - stageInfo: StageInfo?, - tags: Map?, - data:Map?, - contentType: String, - content: Any?, - jurisdiction: String?, - senderId:String?, - dataProducerId: String?, - source: Source): String { + private fun createStageReport( + uploadId: String, + dataStreamId: String, + dataStreamRoute: String, + dexIngestDateTime: Date, + messageMetadata: MessageMetadata?, + stageInfo: StageInfo?, + tags: Map?, + data:Map?, + contentType: String, + content: Any?, + jurisdiction: String?, + senderId:String?, + dataProducerId: String?, + source: Source? + ): String { val stageReportId = UUID.randomUUID().toString() val stageReport = Report().apply { this.id = stageReportId @@ -254,6 +258,7 @@ class ReportManager: KoinComponent { this.jurisdiction = jurisdiction this.senderId = senderId this.dataProducerId= dataProducerId + this.source = source this.contentType = contentType if (contentType.lowercase() == "json") { @@ -286,22 +291,24 @@ class ReportManager: KoinComponent { */ @Throws(BadStateException::class) - fun createDeadLetterReport(uploadId: String?, - dataStreamId: String?, - dataStreamRoute: String?, - dexIngestDateTime: Date?, - messageMetadata: MessageMetadata?, - stageInfo: StageInfo?, - tags: Map?, - data:Map?, - dispositionType: DispositionType, - contentType: String?, - content: Any?, - jurisdiction: String?, - senderId:String?, - dataProducerId: String?, - deadLetterReasons: List, - validationSchemaFileNames:List + fun createDeadLetterReport( + uploadId: String?, + dataStreamId: String?, + dataStreamRoute: String?, + dexIngestDateTime: Date?, + messageMetadata: MessageMetadata?, + stageInfo: StageInfo?, + tags: Map?, + data:Map?, + dispositionType: DispositionType, + contentType: String?, + content: Any?, + jurisdiction: String?, + senderId:String?, + dataProducerId: String?, + source: Source?, + deadLetterReasons: List, + validationSchemaFileNames:List ): String { val deadLetterReportId = UUID.randomUUID().toString() @@ -319,6 +326,7 @@ class ReportManager: KoinComponent { this.jurisdiction= jurisdiction this.senderId= senderId this.dataProducerId= dataProducerId + this.source = source this.dispositionType= dispositionType.toString() this.contentType = contentType this.deadLetterReasons= deadLetterReasons @@ -475,6 +483,5 @@ class ReportManager: KoinComponent { companion object { const val DEFAULT_RETRY_INTERVAL_MILLIS = 500L const val MAX_RETRY_ATTEMPTS = 100 - val reportMgrConfig = ReportManagerConfig() } } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt index e149dd30..e13ae92b 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt @@ -2,6 +2,7 @@ package gov.cdc.ocio.processingstatusapi.models import com.google.gson.annotations.SerializedName import gov.cdc.ocio.processingstatusapi.models.reports.MessageMetadata +import gov.cdc.ocio.processingstatusapi.models.reports.Source import gov.cdc.ocio.processingstatusapi.models.reports.StageInfo import java.util.* @@ -70,6 +71,9 @@ open class Report( @SerializedName("data_producer_id") var dataProducerId: String? = null, + @SerializedName("source") + var source: Source? = null, + var content: Any? = null, val timestamp: Date = Date() diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportMessage.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportMessage.kt index f2cb4437..13e68c02 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportMessage.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/CreateReportMessage.kt @@ -57,6 +57,9 @@ class CreateReportMessage: MessageBase() { @SerializedName("data_producer_id") var dataProducerId: String? = null + @SerializedName("source") + var source: Source? = null + @SerializedName("content_type") var contentType: String? = null diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/NotificationReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/NotificationReport.kt index ba0b0113..47ea0293 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/NotificationReport.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/NotificationReport.kt @@ -38,6 +38,8 @@ data class NotificationReport( enum class Source { HTTP, - SERVICEBUS + SERVICEBUS, + AWS, + RABBITMQ } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt index e3ef7a4c..28e8d6d1 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt @@ -90,7 +90,7 @@ val AWSSQSPlugin = createApplicationPlugin( this.receiptHandle = message.receiptHandle } sqsClient.deleteMessage(deleteMessageRequest) - SchemaValidation.logger.info("Deleted message from AWS SQS: ${message.body}") + SchemaValidation.logger.info("Successfully Deleted processed message from AWS SQS") } } catch (e: AwsServiceException) { diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt index d891a797..aa95f3f6 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQSProcessor.kt @@ -4,6 +4,7 @@ import com.google.gson.JsonSyntaxException import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportMessage +import gov.cdc.ocio.processingstatusapi.models.reports.Source import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.gson import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.logger @@ -41,7 +42,7 @@ class AWSSQSProcessor { }else{ if (isReportValidJson){ logger.info { "The message is in the correct JSON format. Proceed with schema validation" } - SchemaValidation().validateJsonSchema(message) + SchemaValidation().validateJsonSchema(message, Source.AWS) }else{ logger.error { "Validation is enabled, but the message is not in correct JSON format." } SchemaValidation().sendToDeadLetter("The message is not in correct JSON format.") @@ -49,7 +50,7 @@ class AWSSQSProcessor { } } logger.info { "The message is valid creating report."} - SchemaValidation().createReport(gson.fromJson(message, CreateReportMessage::class.java)) + SchemaValidation().createReport(gson.fromJson(message, CreateReportMessage::class.java), Source.AWS) }catch (e: BadRequestException) { logger.error("Failed to validate message received from AWS SQS: ${e.message}") diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/RabbitMQProcessor.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/RabbitMQProcessor.kt index 6e921fac..945fb158 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/RabbitMQProcessor.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/RabbitMQProcessor.kt @@ -3,6 +3,7 @@ package gov.cdc.ocio.processingstatusapi.plugins import com.google.gson.JsonSyntaxException import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportMessage +import gov.cdc.ocio.processingstatusapi.models.reports.Source import gov.cdc.ocio.processingstatusapi.utils.* import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.gson @@ -40,7 +41,7 @@ class RabbitMQProcessor { }else{ if (isReportValidJson){ SchemaValidation.logger.info { "The message is in the correct JSON format. Proceed with schema validation" } - SchemaValidation().validateJsonSchema(message) + SchemaValidation().validateJsonSchema(message, Source.RABBITMQ) }else{ SchemaValidation.logger.error { "Validation is enabled, but the message is not in correct JSON format." } SchemaValidation().sendToDeadLetter("The message is not in correct JSON format.") @@ -48,7 +49,7 @@ class RabbitMQProcessor { } } SchemaValidation.logger.info { "The message is valid creating report."} - SchemaValidation().createReport(gson.fromJson(message, CreateReportMessage::class.java)) + SchemaValidation().createReport(gson.fromJson(message, CreateReportMessage::class.java), Source.RABBITMQ) } catch (e: BadRequestException) { SchemaValidation.logger.error(e) { "Failed to validate rabbitMQ message ${e.message}" } }catch(e: JsonSyntaxException){ diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt index 68b29a25..4474df27 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt @@ -5,6 +5,7 @@ import com.google.gson.JsonSyntaxException import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportMessage +import gov.cdc.ocio.processingstatusapi.models.reports.Source import gov.cdc.ocio.processingstatusapi.utils.* import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.gson import gov.cdc.ocio.processingstatusapi.utils.SchemaValidation.Companion.logger @@ -40,9 +41,9 @@ class ServiceBusProcessor { SchemaValidation().sendToDeadLetter("Validation failed. The message is not in JSON format.") return } else - SchemaValidation().validateJsonSchema(sbMessage) + SchemaValidation().validateJsonSchema(sbMessage, Source.SERVICEBUS) logger.info { "The message is valid creating report."} - SchemaValidation().createReport(gson.fromJson(sbMessage, CreateReportMessage::class.java)) + SchemaValidation().createReport(gson.fromJson(sbMessage, CreateReportMessage::class.java), Source.SERVICEBUS) } catch (e: BadRequestException) { logger.error("Failed to validate service bus message ${e.message}") throw e diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt index af1d00dd..86fd9204 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/utils/SchemaValidation.kt @@ -55,7 +55,7 @@ class SchemaValidation { * @param message ReceivedMessage(from Azure Service Bus, RabbitMQ Queue or AWS SNS/SQS) * @throws BadRequestException */ - fun validateJsonSchema(message: String) { + fun validateJsonSchema(message: String, source: Source) { val invalidData = mutableListOf() val schemaFileNames = mutableListOf() val objectMapper: ObjectMapper = jacksonObjectMapper() @@ -66,6 +66,7 @@ class SchemaValidation { val createReportMessage: CreateReportMessage try { createReportMessage = gson.fromJson(message, CreateReportMessage::class.java) + createReportMessage.source = source //convert to Json val reportJsonNode = objectMapper.readTree(message) // get schema version, and use appropriate base schema version @@ -289,14 +290,16 @@ class SchemaValidation { * @throws BadRequestException * @throws Exception */ - fun createReport(createReportMessage: CreateReportMessage) { + fun createReport(createReportMessage: CreateReportMessage, source:Source) { try { val uploadId = createReportMessage.uploadId var stageName = createReportMessage.stageInfo?.action + //set report source: AWS, RabbitMQ or Azure Service Bus + createReportMessage.source = source if (stageName.isNullOrEmpty()) { stageName = "" } - logger.info("Creating report for uploadId = $uploadId with stageName = $stageName") + logger.info("Creating report for uploadId = $uploadId with stageName = $stageName and source = $source") ReportManager().createReportWithUploadId( uploadId!!, @@ -313,12 +316,12 @@ class SchemaValidation { createReportMessage.senderId, createReportMessage.dataProducerId, createReportMessage.dispositionType, - Source.SERVICEBUS + createReportMessage.source ) } catch (e: BadRequestException) { logger.error("createReport - bad request exception: ${e.message}") } catch (e: Exception) { - logger.error("createReport - Failed to process message:${e}") + logger.error("createReport - Failed to process message:${e.message}") } } /** @@ -368,6 +371,7 @@ class SchemaValidation { createReportMessage.jurisdiction, createReportMessage.senderId, createReportMessage.dataProducerId, + createReportMessage.source, invalidData, validationSchemaFileNames ) From fc7ad918b89a3aaea80eba4a7fd38286525c41de Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:26:18 -0400 Subject: [PATCH 10/17] remove unused import --- .../kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt index 28e8d6d1..3033bc5a 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt @@ -16,7 +16,6 @@ import io.ktor.server.config.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.apache.qpid.proton.TimeoutException /** From ccff4ee99c93bc759218332a64a44fc16e606a9c Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:43:58 -0400 Subject: [PATCH 11/17] more updates to the Readme.md --- pstatus-report-sink-ktor/README.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/pstatus-report-sink-ktor/README.md b/pstatus-report-sink-ktor/README.md index 074e4b5e..0daba23e 100644 --- a/pstatus-report-sink-ktor/README.md +++ b/pstatus-report-sink-ktor/README.md @@ -36,8 +36,6 @@ For AWS SNS/SQS only, set the following environment variables: - `AWS_SECRET_ACCESS_KEY` - The secret access key for an IAM user with permissions to receive and delete messages from the specified SQS queue. This key is used for authentication and secure access to the queue. - `AWS_REGION` (Optional) - The AWS region where your SQS queue is located, if not provided, default region `us-east-1` will be used - - # Publish to CDC's ImageHub With one gradle command you can build and publish the project's Docker container image to the external container registry, imagehub, which is a nexus repository. @@ -52,12 +50,12 @@ The location of the deployment will be to the `docker-dev2` repository under the # Report Delivery Mechanisms Reports may be provided in one of four ways - either through calls into the Processing Status (PS) API as GraphQL mutations, by way of an Azure Service Bus, AWS SNS/SQS or using RabbitMQ. There are pros and cons of each summarized below. -| Azure Service Bus | AWS SQS | GraphQL | RabbitMQ(Local Runs) | -|---------------------|---------|--------------------------|---------------------------------------------| -| Fire and forget [1] | | Confirmation of delivery | Fire and forget [1], publisher confirms [2] | -| Fast | | Slower | Fast and lightweight | +| Azure Service Bus | AWS SQS | GraphQL | RabbitMQ(Local Runs) | +|---------------------|--------------------|--------------------------|---------------------------------------------| +| Fire and forget [1] | Fire and forget[1] | Confirmation of delivery | Fire and forget [1], publisher confirms [2] | +| Fast | Fast | Slower | Fast and lightweight | -[1] Failed reports are sent to a Report dead-letter that can be queried to find out the reason(s) for its rejection. When using ASB there is no direct feedback mechanism to the report provider of the rejection. +[1] Failed reports are sent to a Report dead-letter that can be queried to find out the reason(s) for its rejection. When using Azure Service Bus or AWS SNS/SQS there is no direct feedback mechanism to the report provider of the rejection. [2] Publisher confirms mode can be enabled, which provides asynchronous way to confirm that message has been received. ### GraphQL Mutations @@ -179,6 +177,24 @@ There are two ways reports can be sent through AWS Console and programmatically val report = MyDEXReport().apply { // set the report fields } +suspend fun createSQSClient(): SqsClient{ + return SqsClient{} +} +suspend fun sendReportsToSQS(queueURL: String, report: String){ + val sqsClient = createSQSClient() + try { + val requestToSendReport = SendMessageRequest{ + message = report + this.queueURL = queueURL + } + val response = sqsClient.sendMessage(requestToSendReport) + logger.infor("The report sent, response received: $response") + }catch (ex:SqsException){ + logger.error("Failed to send the message: $ex.message") + }finally { + sqsClient.close() + } +} ``` # Checking on Reports GraphQL queries are available to look for reports, whether they were accepted by PS API or not. If a report can't be ingested, typically due to failed validations, then it will go to deadletter. The deadletter'd reports can be searched for and the reason(s) for its failure examined. From 142af13aa01bb8720de21cd8fbad2360e2133a3b Mon Sep 17 00:00:00 2001 From: TeaSmith7 <137535421+TeaSmith7@users.noreply.github.com> Date: Thu, 12 Sep 2024 11:05:03 -0400 Subject: [PATCH 12/17] updated Kdoc to reflect the change from blocking to non-blocking coroutine --- .../kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt index 3033bc5a..46649260 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/AWSSQS.kt @@ -64,7 +64,7 @@ val AWSSQSPlugin = createApplicationPlugin( } /** * The `consumeMessages` function continuously listens for and processes messages from an AWS SQS queue. - * This function runs in a blocking coroutine, retrieving messages from the queue, validating them using + * This function runs in a non-blocking coroutine, retrieving messages from the queue, validating them using * `AWSSQSProcessor`, and then deleting the processed messages from the queue. * * @throws Exception From 0649e1613c9fc61cd84b4784c11b8ff2870ccdcd Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 12 Sep 2024 13:57:30 -0400 Subject: [PATCH 13/17] Made changes based on PR review --- .../.gitignore | 42 ---- .../build.gradle.kts | 89 ------- .../gradle.properties | 5 - .../gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 - .../gradlew | 234 ------------------ .../gradlew.bat | 89 ------- .../settings.gradle.kts | 5 - .../src/main/kotlin/Application.kt | 29 --- .../src/main/kotlin/HealthCheck.kt | 110 -------- .../src/main/kotlin/Routes.kt | 92 ------- .../kotlin/activity/NotificationActivity.kt | 28 --- .../activity/NotificationActivityImpl.kt | 52 ---- .../src/main/kotlin/cache/InMemoryCache.kt | 98 -------- .../main/kotlin/cache/InMemoryCacheService.kt | 35 --- .../src/main/kotlin/email/EmailDispatcher.kt | 96 ------- .../src/main/kotlin/model/ErrorDetail.kt | 12 - .../kotlin/model/NotificationSubscription.kt | 11 - .../src/main/kotlin/model/Subscirption.kt | 113 --------- ...opErrorsNotificationSubscriptionService.kt | 51 ---- ...ErrorsNotificationUnSubscriptionService.kt | 36 --- .../DeadLineCheckSubscriptionService.kt | 52 ---- .../DeadLineCheckUnSubscriptionService.kt | 35 --- ...adErrorsNotificationSubscriptionService.kt | 54 ---- ...ErrorsNotificationUnSubscriptionService.kt | 35 --- .../main/kotlin/temporal/WorkflowEngine.kt | 74 ------ ...DataStreamTopErrorsNotificationWorkflow.kt | 22 -- ...StreamTopErrorsNotificationWorkflowImpl.kt | 96 ------- .../kotlin/workflow/NotificationWorkflow.kt | 21 -- .../workflow/NotificationWorkflowImpl.kt | 69 ------ .../UploadErrorsNotificationWorkflow.kt | 22 -- .../UploadErrorsNotificationWorkflowImpl.kt | 77 ------ .../src/main/resources/application.conf | 13 - 33 files changed, 1803 deletions(-) delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/.gitignore delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/build.gradle.kts delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradle.properties delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradle/wrapper/gradle-wrapper.jar delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradle/wrapper/gradle-wrapper.properties delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradlew delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/gradlew.bat delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/settings.gradle.kts delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Application.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/HealthCheck.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Routes.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivity.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCache.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCacheService.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/email/EmailDispatcher.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/ErrorDetail.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/NotificationSubscription.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/Subscirption.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/temporal/WorkflowEngine.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt delete mode 100644 pstatus-notifications-workflow-orchestrator-ktor/src/main/resources/application.conf diff --git a/pstatus-notifications-workflow-orchestrator-ktor/.gitignore b/pstatus-notifications-workflow-orchestrator-ktor/.gitignore deleted file mode 100644 index b63da455..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/.gitignore +++ /dev/null @@ -1,42 +0,0 @@ -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### IntelliJ IDEA ### -.idea/modules.xml -.idea/jarRepositories.xml -.idea/compiler.xml -.idea/libraries/ -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### Eclipse ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ - -### Mac OS ### -.DS_Store \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/build.gradle.kts b/pstatus-notifications-workflow-orchestrator-ktor/build.gradle.kts deleted file mode 100644 index 95815fba..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/build.gradle.kts +++ /dev/null @@ -1,89 +0,0 @@ - - -buildscript { - repositories { - mavenCentral() - } - -} -plugins { - kotlin("jvm") version "1.9.23" - id("com.google.cloud.tools.jib") version "3.3.0" - id ("io.ktor.plugin") version "2.3.11" - id ("maven-publish") - id ("java-library") - id ("org.jetbrains.kotlin.plugin.serialization") version "1.8.20" -} -repositories { - mavenCentral() -} - -group "gov.cdc.ocio" -version "0.0.1" - -dependencies { - implementation("io.temporal:temporal-sdk:1.15.1") - implementation("com.sendgrid:sendgrid-java:4.9.2") - implementation ("io.ktor:ktor-server-core:2.3.2") - implementation ("io.ktor:ktor-server-netty:2.3.2") - implementation ("io.ktor:ktor-server-content-negotiation:2.3.2") - implementation ("io.ktor:ktor-serialization-kotlinx-json:2.3.2") - implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2") - implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.7.2") - implementation ("io.github.microutils:kotlin-logging-jvm:3.0.5") - implementation ("com.google.code.gson:gson:2.10.1") - implementation ("io.github.microutils:kotlin-logging-jvm:3.0.5") - implementation ("org.slf4j:slf4j-api:1.7.36") - implementation ("ch.qos.logback:logback-classic:1.4.12") - implementation ("io.insert-koin:koin-core:3.5.6") - implementation ("io.insert-koin:koin-ktor:3.5.6") - implementation ("com.sun.mail:javax.mail:1.6.2") - implementation ("com.expediagroup:graphql-kotlin-ktor-server:7.1.1") - implementation ("com.graphql-java:graphql-java-extended-scalars:22.0") - implementation ("joda-time:joda-time:2.12.7") - implementation ("org.apache.commons:commons-lang3:3.3.1") - implementation ("com.expediagroup:graphql-kotlin-server:6.0.0") - implementation ("com.expediagroup:graphql-kotlin-schema-generator:6.0.0") - implementation ("io.ktor:ktor-server-netty:2.1.0") - implementation ("io.ktor:ktor-client-content-negotiation:2.1.0") - testImplementation(kotlin("test")) - -} - -tasks.test { - useJUnitPlatform() -} -kotlin { - jvmToolchain(20) -} -repositories{ - mavenLocal() - mavenCentral() -} - -ktor { - docker { - localImageName.set("pstatus-notifications-workflow-ktor") - } -} - -jib { - from { - auth { - username = System.getenv("DOCKERHUB_USERNAME") ?: "" - password = System.getenv("DOCKERHUB_TOKEN") ?: "" - } - } - to { - image = "imagehub.cdc.gov:6989/dex/pstatus/notifications-workflow-service" - auth { - username = System.getenv("IMAGEHUB_USERNAME") ?: "" - password = System.getenv("IMAGEHUB_PASSWORD") ?: "" - } - } -} - -repositories{ - mavenCentral() -} - diff --git a/pstatus-notifications-workflow-orchestrator-ktor/gradle.properties b/pstatus-notifications-workflow-orchestrator-ktor/gradle.properties deleted file mode 100644 index 67793d39..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/gradle.properties +++ /dev/null @@ -1,5 +0,0 @@ - -ktor_version=2.3.10 -kotlin_version=1.9.24 -logback_version=1.4.14 -kotlin.code.style=official \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/gradle/wrapper/gradle-wrapper.jar b/pstatus-notifications-workflow-orchestrator-ktor/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 249e5832f090a2944b7473328c07c9755baa3196..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 60756 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 '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/pstatus-notifications-workflow-orchestrator-ktor/gradlew.bat b/pstatus-notifications-workflow-orchestrator-ktor/gradlew.bat deleted file mode 100644 index 107acd32..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/pstatus-notifications-workflow-orchestrator-ktor/settings.gradle.kts b/pstatus-notifications-workflow-orchestrator-ktor/settings.gradle.kts deleted file mode 100644 index 3aff9817..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/settings.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" -} -rootProject.name = "temporal-workflow-orchestrator-service-poc" - diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Application.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Application.kt deleted file mode 100644 index 6c4c7063..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Application.kt +++ /dev/null @@ -1,29 +0,0 @@ -package gov.cdc.ocio.processingnotifications - -import io.ktor.serialization.jackson.* -import io.ktor.server.application.* -import io.ktor.server.engine.* -import io.ktor.server.netty.* -import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.routing.* - -fun main(args: Array) { - embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true) -} - -fun Application.module() { - - install(ContentNegotiation) { - jackson() - } - routing { - subscribeDeadlineCheckRoute() - unsubscribeDeadlineCheck() - subscribeUploadErrorsNotification() - unsubscribeUploadErrorsNotification() - subscribeDataStreamTopErrorsNotification() - unsubscribesDataStreamTopErrorsNotification() - healthCheckRoute() - } - -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/HealthCheck.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/HealthCheck.kt deleted file mode 100644 index eb308a44..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/HealthCheck.kt +++ /dev/null @@ -1,110 +0,0 @@ -package gov.cdc.ocio.processingnotifications - -import io.temporal.api.workflowservice.v1.GetSystemInfoRequest -import io.temporal.serviceclient.WorkflowServiceStubs -import io.temporal.serviceclient.WorkflowServiceStubsOptions -import mu.KotlinLogging -import org.koin.core.component.KoinComponent -import kotlin.system.measureTimeMillis - -/** - * Abstract class used for modeling the health issues of an individual service. - * - * @property status String - * @property healthIssues String? - * @property service String - */ -abstract class HealthCheckSystem { - - var status: String = "DOWN" - var healthIssues: String? = "" - open val service: String = "" -} - -/** - * Concrete implementation of the Temporal Server health check. - * - * @property service String - */ -class HealthCheckTemporalServer: HealthCheckSystem() { - override val service: String = "Temporal Server" -} -/** - * Run health checks for the service. - * - * @property status String? - * @property totalChecksDuration String? - * @property dependencyHealthChecks MutableList - */ -class HealthCheck { - - var status: String = "DOWN" - var totalChecksDuration: String? = null - var dependencyHealthChecks = mutableListOf() -} - -/** - * Service for querying the health of the temporal server and its dependencies. - * - * @property logger KLogger - - */ -class TemporalHealthCheckService: KoinComponent { - private val logger = KotlinLogging.logger {} - private val serviceOptions = WorkflowServiceStubsOptions.newBuilder() - .setTarget(System.getenv().get("")) // Temporal server address - .build() - private val serviceStubs = WorkflowServiceStubs.newServiceStubs(serviceOptions) - - /** - * Returns a HealthCheck object with the overall health of temporal server and its dependencies. - * - * @return HealthCheck - */ - fun getHealth(): HealthCheck { - val temporalHealth = HealthCheckTemporalServer() - val time = measureTimeMillis { - try { - val isDown= serviceStubs.isShutdown || serviceStubs.isTerminated - if(isDown) - { - temporalHealth.status ="DOWN" - temporalHealth.healthIssues= "Temporal Server is down or terminated" - } - else { - // serviceStubs.healthCheck() - issue finding the proper version for grpc-health-check - // Simple call to get the server capabilities to test if it's up - serviceStubs.blockingStub() - .getSystemInfo(GetSystemInfoRequest.getDefaultInstance()).capabilities - temporalHealth.status = "UP" - } - } catch (ex: Exception) { - temporalHealth.status ="DOWN" - temporalHealth.healthIssues= ex.message - logger.error("Temporal Server is not healthy: ${ex.message}") - } - } - - return HealthCheck().apply { - status = temporalHealth.status - totalChecksDuration = formatMillisToHMS(time) - dependencyHealthChecks.add(temporalHealth) - } - } - - /** - * Format the time in milliseconds to 00:00:00.000 format. - * - * @param millis Long - * @return String - */ - private fun formatMillisToHMS(millis: Long): String { - val seconds = millis / 1000 - val hours = seconds / 3600 - val minutes = (seconds % 3600) / 60 - val remainingSeconds = seconds % 60 - val remainingMillis = millis % 1000 - - return "%02d:%02d:%02d.%03d".format(hours, minutes, remainingSeconds, remainingMillis / 10) - } -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Routes.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Routes.kt deleted file mode 100644 index 9d93677a..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/Routes.kt +++ /dev/null @@ -1,92 +0,0 @@ -@file:Suppress("PLUGIN_IS_NOT_ENABLED") -package gov.cdc.ocio.processingnotifications - -import gov.cdc.ocio.processingnotifications.model.* -import gov.cdc.ocio.processingnotifications.service.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* - -/** - * Route to subscribe for DeadlineCheck subscription - */ -fun Route.subscribeDeadlineCheckRoute() { - post("/subscribe/deadlineCheck") { - val subscription = call.receive() - val deadlineCheckSubscription = DeadlineCheckSubscription(subscription.dataStreamId, subscription.dataStreamRoute, subscription.jurisdiction, - subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) - val result = DeadLineCheckSubscriptionService().run(deadlineCheckSubscription) - call.respond(result) - - } -} -/** - * Route to unsubscribe for DeadlineCheck subscription - */ -fun Route.unsubscribeDeadlineCheck() { - post("/unsubscribe/deadlineCheck") { - val subscription = call.receive() - val result = DeadLineCheckUnSubscriptionService().run(subscription.subscriptionId) - call.respond(result) - } -} - - -/** - * Route to subscribe for upload errors notification subscription - */ -fun Route.subscribeUploadErrorsNotification() { - post("/subscribe/uploadErrorsNotification") { - val subscription = call.receive() - val uploadErrorsNotificationSubscription = UploadErrorsNotificationSubscription(subscription.dataStreamId, subscription.dataStreamRoute, - subscription.jurisdiction, - subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) - val result = UploadErrorsNotificationSubscriptionService().run(uploadErrorsNotificationSubscription) - call.respond(result) - - } -} -/** - * Route to unsubscribe for upload errors subscription notification - */ -fun Route.unsubscribeUploadErrorsNotification() { - post("/unsubscribe/uploadErrorsNotification") { - val subscription = call.receive() - val result = UploadErrorsNotificationUnSubscriptionService().run(subscription.subscriptionId) - call.respond(result) - } -} -/** - * Route to subscribe for top data stream errors notification subscription - */ -fun Route.subscribeDataStreamTopErrorsNotification() { - post("/subscribe/dataStreamTopErrorsNotification") { - val subscription = call.receive() - val dataStreamTopErrorsNotificationSubscription = DataStreamTopErrorsNotificationSubscription(subscription.dataStreamId, subscription.dataStreamRoute, - subscription.jurisdiction, - subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) - val result = DataStreamTopErrorsNotificationSubscriptionService().run(dataStreamTopErrorsNotificationSubscription) - call.respond(result) - - } -} -/** - * Route to unsubscribe for top data stream errors notification subscription - */ -fun Route.unsubscribesDataStreamTopErrorsNotification() { - post("/unsubscribe/dataStreamTopErrorsNotification") { - val subscription = call.receive() - val result = DataStreamTopErrorsNotificationUnSubscriptionService().run(subscription.subscriptionId) - call.respond(result) - } -} - -/** - Route to subscribe for Temporal Server health check - */ -fun Route.healthCheckRoute() { - get("/health") { - call.respond(TemporalHealthCheckService().getHealth()) - } -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivity.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivity.kt deleted file mode 100644 index 90f13e13..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivity.kt +++ /dev/null @@ -1,28 +0,0 @@ -package gov.cdc.ocio.processingnotifications.activity - -import io.temporal.activity.ActivityInterface -import io.temporal.activity.ActivityMethod - -/** - * Interface which defines the activity methods - */ -@ActivityInterface -interface NotificationActivities { - @ActivityMethod - fun sendNotification( - dataStreamId: String, - dataStreamRoute: String, - jurisdiction: String, - deliveryReference: String - ) - @ActivityMethod - fun sendUploadErrorsNotification( - error:String, - deliveryReference: String - ) - @ActivityMethod - fun sendDataStreamTopErrorsNotification( - error:String, - deliveryReference: String - ) -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt deleted file mode 100644 index 2830e2c4..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt +++ /dev/null @@ -1,52 +0,0 @@ -package gov.cdc.ocio.processingnotifications.activity - -import gov.cdc.ocio.processingnotifications.email.EmailDispatcher -import mu.KotlinLogging - -/** - * Implementation class for sending email notifications for various notifications - */ -class NotificationActivitiesImpl : NotificationActivities { - private val emailService: EmailDispatcher = EmailDispatcher() - private val logger = KotlinLogging.logger {} - - /** - * Send notification method which uses the email service to send email when an upload fails - * @param dataStreamId String - * @param dataStreamRoute String - * @param jurisdiction String - * @param deliveryReference String - */ - override fun sendNotification( - dataStreamId: String, - dataStreamRoute: String, - jurisdiction: String, - deliveryReference: String - ) { - val msg ="Upload deadline over. Failed to get the upload for dataStreamId: $dataStreamId, jurisdiction: $jurisdiction.Sending the notification to $deliveryReference " - logger.info(msg) - emailService.sendEmail("TEST EMAIL- UPLOAD DEADLINE CHECK EXPIRED",msg, deliveryReference) - } - /** - * Send notification method which uses the email service to send email when there are errors in the upload file - * @param error String - * @param deliveryReference String - */ - - override fun sendUploadErrorsNotification(error: String, deliveryReference: String) { - val msg ="Errors while upload. $error" - logger.info(msg) - emailService.sendEmail("TEST EMAIL-UPLOAD ERRORS NOTIFICATION",msg, deliveryReference) - } - - /** - * Send notification method which uses the email service to send email with the digest counts of the top errors in an upload - * @param error String - * @param deliveryReference String - */ - - override fun sendDataStreamTopErrorsNotification(error: String, deliveryReference: String) { - logger.info(error) - emailService.sendEmail("TEST EMAIL-DATA STREAM TOP ERRORS NOTIFICATION",error, deliveryReference) - } -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCache.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCache.kt deleted file mode 100644 index 40eec547..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCache.kt +++ /dev/null @@ -1,98 +0,0 @@ -package gov.cdc.ocio.processingnotifications.cache - -import gov.cdc.ocio.processingnotifications.model.* -import java.util.concurrent.locks.ReentrantReadWriteLock -import kotlin.collections.HashMap - -/** - * This class represents InMemoryCache to maintain state of the data at any given point for - * subscription of rules and subscriber for the rules - */ -object InMemoryCache { - private val readWriteLock = ReentrantReadWriteLock() - /* - Cache to store "SubscriptionId -> Subscriber Info (Email or Url and type of subscription)" - subscriberCache = HashMap() - */ - private val subscriberCache = HashMap>() - - - /** - * If Success, this method updates Two Caches for New Subscription: - * a. First Cache with subscription Rule and respective subscriptionId, - * if it doesn't exist,or it returns existing subscription id. - * b. Second cache is subscriber cache where the subscription id is mapped to emailId of subscriber - * or websocket url with the type of subscription - * - - * @return String - */ - fun updateCacheForSubscription(workflowId:String, baseSubscription: BaseSubscription): WorkflowSubscriptionResult { - // val uuid = generateUniqueSubscriptionId() - try { - - updateSubscriberCache(workflowId, - NotificationSubscriptionResponse(subscriptionId = workflowId, subscription = baseSubscription)) - return WorkflowSubscriptionResult(subscriptionId = workflowId, message = "Successfully subscribed for $workflowId", deliveryReference = baseSubscription.deliveryReference) - } - catch (e: Exception){ - return WorkflowSubscriptionResult(subscriptionId = workflowId, message = e.message, deliveryReference = baseSubscription.deliveryReference) - } - - } - - fun updateCacheForUnSubscription(workflowId:String): WorkflowSubscriptionResult { - try { - - unsubscribeSubscriberCache(workflowId) - return WorkflowSubscriptionResult(subscriptionId = workflowId, message = "Successfully unsubscribed Id = $workflowId", deliveryReference = "") - } - catch (e: Exception){ - return WorkflowSubscriptionResult(subscriptionId = workflowId, message = e.message,"") - } - - } - - /** - * This method adds to the subscriber cache the new entry of subscriptionId to the NotificationSubscriber - * - * @param subscriptionId String - - */ - private fun updateSubscriberCache(subscriptionId: String, - notificationSubscriptionResponse: NotificationSubscriptionResponse) { - //logger.debug("Subscriber added in subscriber cache") - readWriteLock.writeLock().lock() - try { - subscriberCache.putIfAbsent(subscriptionId, mutableListOf()) - subscriberCache[subscriptionId]?.add(notificationSubscriptionResponse) - } finally { - readWriteLock.writeLock().unlock() - } - } - - /** - * This method unsubscribes the subscriber from the subscriber cache - * by removing the Map[subscriptionId, NotificationSubscriber] - * entry from cache but keeps the susbscriptionRule in subscription - * cache for any other existing subscriber needs. - * - * @param subscriptionId String - * @return Boolean - */ - private fun unsubscribeSubscriberCache(subscriptionId: String): Boolean { - if (subscriberCache.containsKey(subscriptionId)) { - val subscribers = subscriberCache[subscriptionId]?.filter { it.subscriptionId == subscriptionId }.orEmpty().toMutableList() - - readWriteLock.writeLock().lock() - try { - subscriberCache.remove(subscriptionId, subscribers) - } finally { - readWriteLock.writeLock().unlock() - } - return true - } else { - throw Exception("Subscription doesn't exist") - } - } -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCacheService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCacheService.kt deleted file mode 100644 index 75d972fa..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/cache/InMemoryCacheService.kt +++ /dev/null @@ -1,35 +0,0 @@ -package gov.cdc.ocio.processingnotifications.cache - - -import gov.cdc.ocio.processingnotifications.model.BaseSubscription -import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult - -/** - * This class is a service that interacts with InMemory Cache in order to subscribe/unsubscribe users - */ -class InMemoryCacheService { - - /** - * This method creates a hash of the rule keys (dataStreamId, stageName, dataStreamRoute, statusType) - * to use as a key for SubscriptionRuleCache and creates a new or existing subscription (if exist) - * and creates a new entry in subscriberCache for the user with the susbscriptionRuleKey - * - - */ - fun updateSubscriptionPreferences(workflowId:String, baseSubscription: BaseSubscription): WorkflowSubscriptionResult { - try { - return InMemoryCache.updateCacheForSubscription(workflowId,baseSubscription) - } catch (e: Exception) { - throw e - } - } - - fun updateDeadlineCheckUnSubscriptionPreferences(workflowId:String): WorkflowSubscriptionResult { - try { - return InMemoryCache.updateCacheForUnSubscription(workflowId) - } catch (e: Exception) { - throw e - } - } - -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/email/EmailDispatcher.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/email/EmailDispatcher.kt deleted file mode 100644 index bdba51d7..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/email/EmailDispatcher.kt +++ /dev/null @@ -1,96 +0,0 @@ -package gov.cdc.ocio.processingnotifications.email - -import mu.KotlinLogging -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.Socket -import javax.mail.internet.MimeMessage -import java.util.* -import javax.mail.Message -import javax.mail.Session -import javax.mail.Transport -import javax.mail.internet.InternetAddress - -/** - * The class which dispatches the email using SMTP - */ -class EmailDispatcher { - private val logger = KotlinLogging.logger {} - - /** - * Method to send email which checks the SMTP status and then invokes sendEmail - * @param subject String - * @param body String - * @param toEmail String - */ - fun sendEmail(subject:String,body:String, toEmail:String) { - try{ - - if(!checkSMTPStatusWithoutCredentials()) return - // TODO : Change this into properties - val toEmalId = toEmail - val props = System.getProperties() - props["mail.smtp.host"] = "smtpgw.cdc.gov" - props["mail.smtp.port"] = 25 - val session = Session.getInstance(props, null) - sendEmail(session, toEmalId, subject,body) - } catch(e: Exception) { - logger.error("Unable to send email ${e.message}") - } - - } - /** - * Method to send email - * @param session Session - * @param toEmail String - * @param subject String - * @param body String - */ - - private fun sendEmail(session: Session?, toEmail: String?, subject: String?, body: String?) { - try { - val msg = MimeMessage(session) - val replyToEmail = "donotreply@cdc.gov" - val replyToName = "DoNOtReply (DEX Team)" - //set message headers - msg.addHeader("Content-type", "text/HTML; charset=UTF-8") - msg.addHeader("format", "flowed") - msg.addHeader("Content-Transfer-Encoding", "8bit") - - //TODO - Change the from and replyTo address after the new licensed account is created - // Get the email addresses from the property - msg.setFrom(InternetAddress(replyToEmail, replyToName)) - msg.replyTo = InternetAddress.parse(replyToEmail, false) - msg.setSubject(subject, "UTF-8") - msg.setText(body, "UTF-8") - msg.sentDate = Date() - msg.setRecipients(Message.RecipientType.TO, InternetAddress.parse(toEmail, false)) - Transport.send(msg) - } catch (e: Exception) { - e.printStackTrace() - } - } - - /** - * Method to check the status of the SMTP server - */ - private fun checkSMTPStatusWithoutCredentials(): Boolean { - // This is to get the status from curl statement to see if server is connected - try { - - val smtpServer = "smtpgw.cdc.gov" - val port = 25 - val socket = Socket(smtpServer, port) - val reader = BufferedReader(InputStreamReader(socket.getInputStream())) - // Read the server response - val response = reader.readLine() - println("Server response: $response") - // Close the socket - socket.close() - return response !=null - } catch (e: Exception) { - logger.error("Unable to send email. Error is ${e.message} \n. Stack trace : ${e.printStackTrace()}") - } - return false - } -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/ErrorDetail.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/ErrorDetail.kt deleted file mode 100644 index 0ebd380c..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/ErrorDetail.kt +++ /dev/null @@ -1,12 +0,0 @@ -package gov.cdc.ocio.processingnotifications.model - -/** - * Error Detail class - * @property description String - * @property count Int - */ -data class ErrorDetail( - val description: String, - val count: Int -) - diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/NotificationSubscription.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/NotificationSubscription.kt deleted file mode 100644 index 85e90387..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/NotificationSubscription.kt +++ /dev/null @@ -1,11 +0,0 @@ -package gov.cdc.ocio.processingnotifications.model -/** - * Notification subscription response class - * @param subscriptionId String - * @param subscription BaseSubscription - */ - -class NotificationSubscriptionResponse(val subscriptionId: String, - val subscription: BaseSubscription) - - diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/Subscirption.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/Subscirption.kt deleted file mode 100644 index 4f372f9b..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/model/Subscirption.kt +++ /dev/null @@ -1,113 +0,0 @@ -package gov.cdc.ocio.processingnotifications.model - -/** - * Base class for subscription - * @param dataStreamId String - * @param dataStreamRoute String - * @param jurisdiction String - * @param daysToRun List - * @param timeToRun String - * @param deliveryReference String - */ -open class BaseSubscription(open val dataStreamId: String, - open val dataStreamRoute: String, - open val jurisdiction: String, - open val daysToRun: List, - open val timeToRun: String, - open val deliveryReference: String) { -} - -/** - * DeadlineCheckSubscription data class which is serialized back and forth when we need to unsubscribe the workflow by the subscriptionId - * @param dataStreamId String - * @param dataStreamRoute String - * @param jurisdiction String - * @param daysToRun List - * @param timeToRun String - * @param deliveryReference String - */ -data class DeadlineCheckSubscription( - override val dataStreamId: String, - override val dataStreamRoute: String, - override val jurisdiction: String, - override val daysToRun: List, - override val timeToRun: String, - override val deliveryReference: String) - : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) - -/** - * DeadlineCheckUnSubscription data class which is serialized back and forth when we need to unsubscribe the workflow by the subscriptionId - * @param subscriptionId String - */ -data class DeadlineCheckUnSubscription(val subscriptionId:String) - -/** - * The resultant class for subscription of email/webhooks - * @param subscriptionId String - * @param message String - * @param deliveryReference String - */ -data class WorkflowSubscriptionResult( - var subscriptionId: String? = null, - var message: String? = "", - var deliveryReference:String -) - -/** - * Upload errors notification Subscription data class which is serialized back and forth from graphQL to this service - * @param dataStreamId String - * @param dataStreamRoute String - * @param jurisdiction String - * @param daysToRun List - * @param timeToRun String - * @param deliveryReference String - * daysToRun:["Mon","Tue","Wed"] - * timeToRun:"45 16 * *" - this should be the format - */ -data class UploadErrorsNotificationSubscription( override val dataStreamId: String, - override val dataStreamRoute: String, - override val jurisdiction: String, - override val daysToRun: List, - override val timeToRun: String, - override val deliveryReference: String) : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) - - -/** - * Upload errors notification unSubscription data class which is serialized back and forth from graphQL to this service - * @param subscriptionId String - */ -data class UploadErrorsNotificationUnSubscription(val subscriptionId:String) - -/** Data stream top errors notification subscription class which is serialized back and forth from graphQL to this service -* @param dataStreamId String -* @param dataStreamRoute String -* @param jurisdiction String -* @param daysToRun List -* @param timeToRun String -* @param deliveryReference String -* daysToRun:["Mon","Tue","Wed"] -* timeToRun:"45 16 * *" - this should be the format - */ -data class DataStreamTopErrorsNotificationSubscription( override val dataStreamId: String, - override val dataStreamRoute: String, - override val jurisdiction: String, - override val daysToRun: List, - override val timeToRun: String, - override val deliveryReference: String) : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) - -/** - * Data stream errors notification unSubscription data class which is serialized back and forth from graphQL to this service - * @param subscriptionId String - */ -data class DataStreamTopErrorsNotificationUnSubscription(val subscriptionId:String) - -/** - * Get Cron expression based on the daysToRun and timeToRun parameters - * @param daysToRun List - * - */ -fun getCronExpression(daysToRun: List, timeToRun: String):String{ - val daysToRunInStr =daysToRun.joinToString(separator = ",") - val cronExpression= "$timeToRun $daysToRunInStr" - return cronExpression -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt deleted file mode 100644 index 0e5fb88f..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt +++ /dev/null @@ -1,51 +0,0 @@ -package gov.cdc.ocio.processingnotifications.service - -import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl -import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService -import gov.cdc.ocio.processingnotifications.model.DataStreamTopErrorsNotificationSubscription -import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult -import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine -import gov.cdc.ocio.processingnotifications.workflow.DataStreamTopErrorsNotificationWorkflowImpl -import gov.cdc.ocio.processingnotifications.workflow.DataStreamTopErrorsNotificationWorkflow -import io.temporal.client.WorkflowClient -import mu.KotlinLogging - -/** - * The main class which sets up and subscribes the workflow execution - * for digest counts and the frequency with which each of the top 5 errors occur - */ - -class DataStreamTopErrorsNotificationSubscriptionService { - private val logger = KotlinLogging.logger {} - private val cacheService: InMemoryCacheService = InMemoryCacheService() - private val workflowEngine:WorkflowEngine = WorkflowEngine() - private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() - - /** - * The main method which gets called from the route which executes and kicks off the - * workflow execution for digest counts and the frequency with which each of the top 5 errors occur - * @param subscription DataStreamTopErrorsNotificationSubscription - */ - fun run(subscription: DataStreamTopErrorsNotificationSubscription): - WorkflowSubscriptionResult { - try { - val dataStreamId = subscription.dataStreamId - val dataStreamRoute = subscription.dataStreamRoute - val jurisdiction = subscription.jurisdiction - val daysToRun = subscription.daysToRun - val timeToRun = subscription.timeToRun - val deliveryReference= subscription.deliveryReference - val taskQueue = "dataStreamTopErrorsNotificationTaskQueue" - - val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, - DataStreamTopErrorsNotificationWorkflowImpl::class.java ,notificationActivitiesImpl, DataStreamTopErrorsNotificationWorkflow::class.java) - - val execution = WorkflowClient.start(workflow::checkDataStreamTopErrorsAndNotify, dataStreamId, dataStreamRoute, jurisdiction,daysToRun, timeToRun, deliveryReference) - return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) - } - catch (e:Exception){ - logger.error("Error occurred while subscribing for digest counts and top errors : ${e.message}") - } - throw Exception("Error occurred while subscribing for the workflow engine for digest counts and top errors") - } -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt deleted file mode 100644 index 95fb3d23..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt +++ /dev/null @@ -1,36 +0,0 @@ -package gov.cdc.ocio.processingnotifications.service - -import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService -import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult -import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine -import mu.KotlinLogging - -/** - * The main class which subscribes the workflow execution - * for digest counts and top errors and its frequency for each upload - * @property cacheService IMemoryCacheService - * @property workflowEngine WorkflowEngine - - */ -class DataStreamTopErrorsNotificationUnSubscriptionService { - private val logger = KotlinLogging.logger {} - private val cacheService: InMemoryCacheService = InMemoryCacheService() - private val workflowEngine: WorkflowEngine = WorkflowEngine() - - /** - * The main function which is used to cancel the workflow based on the workflowID - * @param subscriptionId String - * @return WorkflowSubscriptionResult - */ - fun run(subscriptionId: String): - WorkflowSubscriptionResult { - try { - workflowEngine.cancelWorkflow(subscriptionId) - return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) - } - catch (e:Exception){ - logger.error("Error occurred while unsubscribing and canceling the workflow for digest counts and top errors with workflowId $subscriptionId: ${e.message}") - } - throw Exception("Error occurred while canceling the workflow engine for digest counts and top for workflow Id $subscriptionId") - } -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt deleted file mode 100644 index ec3b9b85..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt +++ /dev/null @@ -1,52 +0,0 @@ -package gov.cdc.ocio.processingnotifications.service - -import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl -import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService -import gov.cdc.ocio.processingnotifications.model.DeadlineCheckSubscription -import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult -import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine -import gov.cdc.ocio.processingnotifications.workflow.NotificationWorkflow -import gov.cdc.ocio.processingnotifications.workflow.NotificationWorkflowImpl -import io.temporal.client.WorkflowClient -import mu.KotlinLogging - -/** - * The main class which subscribes the workflow execution - * for upload deadline check - * @property cacheService IMemoryCacheService - * @property workflowEngine WorkflowEngine - * @property notificationActivitiesImpl NotificationActivitiesImpl - */ -class DeadLineCheckSubscriptionService { - private val logger = KotlinLogging.logger {} - private val cacheService: InMemoryCacheService = InMemoryCacheService() - private val workflowEngine: WorkflowEngine = WorkflowEngine() - private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() - - /** - * The main method which executes workflow for uploadDeadline check - * @param subscription DeadlineCheckSubscription - * @return WorkflowSubscriptionResult - */ - fun run(subscription: DeadlineCheckSubscription): - WorkflowSubscriptionResult { - try { - val dataStreamId = subscription.dataStreamId - val dataStreamRoute = subscription.dataStreamRoute - val jurisdiction = subscription.jurisdiction - val daysToRun = subscription.daysToRun - val timeToRun = subscription.timeToRun - val deliveryReference= subscription.deliveryReference - val taskQueue = "notificationTaskQueue" - - val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, - NotificationWorkflowImpl::class.java ,notificationActivitiesImpl, NotificationWorkflow::class.java) - val execution = WorkflowClient.start(workflow::checkUploadAndNotify, jurisdiction, dataStreamId, dataStreamRoute, daysToRun, timeToRun, deliveryReference) - return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) - } - catch (e:Exception){ - logger.error("Error occurred while subscribing workflow for upload deadline: ${e.message}") - } - throw Exception("Error occurred while executing workflow engine to subscribe for upload deadline") - } -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt deleted file mode 100644 index f07bd87f..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt +++ /dev/null @@ -1,35 +0,0 @@ -package gov.cdc.ocio.processingnotifications.service - -import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService -import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult -import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine -import mu.KotlinLogging - -/** - * The main class which unsubscribes the workflow execution - * for upload errors - * @property cacheService IMemoryCacheService - * @property workflowEngine WorkflowEngine - */ -class DeadLineCheckUnSubscriptionService { - private val logger = KotlinLogging.logger {} - private val cacheService: InMemoryCacheService = InMemoryCacheService() - private val workflowEngine: WorkflowEngine = WorkflowEngine() - - /** - * The main method which cancels the workflow based on the workflow Id - * @param subscriptionId String - */ - - fun run(subscriptionId: String): - WorkflowSubscriptionResult { - try { - workflowEngine.cancelWorkflow(subscriptionId) - return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) - } - catch (e:Exception){ - logger.error("Error occurred while unsubscribing and canceling the workflow for upload deadline with workflowId $subscriptionId: ${e.message}") - } - throw Exception("Error occurred while canceling the workflow execution engine for upload deadline check for workflow Id $subscriptionId") - } -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt deleted file mode 100644 index 35695ee2..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt +++ /dev/null @@ -1,54 +0,0 @@ -package gov.cdc.ocio.processingnotifications.service - -import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl -import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService -import gov.cdc.ocio.processingnotifications.model.UploadErrorsNotificationSubscription -import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult -import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine -import gov.cdc.ocio.processingnotifications.workflow.UploadErrorsNotificationWorkflow -import gov.cdc.ocio.processingnotifications.workflow.UploadErrorsNotificationWorkflowImpl - -import io.temporal.client.WorkflowClient -import mu.KotlinLogging - -/** - * The main class which subscribes the workflow execution - * for upload errors - * @property cacheService IMemoryCacheService - * @property workflowEngine WorkflowEngine - * @property notificationActivitiesImpl NotificationActivitiesImpl - */ -class UploadErrorsNotificationSubscriptionService { - private val cacheService: InMemoryCacheService = InMemoryCacheService() - private val workflowEngine:WorkflowEngine = WorkflowEngine() - private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() - private val logger = KotlinLogging.logger {} - /** - * The main method which executes workflow engine to check for upload errors and notify - * @param subscription UploadErrorsNotificationSubscription - * @return WorkflowSubscriptionResult - */ - - fun run(subscription: UploadErrorsNotificationSubscription): - WorkflowSubscriptionResult { - try { - val dataStreamId = subscription.dataStreamId - val dataStreamRoute = subscription.dataStreamRoute - val jurisdiction = subscription.jurisdiction - val daysToRun = subscription.daysToRun - val timeToRun = subscription.timeToRun - val deliveryReference= subscription.deliveryReference - val taskQueue = "uploadErrorsNotificationTaskQueue" - - val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, - UploadErrorsNotificationWorkflowImpl::class.java ,notificationActivitiesImpl, UploadErrorsNotificationWorkflow::class.java) - - val execution = WorkflowClient.start(workflow::checkUploadErrorsAndNotify, dataStreamId, dataStreamRoute, jurisdiction,daysToRun, timeToRun, deliveryReference) - return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) - } - catch (e:Exception){ - logger.error("Error occurred while checking for errors in upload: ${e.message}") - } - throw Exception("Error occurred while executing workflow engine to subscribe for errors in upload") - } -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt deleted file mode 100644 index 0eb2b142..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt +++ /dev/null @@ -1,35 +0,0 @@ -package gov.cdc.ocio.processingnotifications.service - - -import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService -import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult -import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine -import mu.KotlinLogging - -/** - * The main class which unsubscribes the workflow execution - * for upload errors - * @property cacheService IMemoryCacheService - * @property workflowEngine WorkflowEngine - */ -class UploadErrorsNotificationUnSubscriptionService { - private val logger = KotlinLogging.logger {} - private val cacheService: InMemoryCacheService = InMemoryCacheService() - private val workflowEngine: WorkflowEngine = WorkflowEngine() - - /** - * The main method which cancels a workflow based on the workflow Id - * @param subscriptionId String - */ - fun run(subscriptionId: String): - WorkflowSubscriptionResult { - try { - workflowEngine.cancelWorkflow(subscriptionId) - return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) - } - catch (e:Exception){ - logger.error("Error occurred while checking for upload deadline: ${e.message}") - } - throw Exception("Error occurred while canceling the execution of workflow engine to cancel workflow for workflow Id $subscriptionId") - } -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/temporal/WorkflowEngine.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/temporal/WorkflowEngine.kt deleted file mode 100644 index 7bb445eb..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/temporal/WorkflowEngine.kt +++ /dev/null @@ -1,74 +0,0 @@ -package gov.cdc.ocio.processingnotifications.temporal - -import gov.cdc.ocio.processingnotifications.model.getCronExpression -import io.temporal.client.WorkflowClient -import io.temporal.client.WorkflowOptions -import io.temporal.client.WorkflowStub -import io.temporal.serviceclient.WorkflowServiceStubs -import io.temporal.worker.WorkerFactory -import mu.KotlinLogging - -/** - * Workflow engine class which creates a grpC client instance of the temporal server - * using which it registers the workflow and the activity implementation - * Also,using the workflow options the client creates a new workflow stub - * Note : CRON expression is used to set the schedule - */ -class WorkflowEngine { - private val logger = KotlinLogging.logger {} - - fun setupWorkflow( - taskName:String, - daysToRun:List, timeToRun:String, - workflowImpl: Class, activitiesImpl:T2, workflowImplInterface:Class):T3{ - try { - val service = WorkflowServiceStubs.newLocalServiceStubs() - val client = WorkflowClient.newInstance(service) - val factory = WorkerFactory.newInstance(client) - - val worker = factory.newWorker(taskName) - worker.registerWorkflowImplementationTypes(workflowImpl) - worker.registerActivitiesImplementations(activitiesImpl) - logger.info("Workflow and Activity successfully registered") - factory.start() - - val workflowOptions = WorkflowOptions.newBuilder() - .setTaskQueue(taskName) - .setCronSchedule(getCronExpression(daysToRun,timeToRun)) // Cron schedule: 15 5 * * 1-5 - Every week day at 5:15a - .build() - - val workflow = client.newWorkflowStub( - workflowImplInterface, - workflowOptions - ) - logger.info("Workflow successfully started") - return workflow - } - catch (ex: Exception){ - logger.error("Error while creating workflow: ${ex.message}") - } - throw Exception("WorkflowEngine instantiation failed. Please try again") - } - - /** - * Cancel the workflow based on the workflowId - * @param workflowId String - */ - fun cancelWorkflow(workflowId:String){ - try { - val service = WorkflowServiceStubs.newLocalServiceStubs() - val client = WorkflowClient.newInstance(service) - - // Retrieve the workflow by its ID - val workflow: WorkflowStub = client.newUntypedWorkflowStub(workflowId) - // Cancel the workflow - workflow.cancel() - logger.info("WorkflowID:$workflowId successfully cancelled") - } - catch (ex: Exception){ - logger.error("Error while canceling the workflow : ${ex.message}") - } - throw Exception("Workflow cancellation failed. Please try again") - - } -} \ No newline at end of file diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt deleted file mode 100644 index 4e023846..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt +++ /dev/null @@ -1,22 +0,0 @@ -package gov.cdc.ocio.processingnotifications.workflow - -import io.temporal.workflow.WorkflowInterface -import io.temporal.workflow.WorkflowMethod - -/** - * The interface which defines the digest counts and top errors during an upload and its frequency - */ -@WorkflowInterface -interface DataStreamTopErrorsNotificationWorkflow { - - @WorkflowMethod - fun checkDataStreamTopErrorsAndNotify( - dataStreamId: String, - dataStreamRoute: String, - jurisdiction: String, - daysToRun: List, - timeToRun: String, - deliveryReference: String - ) - -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt deleted file mode 100644 index 38f45a20..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt +++ /dev/null @@ -1,96 +0,0 @@ -package gov.cdc.ocio.processingnotifications.workflow - -import gov.cdc.ocio.processingnotifications.activity.NotificationActivities -import gov.cdc.ocio.processingnotifications.model.ErrorDetail -import io.temporal.activity.ActivityOptions -import io.temporal.common.RetryOptions -import io.temporal.workflow.Workflow -import mu.KotlinLogging -import java.time.Duration - -/** - * The implementation class which determines the digest counts and top errors during an upload and its frequency - * @property activities T - */ -class DataStreamTopErrorsNotificationWorkflowImpl : DataStreamTopErrorsNotificationWorkflow { - private val logger = KotlinLogging.logger {} - private val activities = Workflow.newActivityStub( - NotificationActivities::class.java, - ActivityOptions.newBuilder() - .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout - .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout - .setRetryOptions( - RetryOptions.newBuilder() - .setMaximumAttempts(3) // Set retry options if needed - .build() - ) - .build() - ) - - //TODO : This should come from db in real application - val errorList = listOf( - "DataStreamId missing", - "DataStreamRoute missing", - "Jurisdiction missing", - "DataStreamId missing", - "Jurisdiction missing", - "DataStreamRoute missing", - "DataStreamRoute missing", - "DataStreamId missing", - "DataStreamId missing", - "DataStreamRoute missing", - "DataStreamId missing", - "DataStreamId missing", - ) - - /** - * The function which determines the digest counts and top errors during an upload and its frequency - * @param dataStreamId String - * @param dataStreamRoute String - * @param jurisdiction String - * @param daysToRun List - * @param timeToRun String - * @param deliveryReference String - */ - override fun checkDataStreamTopErrorsAndNotify( - dataStreamId: String, - dataStreamRoute: String, - jurisdiction: String, - daysToRun: List, - timeToRun: String, - deliveryReference: String - ) { - try { - // Logic to check if the upload occurred*/ - val (totalCount, topErrors) = getTopErrors(errorList) - val errors = topErrors.filter{it.description.isNotEmpty()}.joinToString() - if (topErrors.isNotEmpty()) { - activities.sendDataStreamTopErrorsNotification("There are $totalCount errors \n These are the top errors : \n $errors \n",deliveryReference) - } - } catch (e: Exception) { - logger.error("Error occurred while checking for counts and top errors and frequency in an upload: ${e.message}") - } - } - - /** - * Function which actually does find the counts and the erroneous fields and its frequency - * @param errors List - * @return Pair - */ - - private fun getTopErrors(errors: List): Pair> { - // Group the errors by description and count their occurrences - val errorCounts = errors.groupingBy { it }.eachCount() - - // Convert the grouped data into a list of ErrorDetail objects - val errorDetails = errorCounts.map { (description, count) -> - ErrorDetail(description, count) - } - // Sort the errors by their count in descending order and take the top 5 - val topErrors = errorDetails.sortedByDescending { it.count }.take(5) - - // Return the total count of errors and the top 5 errors - return Pair(errors.size, topErrors) - } - -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt deleted file mode 100644 index 19420635..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt +++ /dev/null @@ -1,21 +0,0 @@ -package gov.cdc.ocio.processingnotifications.workflow - -import io.temporal.workflow.WorkflowInterface -import io.temporal.workflow.WorkflowMethod - -/** - * The interface which define the upload error and notify method - */ -@WorkflowInterface -interface NotificationWorkflow { - @WorkflowMethod - fun checkUploadAndNotify( - dataStreamId: String, - dataStreamRoute: String, - jurisdiction: String, - daysToRun: List, - timeToRun: String, - deliveryReference: String - ) - -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt deleted file mode 100644 index 70cf9faf..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt +++ /dev/null @@ -1,69 +0,0 @@ -package gov.cdc.ocio.processingnotifications.workflow - -import gov.cdc.ocio.processingnotifications.activity.NotificationActivities -import io.temporal.activity.ActivityOptions -import io.temporal.common.RetryOptions -import io.temporal.workflow.Workflow -import mu.KotlinLogging -import java.time.Duration - -/** - * The implementation class for notifying if an upload has not occurred within a specified time - * @property activities T - */ -class NotificationWorkflowImpl : NotificationWorkflow { - private val logger = KotlinLogging.logger {} - private val activities = Workflow.newActivityStub( - NotificationActivities::class.java, - ActivityOptions.newBuilder() - .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout - .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout - .setRetryOptions( - RetryOptions.newBuilder() - .setMaximumAttempts(3) // Set retry options if needed - .build() - ) - .build() - ) - /** - * The function which gets invoked by the temporal WF engine which checks whether upload has occurred within a specified time or not - * invokes the activity, if there are errors - * @param dataStreamId String - * @param dataStreamRoute String - * @param jurisdiction String - * @param daysToRun List - * @param timeToRun String - * @param deliveryReference String - */ - override fun checkUploadAndNotify( - dataStreamId: String, - dataStreamRoute: String, - jurisdiction: String, - daysToRun: List, - timeToRun: String, - deliveryReference: String - ) { - - try { - // Logic to check if the upload occurred*/ - val uploadOccurred = checkUpload(dataStreamId, jurisdiction) - if (!uploadOccurred) { - activities.sendNotification(dataStreamId, dataStreamRoute, jurisdiction, deliveryReference) - } - } catch (e: Exception) { - logger.error("Error occurred while checking for upload deadline: ${e.message}") - } - - } - /** - * The actual function which checks for whether the upload has occurred or not within a specified time - * @param dataStreamId String - * @param jurisdiction String - */ - private fun checkUpload(dataStreamId: String, jurisdiction: String): Boolean { - // add check logic here - return false - } - - -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt deleted file mode 100644 index eb0eadff..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt +++ /dev/null @@ -1,22 +0,0 @@ -package gov.cdc.ocio.processingnotifications.workflow - -import io.temporal.workflow.WorkflowInterface -import io.temporal.workflow.WorkflowMethod - -/** - * Interface that defines the upload errors and notify - */ -@WorkflowInterface -interface UploadErrorsNotificationWorkflow { - - @WorkflowMethod - fun checkUploadErrorsAndNotify( - dataStreamId: String, - dataStreamRoute: String, - jurisdiction: String, - daysToRun: List, - timeToRun: String, - deliveryReference: String - ) - -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt b/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt deleted file mode 100644 index cc2782ce..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt +++ /dev/null @@ -1,77 +0,0 @@ -package gov.cdc.ocio.processingnotifications.workflow - -import gov.cdc.ocio.processingnotifications.activity.NotificationActivities -import io.temporal.activity.ActivityOptions -import io.temporal.common.RetryOptions -import io.temporal.workflow.Workflow -import mu.KotlinLogging -import java.time.Duration - -/** - * The implementation class for errors on missing fields from a upload - * @property activities T - */ -class UploadErrorsNotificationWorkflowImpl : UploadErrorsNotificationWorkflow { - private val logger = KotlinLogging.logger {} - private val activities = Workflow.newActivityStub( - NotificationActivities::class.java, - ActivityOptions.newBuilder() - .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout - .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout - .setRetryOptions( - RetryOptions.newBuilder() - .setMaximumAttempts(3) // Set retry options if needed - .build() - ) - .build() - ) -/** - * The function which gets invoked by the temporal WF engine and which checks for the errors in the upload and - * invokes the activity, if there are errors - * @param dataStreamId String - * @param dataStreamRoute String - * @param jurisdiction String - * @param daysToRun List - * @param timeToRun String - * @param deliveryReference String - */ - override fun checkUploadErrorsAndNotify( - dataStreamId: String, - dataStreamRoute: String, - jurisdiction: String, - daysToRun: List, - timeToRun: String, - deliveryReference: String - ) { - try { - // Logic to check if the upload occurred*/ - val error = checkUploadErrors(dataStreamId, dataStreamRoute, jurisdiction) - if (error.isNotEmpty()) { - activities.sendUploadErrorsNotification(error,deliveryReference) - } - } catch (e: Exception) { - logger.error("Error occurred while checking for errors in upload. Errors are : ${e.message}") - } - } - - /** - * Thw actual function which checks for errors in the fields used for upload - * @param dataStreamId String - * @param dataStreamRoute String - * * @param jurisdiction String - */ - - private fun checkUploadErrors(dataStreamId: String, dataStreamRoute: String, jurisdiction: String): String { - var error = "" - if (dataStreamId.isEmpty()) { - error = "DataStreamId is missing from the upload." - } - if (dataStreamRoute.isEmpty()) { - error += "DataStreamRoute is missing from the upload." - } - if (jurisdiction.isEmpty()) { - error += "Jurisdiction is missing from the upload" - } - return error - } -} diff --git a/pstatus-notifications-workflow-orchestrator-ktor/src/main/resources/application.conf b/pstatus-notifications-workflow-orchestrator-ktor/src/main/resources/application.conf deleted file mode 100644 index b5464773..00000000 --- a/pstatus-notifications-workflow-orchestrator-ktor/src/main/resources/application.conf +++ /dev/null @@ -1,13 +0,0 @@ -ktor { - deployment { - port = 8081 - host = 0.0.0.0 - } - - application { - modules = [gov.cdc.ocio.processingnotifications.ApplicationKt.module] - } - - version = "0.0.1" - } - From 8e2bc3de2ef890f21f08fbd60383924926a48d1a Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 12 Sep 2024 14:38:34 -0400 Subject: [PATCH 14/17] Made changes based on PR review. Renamed the project as well --- .../mutations/response/Response.kt | 42 ++++ .../.gitignore | 42 ++++ .../build.gradle.kts | 89 +++++++ .../gradle.properties | 5 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + pstatus-notifications-workflow-ktor/gradlew | 234 ++++++++++++++++++ .../gradlew.bat | 89 +++++++ .../settings.gradle.kts | 5 + .../src/main/kotlin/Application.kt | 29 +++ .../src/main/kotlin/HealthCheck.kt | 110 ++++++++ .../src/main/kotlin/Routes.kt | 92 +++++++ .../kotlin/activity/NotificationActivity.kt | 28 +++ .../activity/NotificationActivityImpl.kt | 52 ++++ .../src/main/kotlin/cache/InMemoryCache.kt | 98 ++++++++ .../main/kotlin/cache/InMemoryCacheService.kt | 35 +++ .../src/main/kotlin/email/EmailDispatcher.kt | 96 +++++++ .../src/main/kotlin/model/ErrorDetail.kt | 12 + .../kotlin/model/NotificationSubscription.kt | 11 + .../src/main/kotlin/model/Subscription.kt | 113 +++++++++ ...opErrorsNotificationSubscriptionService.kt | 51 ++++ ...ErrorsNotificationUnSubscriptionService.kt | 36 +++ .../DeadLineCheckSubscriptionService.kt | 52 ++++ .../DeadLineCheckUnSubscriptionService.kt | 35 +++ ...adErrorsNotificationSubscriptionService.kt | 54 ++++ ...ErrorsNotificationUnSubscriptionService.kt | 35 +++ .../main/kotlin/temporal/WorkflowEngine.kt | 74 ++++++ ...DataStreamTopErrorsNotificationWorkflow.kt | 22 ++ ...StreamTopErrorsNotificationWorkflowImpl.kt | 96 +++++++ .../kotlin/workflow/NotificationWorkflow.kt | 21 ++ .../workflow/NotificationWorkflowImpl.kt | 69 ++++++ .../UploadErrorsNotificationWorkflow.kt | 22 ++ .../UploadErrorsNotificationWorkflowImpl.kt | 77 ++++++ .../src/main/resources/application.conf | 13 + 34 files changed, 1845 insertions(+) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt create mode 100644 pstatus-notifications-workflow-ktor/.gitignore create mode 100644 pstatus-notifications-workflow-ktor/build.gradle.kts create mode 100644 pstatus-notifications-workflow-ktor/gradle.properties create mode 100644 pstatus-notifications-workflow-ktor/gradle/wrapper/gradle-wrapper.jar create mode 100644 pstatus-notifications-workflow-ktor/gradle/wrapper/gradle-wrapper.properties create mode 100644 pstatus-notifications-workflow-ktor/gradlew create mode 100644 pstatus-notifications-workflow-ktor/gradlew.bat create mode 100644 pstatus-notifications-workflow-ktor/settings.gradle.kts create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/Application.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/HealthCheck.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/Routes.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/activity/NotificationActivity.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/cache/InMemoryCache.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/cache/InMemoryCacheService.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/email/EmailDispatcher.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/model/ErrorDetail.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/model/NotificationSubscription.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/model/Subscription.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/temporal/WorkflowEngine.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt create mode 100644 pstatus-notifications-workflow-ktor/src/main/resources/application.conf diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt new file mode 100644 index 00000000..a73b2bf9 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt @@ -0,0 +1,42 @@ +package gov.cdc.ocio.processingstatusapi.mutations.response + +import gov.cdc.ocio.processingstatusapi.mutations.models.NotificationSubscriptionResult +import io.ktor.client.call.* +import io.ktor.client.statement.* +import io.ktor.http.* + +object SubscriptionResponse{ + + /** + * Function to process the http response coming from notifications service + * @param response HttpResponse + */ + @JvmStatic + suspend fun ProcessNotificationResponse(response: HttpResponse): NotificationSubscriptionResult { + if (response.status == HttpStatusCode.OK) { + return response.body() + } else { + throw Exception("Notification service is unavailable. Status:${response.status}") + } + } + + @JvmStatic + @Throws(Exception::class) + /** + * Function to process the http response codes and throw exception accordingly + * @param url String + * @param e Exception + * @param subscriptionId String? + */ + fun ProcessErrorCodes(url: String, e: Exception, subscriptionId: String?) { + val error = e.message!!.substringAfter("Status:").substringBefore(" ") + when (error) { + "500" -> throw Exception("Subscription with subscriptionId = ${subscriptionId} does not exist in the cache") + "400" -> throw Exception("Bad Request: Please check the request and retry") + "401" -> throw Exception("Unauthorized access to notifications service") + "403" -> throw Exception("Access to notifications service is forbidden") + "404" -> throw Exception("${url} not found") + else -> throw Exception(e.message) + } + } +} diff --git a/pstatus-notifications-workflow-ktor/.gitignore b/pstatus-notifications-workflow-ktor/.gitignore new file mode 100644 index 00000000..b63da455 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/build.gradle.kts b/pstatus-notifications-workflow-ktor/build.gradle.kts new file mode 100644 index 00000000..95815fba --- /dev/null +++ b/pstatus-notifications-workflow-ktor/build.gradle.kts @@ -0,0 +1,89 @@ + + +buildscript { + repositories { + mavenCentral() + } + +} +plugins { + kotlin("jvm") version "1.9.23" + id("com.google.cloud.tools.jib") version "3.3.0" + id ("io.ktor.plugin") version "2.3.11" + id ("maven-publish") + id ("java-library") + id ("org.jetbrains.kotlin.plugin.serialization") version "1.8.20" +} +repositories { + mavenCentral() +} + +group "gov.cdc.ocio" +version "0.0.1" + +dependencies { + implementation("io.temporal:temporal-sdk:1.15.1") + implementation("com.sendgrid:sendgrid-java:4.9.2") + implementation ("io.ktor:ktor-server-core:2.3.2") + implementation ("io.ktor:ktor-server-netty:2.3.2") + implementation ("io.ktor:ktor-server-content-negotiation:2.3.2") + implementation ("io.ktor:ktor-serialization-kotlinx-json:2.3.2") + implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2") + implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.7.2") + implementation ("io.github.microutils:kotlin-logging-jvm:3.0.5") + implementation ("com.google.code.gson:gson:2.10.1") + implementation ("io.github.microutils:kotlin-logging-jvm:3.0.5") + implementation ("org.slf4j:slf4j-api:1.7.36") + implementation ("ch.qos.logback:logback-classic:1.4.12") + implementation ("io.insert-koin:koin-core:3.5.6") + implementation ("io.insert-koin:koin-ktor:3.5.6") + implementation ("com.sun.mail:javax.mail:1.6.2") + implementation ("com.expediagroup:graphql-kotlin-ktor-server:7.1.1") + implementation ("com.graphql-java:graphql-java-extended-scalars:22.0") + implementation ("joda-time:joda-time:2.12.7") + implementation ("org.apache.commons:commons-lang3:3.3.1") + implementation ("com.expediagroup:graphql-kotlin-server:6.0.0") + implementation ("com.expediagroup:graphql-kotlin-schema-generator:6.0.0") + implementation ("io.ktor:ktor-server-netty:2.1.0") + implementation ("io.ktor:ktor-client-content-negotiation:2.1.0") + testImplementation(kotlin("test")) + +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(20) +} +repositories{ + mavenLocal() + mavenCentral() +} + +ktor { + docker { + localImageName.set("pstatus-notifications-workflow-ktor") + } +} + +jib { + from { + auth { + username = System.getenv("DOCKERHUB_USERNAME") ?: "" + password = System.getenv("DOCKERHUB_TOKEN") ?: "" + } + } + to { + image = "imagehub.cdc.gov:6989/dex/pstatus/notifications-workflow-service" + auth { + username = System.getenv("IMAGEHUB_USERNAME") ?: "" + password = System.getenv("IMAGEHUB_PASSWORD") ?: "" + } + } +} + +repositories{ + mavenCentral() +} + diff --git a/pstatus-notifications-workflow-ktor/gradle.properties b/pstatus-notifications-workflow-ktor/gradle.properties new file mode 100644 index 00000000..67793d39 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/gradle.properties @@ -0,0 +1,5 @@ + +ktor_version=2.3.10 +kotlin_version=1.9.24 +logback_version=1.4.14 +kotlin.code.style=official \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/gradle/wrapper/gradle-wrapper.jar b/pstatus-notifications-workflow-ktor/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/pstatus-notifications-workflow-ktor/gradlew.bat b/pstatus-notifications-workflow-ktor/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pstatus-notifications-workflow-ktor/settings.gradle.kts b/pstatus-notifications-workflow-ktor/settings.gradle.kts new file mode 100644 index 00000000..f11902ea --- /dev/null +++ b/pstatus-notifications-workflow-ktor/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" +} +rootProject.name = "pstatus-notifications-workflow-ktor" + diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/Application.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/Application.kt new file mode 100644 index 00000000..6c4c7063 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/Application.kt @@ -0,0 +1,29 @@ +package gov.cdc.ocio.processingnotifications + +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.routing.* + +fun main(args: Array) { + embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true) +} + +fun Application.module() { + + install(ContentNegotiation) { + jackson() + } + routing { + subscribeDeadlineCheckRoute() + unsubscribeDeadlineCheck() + subscribeUploadErrorsNotification() + unsubscribeUploadErrorsNotification() + subscribeDataStreamTopErrorsNotification() + unsubscribesDataStreamTopErrorsNotification() + healthCheckRoute() + } + +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/HealthCheck.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/HealthCheck.kt new file mode 100644 index 00000000..eb308a44 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/HealthCheck.kt @@ -0,0 +1,110 @@ +package gov.cdc.ocio.processingnotifications + +import io.temporal.api.workflowservice.v1.GetSystemInfoRequest +import io.temporal.serviceclient.WorkflowServiceStubs +import io.temporal.serviceclient.WorkflowServiceStubsOptions +import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import kotlin.system.measureTimeMillis + +/** + * Abstract class used for modeling the health issues of an individual service. + * + * @property status String + * @property healthIssues String? + * @property service String + */ +abstract class HealthCheckSystem { + + var status: String = "DOWN" + var healthIssues: String? = "" + open val service: String = "" +} + +/** + * Concrete implementation of the Temporal Server health check. + * + * @property service String + */ +class HealthCheckTemporalServer: HealthCheckSystem() { + override val service: String = "Temporal Server" +} +/** + * Run health checks for the service. + * + * @property status String? + * @property totalChecksDuration String? + * @property dependencyHealthChecks MutableList + */ +class HealthCheck { + + var status: String = "DOWN" + var totalChecksDuration: String? = null + var dependencyHealthChecks = mutableListOf() +} + +/** + * Service for querying the health of the temporal server and its dependencies. + * + * @property logger KLogger + + */ +class TemporalHealthCheckService: KoinComponent { + private val logger = KotlinLogging.logger {} + private val serviceOptions = WorkflowServiceStubsOptions.newBuilder() + .setTarget(System.getenv().get("")) // Temporal server address + .build() + private val serviceStubs = WorkflowServiceStubs.newServiceStubs(serviceOptions) + + /** + * Returns a HealthCheck object with the overall health of temporal server and its dependencies. + * + * @return HealthCheck + */ + fun getHealth(): HealthCheck { + val temporalHealth = HealthCheckTemporalServer() + val time = measureTimeMillis { + try { + val isDown= serviceStubs.isShutdown || serviceStubs.isTerminated + if(isDown) + { + temporalHealth.status ="DOWN" + temporalHealth.healthIssues= "Temporal Server is down or terminated" + } + else { + // serviceStubs.healthCheck() - issue finding the proper version for grpc-health-check + // Simple call to get the server capabilities to test if it's up + serviceStubs.blockingStub() + .getSystemInfo(GetSystemInfoRequest.getDefaultInstance()).capabilities + temporalHealth.status = "UP" + } + } catch (ex: Exception) { + temporalHealth.status ="DOWN" + temporalHealth.healthIssues= ex.message + logger.error("Temporal Server is not healthy: ${ex.message}") + } + } + + return HealthCheck().apply { + status = temporalHealth.status + totalChecksDuration = formatMillisToHMS(time) + dependencyHealthChecks.add(temporalHealth) + } + } + + /** + * Format the time in milliseconds to 00:00:00.000 format. + * + * @param millis Long + * @return String + */ + private fun formatMillisToHMS(millis: Long): String { + val seconds = millis / 1000 + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + val remainingMillis = millis % 1000 + + return "%02d:%02d:%02d.%03d".format(hours, minutes, remainingSeconds, remainingMillis / 10) + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/Routes.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/Routes.kt new file mode 100644 index 00000000..9d93677a --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/Routes.kt @@ -0,0 +1,92 @@ +@file:Suppress("PLUGIN_IS_NOT_ENABLED") +package gov.cdc.ocio.processingnotifications + +import gov.cdc.ocio.processingnotifications.model.* +import gov.cdc.ocio.processingnotifications.service.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +/** + * Route to subscribe for DeadlineCheck subscription + */ +fun Route.subscribeDeadlineCheckRoute() { + post("/subscribe/deadlineCheck") { + val subscription = call.receive() + val deadlineCheckSubscription = DeadlineCheckSubscription(subscription.dataStreamId, subscription.dataStreamRoute, subscription.jurisdiction, + subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) + val result = DeadLineCheckSubscriptionService().run(deadlineCheckSubscription) + call.respond(result) + + } +} +/** + * Route to unsubscribe for DeadlineCheck subscription + */ +fun Route.unsubscribeDeadlineCheck() { + post("/unsubscribe/deadlineCheck") { + val subscription = call.receive() + val result = DeadLineCheckUnSubscriptionService().run(subscription.subscriptionId) + call.respond(result) + } +} + + +/** + * Route to subscribe for upload errors notification subscription + */ +fun Route.subscribeUploadErrorsNotification() { + post("/subscribe/uploadErrorsNotification") { + val subscription = call.receive() + val uploadErrorsNotificationSubscription = UploadErrorsNotificationSubscription(subscription.dataStreamId, subscription.dataStreamRoute, + subscription.jurisdiction, + subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) + val result = UploadErrorsNotificationSubscriptionService().run(uploadErrorsNotificationSubscription) + call.respond(result) + + } +} +/** + * Route to unsubscribe for upload errors subscription notification + */ +fun Route.unsubscribeUploadErrorsNotification() { + post("/unsubscribe/uploadErrorsNotification") { + val subscription = call.receive() + val result = UploadErrorsNotificationUnSubscriptionService().run(subscription.subscriptionId) + call.respond(result) + } +} +/** + * Route to subscribe for top data stream errors notification subscription + */ +fun Route.subscribeDataStreamTopErrorsNotification() { + post("/subscribe/dataStreamTopErrorsNotification") { + val subscription = call.receive() + val dataStreamTopErrorsNotificationSubscription = DataStreamTopErrorsNotificationSubscription(subscription.dataStreamId, subscription.dataStreamRoute, + subscription.jurisdiction, + subscription.daysToRun, subscription.timeToRun, subscription.deliveryReference) + val result = DataStreamTopErrorsNotificationSubscriptionService().run(dataStreamTopErrorsNotificationSubscription) + call.respond(result) + + } +} +/** + * Route to unsubscribe for top data stream errors notification subscription + */ +fun Route.unsubscribesDataStreamTopErrorsNotification() { + post("/unsubscribe/dataStreamTopErrorsNotification") { + val subscription = call.receive() + val result = DataStreamTopErrorsNotificationUnSubscriptionService().run(subscription.subscriptionId) + call.respond(result) + } +} + +/** + Route to subscribe for Temporal Server health check + */ +fun Route.healthCheckRoute() { + get("/health") { + call.respond(TemporalHealthCheckService().getHealth()) + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/activity/NotificationActivity.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/activity/NotificationActivity.kt new file mode 100644 index 00000000..90f13e13 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/activity/NotificationActivity.kt @@ -0,0 +1,28 @@ +package gov.cdc.ocio.processingnotifications.activity + +import io.temporal.activity.ActivityInterface +import io.temporal.activity.ActivityMethod + +/** + * Interface which defines the activity methods + */ +@ActivityInterface +interface NotificationActivities { + @ActivityMethod + fun sendNotification( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + deliveryReference: String + ) + @ActivityMethod + fun sendUploadErrorsNotification( + error:String, + deliveryReference: String + ) + @ActivityMethod + fun sendDataStreamTopErrorsNotification( + error:String, + deliveryReference: String + ) +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt new file mode 100644 index 00000000..2830e2c4 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/activity/NotificationActivityImpl.kt @@ -0,0 +1,52 @@ +package gov.cdc.ocio.processingnotifications.activity + +import gov.cdc.ocio.processingnotifications.email.EmailDispatcher +import mu.KotlinLogging + +/** + * Implementation class for sending email notifications for various notifications + */ +class NotificationActivitiesImpl : NotificationActivities { + private val emailService: EmailDispatcher = EmailDispatcher() + private val logger = KotlinLogging.logger {} + + /** + * Send notification method which uses the email service to send email when an upload fails + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param deliveryReference String + */ + override fun sendNotification( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + deliveryReference: String + ) { + val msg ="Upload deadline over. Failed to get the upload for dataStreamId: $dataStreamId, jurisdiction: $jurisdiction.Sending the notification to $deliveryReference " + logger.info(msg) + emailService.sendEmail("TEST EMAIL- UPLOAD DEADLINE CHECK EXPIRED",msg, deliveryReference) + } + /** + * Send notification method which uses the email service to send email when there are errors in the upload file + * @param error String + * @param deliveryReference String + */ + + override fun sendUploadErrorsNotification(error: String, deliveryReference: String) { + val msg ="Errors while upload. $error" + logger.info(msg) + emailService.sendEmail("TEST EMAIL-UPLOAD ERRORS NOTIFICATION",msg, deliveryReference) + } + + /** + * Send notification method which uses the email service to send email with the digest counts of the top errors in an upload + * @param error String + * @param deliveryReference String + */ + + override fun sendDataStreamTopErrorsNotification(error: String, deliveryReference: String) { + logger.info(error) + emailService.sendEmail("TEST EMAIL-DATA STREAM TOP ERRORS NOTIFICATION",error, deliveryReference) + } +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/cache/InMemoryCache.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/cache/InMemoryCache.kt new file mode 100644 index 00000000..40eec547 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/cache/InMemoryCache.kt @@ -0,0 +1,98 @@ +package gov.cdc.ocio.processingnotifications.cache + +import gov.cdc.ocio.processingnotifications.model.* +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.collections.HashMap + +/** + * This class represents InMemoryCache to maintain state of the data at any given point for + * subscription of rules and subscriber for the rules + */ +object InMemoryCache { + private val readWriteLock = ReentrantReadWriteLock() + /* + Cache to store "SubscriptionId -> Subscriber Info (Email or Url and type of subscription)" + subscriberCache = HashMap() + */ + private val subscriberCache = HashMap>() + + + /** + * If Success, this method updates Two Caches for New Subscription: + * a. First Cache with subscription Rule and respective subscriptionId, + * if it doesn't exist,or it returns existing subscription id. + * b. Second cache is subscriber cache where the subscription id is mapped to emailId of subscriber + * or websocket url with the type of subscription + * + + * @return String + */ + fun updateCacheForSubscription(workflowId:String, baseSubscription: BaseSubscription): WorkflowSubscriptionResult { + // val uuid = generateUniqueSubscriptionId() + try { + + updateSubscriberCache(workflowId, + NotificationSubscriptionResponse(subscriptionId = workflowId, subscription = baseSubscription)) + return WorkflowSubscriptionResult(subscriptionId = workflowId, message = "Successfully subscribed for $workflowId", deliveryReference = baseSubscription.deliveryReference) + } + catch (e: Exception){ + return WorkflowSubscriptionResult(subscriptionId = workflowId, message = e.message, deliveryReference = baseSubscription.deliveryReference) + } + + } + + fun updateCacheForUnSubscription(workflowId:String): WorkflowSubscriptionResult { + try { + + unsubscribeSubscriberCache(workflowId) + return WorkflowSubscriptionResult(subscriptionId = workflowId, message = "Successfully unsubscribed Id = $workflowId", deliveryReference = "") + } + catch (e: Exception){ + return WorkflowSubscriptionResult(subscriptionId = workflowId, message = e.message,"") + } + + } + + /** + * This method adds to the subscriber cache the new entry of subscriptionId to the NotificationSubscriber + * + * @param subscriptionId String + + */ + private fun updateSubscriberCache(subscriptionId: String, + notificationSubscriptionResponse: NotificationSubscriptionResponse) { + //logger.debug("Subscriber added in subscriber cache") + readWriteLock.writeLock().lock() + try { + subscriberCache.putIfAbsent(subscriptionId, mutableListOf()) + subscriberCache[subscriptionId]?.add(notificationSubscriptionResponse) + } finally { + readWriteLock.writeLock().unlock() + } + } + + /** + * This method unsubscribes the subscriber from the subscriber cache + * by removing the Map[subscriptionId, NotificationSubscriber] + * entry from cache but keeps the susbscriptionRule in subscription + * cache for any other existing subscriber needs. + * + * @param subscriptionId String + * @return Boolean + */ + private fun unsubscribeSubscriberCache(subscriptionId: String): Boolean { + if (subscriberCache.containsKey(subscriptionId)) { + val subscribers = subscriberCache[subscriptionId]?.filter { it.subscriptionId == subscriptionId }.orEmpty().toMutableList() + + readWriteLock.writeLock().lock() + try { + subscriberCache.remove(subscriptionId, subscribers) + } finally { + readWriteLock.writeLock().unlock() + } + return true + } else { + throw Exception("Subscription doesn't exist") + } + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/cache/InMemoryCacheService.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/cache/InMemoryCacheService.kt new file mode 100644 index 00000000..75d972fa --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/cache/InMemoryCacheService.kt @@ -0,0 +1,35 @@ +package gov.cdc.ocio.processingnotifications.cache + + +import gov.cdc.ocio.processingnotifications.model.BaseSubscription +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult + +/** + * This class is a service that interacts with InMemory Cache in order to subscribe/unsubscribe users + */ +class InMemoryCacheService { + + /** + * This method creates a hash of the rule keys (dataStreamId, stageName, dataStreamRoute, statusType) + * to use as a key for SubscriptionRuleCache and creates a new or existing subscription (if exist) + * and creates a new entry in subscriberCache for the user with the susbscriptionRuleKey + * + + */ + fun updateSubscriptionPreferences(workflowId:String, baseSubscription: BaseSubscription): WorkflowSubscriptionResult { + try { + return InMemoryCache.updateCacheForSubscription(workflowId,baseSubscription) + } catch (e: Exception) { + throw e + } + } + + fun updateDeadlineCheckUnSubscriptionPreferences(workflowId:String): WorkflowSubscriptionResult { + try { + return InMemoryCache.updateCacheForUnSubscription(workflowId) + } catch (e: Exception) { + throw e + } + } + +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/email/EmailDispatcher.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/email/EmailDispatcher.kt new file mode 100644 index 00000000..bdba51d7 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/email/EmailDispatcher.kt @@ -0,0 +1,96 @@ +package gov.cdc.ocio.processingnotifications.email + +import mu.KotlinLogging +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.Socket +import javax.mail.internet.MimeMessage +import java.util.* +import javax.mail.Message +import javax.mail.Session +import javax.mail.Transport +import javax.mail.internet.InternetAddress + +/** + * The class which dispatches the email using SMTP + */ +class EmailDispatcher { + private val logger = KotlinLogging.logger {} + + /** + * Method to send email which checks the SMTP status and then invokes sendEmail + * @param subject String + * @param body String + * @param toEmail String + */ + fun sendEmail(subject:String,body:String, toEmail:String) { + try{ + + if(!checkSMTPStatusWithoutCredentials()) return + // TODO : Change this into properties + val toEmalId = toEmail + val props = System.getProperties() + props["mail.smtp.host"] = "smtpgw.cdc.gov" + props["mail.smtp.port"] = 25 + val session = Session.getInstance(props, null) + sendEmail(session, toEmalId, subject,body) + } catch(e: Exception) { + logger.error("Unable to send email ${e.message}") + } + + } + /** + * Method to send email + * @param session Session + * @param toEmail String + * @param subject String + * @param body String + */ + + private fun sendEmail(session: Session?, toEmail: String?, subject: String?, body: String?) { + try { + val msg = MimeMessage(session) + val replyToEmail = "donotreply@cdc.gov" + val replyToName = "DoNOtReply (DEX Team)" + //set message headers + msg.addHeader("Content-type", "text/HTML; charset=UTF-8") + msg.addHeader("format", "flowed") + msg.addHeader("Content-Transfer-Encoding", "8bit") + + //TODO - Change the from and replyTo address after the new licensed account is created + // Get the email addresses from the property + msg.setFrom(InternetAddress(replyToEmail, replyToName)) + msg.replyTo = InternetAddress.parse(replyToEmail, false) + msg.setSubject(subject, "UTF-8") + msg.setText(body, "UTF-8") + msg.sentDate = Date() + msg.setRecipients(Message.RecipientType.TO, InternetAddress.parse(toEmail, false)) + Transport.send(msg) + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Method to check the status of the SMTP server + */ + private fun checkSMTPStatusWithoutCredentials(): Boolean { + // This is to get the status from curl statement to see if server is connected + try { + + val smtpServer = "smtpgw.cdc.gov" + val port = 25 + val socket = Socket(smtpServer, port) + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + // Read the server response + val response = reader.readLine() + println("Server response: $response") + // Close the socket + socket.close() + return response !=null + } catch (e: Exception) { + logger.error("Unable to send email. Error is ${e.message} \n. Stack trace : ${e.printStackTrace()}") + } + return false + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/model/ErrorDetail.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/model/ErrorDetail.kt new file mode 100644 index 00000000..0ebd380c --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/model/ErrorDetail.kt @@ -0,0 +1,12 @@ +package gov.cdc.ocio.processingnotifications.model + +/** + * Error Detail class + * @property description String + * @property count Int + */ +data class ErrorDetail( + val description: String, + val count: Int +) + diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/model/NotificationSubscription.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/model/NotificationSubscription.kt new file mode 100644 index 00000000..85e90387 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/model/NotificationSubscription.kt @@ -0,0 +1,11 @@ +package gov.cdc.ocio.processingnotifications.model +/** + * Notification subscription response class + * @param subscriptionId String + * @param subscription BaseSubscription + */ + +class NotificationSubscriptionResponse(val subscriptionId: String, + val subscription: BaseSubscription) + + diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/model/Subscription.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/model/Subscription.kt new file mode 100644 index 00000000..4f372f9b --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/model/Subscription.kt @@ -0,0 +1,113 @@ +package gov.cdc.ocio.processingnotifications.model + +/** + * Base class for subscription + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ +open class BaseSubscription(open val dataStreamId: String, + open val dataStreamRoute: String, + open val jurisdiction: String, + open val daysToRun: List, + open val timeToRun: String, + open val deliveryReference: String) { +} + +/** + * DeadlineCheckSubscription data class which is serialized back and forth when we need to unsubscribe the workflow by the subscriptionId + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ +data class DeadlineCheckSubscription( + override val dataStreamId: String, + override val dataStreamRoute: String, + override val jurisdiction: String, + override val daysToRun: List, + override val timeToRun: String, + override val deliveryReference: String) + : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) + +/** + * DeadlineCheckUnSubscription data class which is serialized back and forth when we need to unsubscribe the workflow by the subscriptionId + * @param subscriptionId String + */ +data class DeadlineCheckUnSubscription(val subscriptionId:String) + +/** + * The resultant class for subscription of email/webhooks + * @param subscriptionId String + * @param message String + * @param deliveryReference String + */ +data class WorkflowSubscriptionResult( + var subscriptionId: String? = null, + var message: String? = "", + var deliveryReference:String +) + +/** + * Upload errors notification Subscription data class which is serialized back and forth from graphQL to this service + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + * daysToRun:["Mon","Tue","Wed"] + * timeToRun:"45 16 * *" - this should be the format + */ +data class UploadErrorsNotificationSubscription( override val dataStreamId: String, + override val dataStreamRoute: String, + override val jurisdiction: String, + override val daysToRun: List, + override val timeToRun: String, + override val deliveryReference: String) : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) + + +/** + * Upload errors notification unSubscription data class which is serialized back and forth from graphQL to this service + * @param subscriptionId String + */ +data class UploadErrorsNotificationUnSubscription(val subscriptionId:String) + +/** Data stream top errors notification subscription class which is serialized back and forth from graphQL to this service +* @param dataStreamId String +* @param dataStreamRoute String +* @param jurisdiction String +* @param daysToRun List +* @param timeToRun String +* @param deliveryReference String +* daysToRun:["Mon","Tue","Wed"] +* timeToRun:"45 16 * *" - this should be the format + */ +data class DataStreamTopErrorsNotificationSubscription( override val dataStreamId: String, + override val dataStreamRoute: String, + override val jurisdiction: String, + override val daysToRun: List, + override val timeToRun: String, + override val deliveryReference: String) : BaseSubscription(dataStreamId, dataStreamRoute ,jurisdiction, daysToRun, timeToRun, deliveryReference ) + +/** + * Data stream errors notification unSubscription data class which is serialized back and forth from graphQL to this service + * @param subscriptionId String + */ +data class DataStreamTopErrorsNotificationUnSubscription(val subscriptionId:String) + +/** + * Get Cron expression based on the daysToRun and timeToRun parameters + * @param daysToRun List + * + */ +fun getCronExpression(daysToRun: List, timeToRun: String):String{ + val daysToRunInStr =daysToRun.joinToString(separator = ",") + val cronExpression= "$timeToRun $daysToRunInStr" + return cronExpression +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt new file mode 100644 index 00000000..0e5fb88f --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationSubscriptionService.kt @@ -0,0 +1,51 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.DataStreamTopErrorsNotificationSubscription +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import gov.cdc.ocio.processingnotifications.workflow.DataStreamTopErrorsNotificationWorkflowImpl +import gov.cdc.ocio.processingnotifications.workflow.DataStreamTopErrorsNotificationWorkflow +import io.temporal.client.WorkflowClient +import mu.KotlinLogging + +/** + * The main class which sets up and subscribes the workflow execution + * for digest counts and the frequency with which each of the top 5 errors occur + */ + +class DataStreamTopErrorsNotificationSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine:WorkflowEngine = WorkflowEngine() + private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() + + /** + * The main method which gets called from the route which executes and kicks off the + * workflow execution for digest counts and the frequency with which each of the top 5 errors occur + * @param subscription DataStreamTopErrorsNotificationSubscription + */ + fun run(subscription: DataStreamTopErrorsNotificationSubscription): + WorkflowSubscriptionResult { + try { + val dataStreamId = subscription.dataStreamId + val dataStreamRoute = subscription.dataStreamRoute + val jurisdiction = subscription.jurisdiction + val daysToRun = subscription.daysToRun + val timeToRun = subscription.timeToRun + val deliveryReference= subscription.deliveryReference + val taskQueue = "dataStreamTopErrorsNotificationTaskQueue" + + val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, + DataStreamTopErrorsNotificationWorkflowImpl::class.java ,notificationActivitiesImpl, DataStreamTopErrorsNotificationWorkflow::class.java) + + val execution = WorkflowClient.start(workflow::checkDataStreamTopErrorsAndNotify, dataStreamId, dataStreamRoute, jurisdiction,daysToRun, timeToRun, deliveryReference) + return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) + } + catch (e:Exception){ + logger.error("Error occurred while subscribing for digest counts and top errors : ${e.message}") + } + throw Exception("Error occurred while subscribing for the workflow engine for digest counts and top errors") + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt new file mode 100644 index 00000000..95fb3d23 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DataStreamTopErrorsNotificationUnSubscriptionService.kt @@ -0,0 +1,36 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import mu.KotlinLogging + +/** + * The main class which subscribes the workflow execution + * for digest counts and top errors and its frequency for each upload + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + + */ +class DataStreamTopErrorsNotificationUnSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine: WorkflowEngine = WorkflowEngine() + + /** + * The main function which is used to cancel the workflow based on the workflowID + * @param subscriptionId String + * @return WorkflowSubscriptionResult + */ + fun run(subscriptionId: String): + WorkflowSubscriptionResult { + try { + workflowEngine.cancelWorkflow(subscriptionId) + return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) + } + catch (e:Exception){ + logger.error("Error occurred while unsubscribing and canceling the workflow for digest counts and top errors with workflowId $subscriptionId: ${e.message}") + } + throw Exception("Error occurred while canceling the workflow engine for digest counts and top for workflow Id $subscriptionId") + } +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt new file mode 100644 index 00000000..ec3b9b85 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DeadLineCheckSubscriptionService.kt @@ -0,0 +1,52 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.DeadlineCheckSubscription +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import gov.cdc.ocio.processingnotifications.workflow.NotificationWorkflow +import gov.cdc.ocio.processingnotifications.workflow.NotificationWorkflowImpl +import io.temporal.client.WorkflowClient +import mu.KotlinLogging + +/** + * The main class which subscribes the workflow execution + * for upload deadline check + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + * @property notificationActivitiesImpl NotificationActivitiesImpl + */ +class DeadLineCheckSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine: WorkflowEngine = WorkflowEngine() + private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() + + /** + * The main method which executes workflow for uploadDeadline check + * @param subscription DeadlineCheckSubscription + * @return WorkflowSubscriptionResult + */ + fun run(subscription: DeadlineCheckSubscription): + WorkflowSubscriptionResult { + try { + val dataStreamId = subscription.dataStreamId + val dataStreamRoute = subscription.dataStreamRoute + val jurisdiction = subscription.jurisdiction + val daysToRun = subscription.daysToRun + val timeToRun = subscription.timeToRun + val deliveryReference= subscription.deliveryReference + val taskQueue = "notificationTaskQueue" + + val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, + NotificationWorkflowImpl::class.java ,notificationActivitiesImpl, NotificationWorkflow::class.java) + val execution = WorkflowClient.start(workflow::checkUploadAndNotify, jurisdiction, dataStreamId, dataStreamRoute, daysToRun, timeToRun, deliveryReference) + return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) + } + catch (e:Exception){ + logger.error("Error occurred while subscribing workflow for upload deadline: ${e.message}") + } + throw Exception("Error occurred while executing workflow engine to subscribe for upload deadline") + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt new file mode 100644 index 00000000..f07bd87f --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/DeadLineCheckUnSubscriptionService.kt @@ -0,0 +1,35 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import mu.KotlinLogging + +/** + * The main class which unsubscribes the workflow execution + * for upload errors + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + */ +class DeadLineCheckUnSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine: WorkflowEngine = WorkflowEngine() + + /** + * The main method which cancels the workflow based on the workflow Id + * @param subscriptionId String + */ + + fun run(subscriptionId: String): + WorkflowSubscriptionResult { + try { + workflowEngine.cancelWorkflow(subscriptionId) + return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) + } + catch (e:Exception){ + logger.error("Error occurred while unsubscribing and canceling the workflow for upload deadline with workflowId $subscriptionId: ${e.message}") + } + throw Exception("Error occurred while canceling the workflow execution engine for upload deadline check for workflow Id $subscriptionId") + } +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt new file mode 100644 index 00000000..35695ee2 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/UploadErrorsNotificationSubscriptionService.kt @@ -0,0 +1,54 @@ +package gov.cdc.ocio.processingnotifications.service + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivitiesImpl +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.UploadErrorsNotificationSubscription +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import gov.cdc.ocio.processingnotifications.workflow.UploadErrorsNotificationWorkflow +import gov.cdc.ocio.processingnotifications.workflow.UploadErrorsNotificationWorkflowImpl + +import io.temporal.client.WorkflowClient +import mu.KotlinLogging + +/** + * The main class which subscribes the workflow execution + * for upload errors + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + * @property notificationActivitiesImpl NotificationActivitiesImpl + */ +class UploadErrorsNotificationSubscriptionService { + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine:WorkflowEngine = WorkflowEngine() + private val notificationActivitiesImpl:NotificationActivitiesImpl = NotificationActivitiesImpl() + private val logger = KotlinLogging.logger {} + /** + * The main method which executes workflow engine to check for upload errors and notify + * @param subscription UploadErrorsNotificationSubscription + * @return WorkflowSubscriptionResult + */ + + fun run(subscription: UploadErrorsNotificationSubscription): + WorkflowSubscriptionResult { + try { + val dataStreamId = subscription.dataStreamId + val dataStreamRoute = subscription.dataStreamRoute + val jurisdiction = subscription.jurisdiction + val daysToRun = subscription.daysToRun + val timeToRun = subscription.timeToRun + val deliveryReference= subscription.deliveryReference + val taskQueue = "uploadErrorsNotificationTaskQueue" + + val workflow = workflowEngine.setupWorkflow(taskQueue,daysToRun,timeToRun, + UploadErrorsNotificationWorkflowImpl::class.java ,notificationActivitiesImpl, UploadErrorsNotificationWorkflow::class.java) + + val execution = WorkflowClient.start(workflow::checkUploadErrorsAndNotify, dataStreamId, dataStreamRoute, jurisdiction,daysToRun, timeToRun, deliveryReference) + return cacheService.updateSubscriptionPreferences(execution.workflowId,subscription) + } + catch (e:Exception){ + logger.error("Error occurred while checking for errors in upload: ${e.message}") + } + throw Exception("Error occurred while executing workflow engine to subscribe for errors in upload") + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt new file mode 100644 index 00000000..0eb2b142 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/service/UploadErrorsNotificationUnSubscriptionService.kt @@ -0,0 +1,35 @@ +package gov.cdc.ocio.processingnotifications.service + + +import gov.cdc.ocio.processingnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingnotifications.model.WorkflowSubscriptionResult +import gov.cdc.ocio.processingnotifications.temporal.WorkflowEngine +import mu.KotlinLogging + +/** + * The main class which unsubscribes the workflow execution + * for upload errors + * @property cacheService IMemoryCacheService + * @property workflowEngine WorkflowEngine + */ +class UploadErrorsNotificationUnSubscriptionService { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + private val workflowEngine: WorkflowEngine = WorkflowEngine() + + /** + * The main method which cancels a workflow based on the workflow Id + * @param subscriptionId String + */ + fun run(subscriptionId: String): + WorkflowSubscriptionResult { + try { + workflowEngine.cancelWorkflow(subscriptionId) + return cacheService.updateDeadlineCheckUnSubscriptionPreferences(subscriptionId) + } + catch (e:Exception){ + logger.error("Error occurred while checking for upload deadline: ${e.message}") + } + throw Exception("Error occurred while canceling the execution of workflow engine to cancel workflow for workflow Id $subscriptionId") + } +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/temporal/WorkflowEngine.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/temporal/WorkflowEngine.kt new file mode 100644 index 00000000..7bb445eb --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/temporal/WorkflowEngine.kt @@ -0,0 +1,74 @@ +package gov.cdc.ocio.processingnotifications.temporal + +import gov.cdc.ocio.processingnotifications.model.getCronExpression +import io.temporal.client.WorkflowClient +import io.temporal.client.WorkflowOptions +import io.temporal.client.WorkflowStub +import io.temporal.serviceclient.WorkflowServiceStubs +import io.temporal.worker.WorkerFactory +import mu.KotlinLogging + +/** + * Workflow engine class which creates a grpC client instance of the temporal server + * using which it registers the workflow and the activity implementation + * Also,using the workflow options the client creates a new workflow stub + * Note : CRON expression is used to set the schedule + */ +class WorkflowEngine { + private val logger = KotlinLogging.logger {} + + fun setupWorkflow( + taskName:String, + daysToRun:List, timeToRun:String, + workflowImpl: Class, activitiesImpl:T2, workflowImplInterface:Class):T3{ + try { + val service = WorkflowServiceStubs.newLocalServiceStubs() + val client = WorkflowClient.newInstance(service) + val factory = WorkerFactory.newInstance(client) + + val worker = factory.newWorker(taskName) + worker.registerWorkflowImplementationTypes(workflowImpl) + worker.registerActivitiesImplementations(activitiesImpl) + logger.info("Workflow and Activity successfully registered") + factory.start() + + val workflowOptions = WorkflowOptions.newBuilder() + .setTaskQueue(taskName) + .setCronSchedule(getCronExpression(daysToRun,timeToRun)) // Cron schedule: 15 5 * * 1-5 - Every week day at 5:15a + .build() + + val workflow = client.newWorkflowStub( + workflowImplInterface, + workflowOptions + ) + logger.info("Workflow successfully started") + return workflow + } + catch (ex: Exception){ + logger.error("Error while creating workflow: ${ex.message}") + } + throw Exception("WorkflowEngine instantiation failed. Please try again") + } + + /** + * Cancel the workflow based on the workflowId + * @param workflowId String + */ + fun cancelWorkflow(workflowId:String){ + try { + val service = WorkflowServiceStubs.newLocalServiceStubs() + val client = WorkflowClient.newInstance(service) + + // Retrieve the workflow by its ID + val workflow: WorkflowStub = client.newUntypedWorkflowStub(workflowId) + // Cancel the workflow + workflow.cancel() + logger.info("WorkflowID:$workflowId successfully cancelled") + } + catch (ex: Exception){ + logger.error("Error while canceling the workflow : ${ex.message}") + } + throw Exception("Workflow cancellation failed. Please try again") + + } +} \ No newline at end of file diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt new file mode 100644 index 00000000..4e023846 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflow.kt @@ -0,0 +1,22 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import io.temporal.workflow.WorkflowInterface +import io.temporal.workflow.WorkflowMethod + +/** + * The interface which defines the digest counts and top errors during an upload and its frequency + */ +@WorkflowInterface +interface DataStreamTopErrorsNotificationWorkflow { + + @WorkflowMethod + fun checkDataStreamTopErrorsAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) + +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt new file mode 100644 index 00000000..38f45a20 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/DataStreamTopErrorsNotificationWorkflowImpl.kt @@ -0,0 +1,96 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivities +import gov.cdc.ocio.processingnotifications.model.ErrorDetail +import io.temporal.activity.ActivityOptions +import io.temporal.common.RetryOptions +import io.temporal.workflow.Workflow +import mu.KotlinLogging +import java.time.Duration + +/** + * The implementation class which determines the digest counts and top errors during an upload and its frequency + * @property activities T + */ +class DataStreamTopErrorsNotificationWorkflowImpl : DataStreamTopErrorsNotificationWorkflow { + private val logger = KotlinLogging.logger {} + private val activities = Workflow.newActivityStub( + NotificationActivities::class.java, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(3) // Set retry options if needed + .build() + ) + .build() + ) + + //TODO : This should come from db in real application + val errorList = listOf( + "DataStreamId missing", + "DataStreamRoute missing", + "Jurisdiction missing", + "DataStreamId missing", + "Jurisdiction missing", + "DataStreamRoute missing", + "DataStreamRoute missing", + "DataStreamId missing", + "DataStreamId missing", + "DataStreamRoute missing", + "DataStreamId missing", + "DataStreamId missing", + ) + + /** + * The function which determines the digest counts and top errors during an upload and its frequency + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ + override fun checkDataStreamTopErrorsAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) { + try { + // Logic to check if the upload occurred*/ + val (totalCount, topErrors) = getTopErrors(errorList) + val errors = topErrors.filter{it.description.isNotEmpty()}.joinToString() + if (topErrors.isNotEmpty()) { + activities.sendDataStreamTopErrorsNotification("There are $totalCount errors \n These are the top errors : \n $errors \n",deliveryReference) + } + } catch (e: Exception) { + logger.error("Error occurred while checking for counts and top errors and frequency in an upload: ${e.message}") + } + } + + /** + * Function which actually does find the counts and the erroneous fields and its frequency + * @param errors List + * @return Pair + */ + + private fun getTopErrors(errors: List): Pair> { + // Group the errors by description and count their occurrences + val errorCounts = errors.groupingBy { it }.eachCount() + + // Convert the grouped data into a list of ErrorDetail objects + val errorDetails = errorCounts.map { (description, count) -> + ErrorDetail(description, count) + } + // Sort the errors by their count in descending order and take the top 5 + val topErrors = errorDetails.sortedByDescending { it.count }.take(5) + + // Return the total count of errors and the top 5 errors + return Pair(errors.size, topErrors) + } + +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt new file mode 100644 index 00000000..19420635 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/NotificationWorkflow.kt @@ -0,0 +1,21 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import io.temporal.workflow.WorkflowInterface +import io.temporal.workflow.WorkflowMethod + +/** + * The interface which define the upload error and notify method + */ +@WorkflowInterface +interface NotificationWorkflow { + @WorkflowMethod + fun checkUploadAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) + +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt new file mode 100644 index 00000000..70cf9faf --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/NotificationWorkflowImpl.kt @@ -0,0 +1,69 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivities +import io.temporal.activity.ActivityOptions +import io.temporal.common.RetryOptions +import io.temporal.workflow.Workflow +import mu.KotlinLogging +import java.time.Duration + +/** + * The implementation class for notifying if an upload has not occurred within a specified time + * @property activities T + */ +class NotificationWorkflowImpl : NotificationWorkflow { + private val logger = KotlinLogging.logger {} + private val activities = Workflow.newActivityStub( + NotificationActivities::class.java, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(3) // Set retry options if needed + .build() + ) + .build() + ) + /** + * The function which gets invoked by the temporal WF engine which checks whether upload has occurred within a specified time or not + * invokes the activity, if there are errors + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ + override fun checkUploadAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) { + + try { + // Logic to check if the upload occurred*/ + val uploadOccurred = checkUpload(dataStreamId, jurisdiction) + if (!uploadOccurred) { + activities.sendNotification(dataStreamId, dataStreamRoute, jurisdiction, deliveryReference) + } + } catch (e: Exception) { + logger.error("Error occurred while checking for upload deadline: ${e.message}") + } + + } + /** + * The actual function which checks for whether the upload has occurred or not within a specified time + * @param dataStreamId String + * @param jurisdiction String + */ + private fun checkUpload(dataStreamId: String, jurisdiction: String): Boolean { + // add check logic here + return false + } + + +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt new file mode 100644 index 00000000..eb0eadff --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflow.kt @@ -0,0 +1,22 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import io.temporal.workflow.WorkflowInterface +import io.temporal.workflow.WorkflowMethod + +/** + * Interface that defines the upload errors and notify + */ +@WorkflowInterface +interface UploadErrorsNotificationWorkflow { + + @WorkflowMethod + fun checkUploadErrorsAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) + +} diff --git a/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt new file mode 100644 index 00000000..cc2782ce --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/kotlin/workflow/UploadErrorsNotificationWorkflowImpl.kt @@ -0,0 +1,77 @@ +package gov.cdc.ocio.processingnotifications.workflow + +import gov.cdc.ocio.processingnotifications.activity.NotificationActivities +import io.temporal.activity.ActivityOptions +import io.temporal.common.RetryOptions +import io.temporal.workflow.Workflow +import mu.KotlinLogging +import java.time.Duration + +/** + * The implementation class for errors on missing fields from a upload + * @property activities T + */ +class UploadErrorsNotificationWorkflowImpl : UploadErrorsNotificationWorkflow { + private val logger = KotlinLogging.logger {} + private val activities = Workflow.newActivityStub( + NotificationActivities::class.java, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) // Set the start-to-close timeout + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) // Set the schedule-to-close timeout + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(3) // Set retry options if needed + .build() + ) + .build() + ) +/** + * The function which gets invoked by the temporal WF engine and which checks for the errors in the upload and + * invokes the activity, if there are errors + * @param dataStreamId String + * @param dataStreamRoute String + * @param jurisdiction String + * @param daysToRun List + * @param timeToRun String + * @param deliveryReference String + */ + override fun checkUploadErrorsAndNotify( + dataStreamId: String, + dataStreamRoute: String, + jurisdiction: String, + daysToRun: List, + timeToRun: String, + deliveryReference: String + ) { + try { + // Logic to check if the upload occurred*/ + val error = checkUploadErrors(dataStreamId, dataStreamRoute, jurisdiction) + if (error.isNotEmpty()) { + activities.sendUploadErrorsNotification(error,deliveryReference) + } + } catch (e: Exception) { + logger.error("Error occurred while checking for errors in upload. Errors are : ${e.message}") + } + } + + /** + * Thw actual function which checks for errors in the fields used for upload + * @param dataStreamId String + * @param dataStreamRoute String + * * @param jurisdiction String + */ + + private fun checkUploadErrors(dataStreamId: String, dataStreamRoute: String, jurisdiction: String): String { + var error = "" + if (dataStreamId.isEmpty()) { + error = "DataStreamId is missing from the upload." + } + if (dataStreamRoute.isEmpty()) { + error += "DataStreamRoute is missing from the upload." + } + if (jurisdiction.isEmpty()) { + error += "Jurisdiction is missing from the upload" + } + return error + } +} diff --git a/pstatus-notifications-workflow-ktor/src/main/resources/application.conf b/pstatus-notifications-workflow-ktor/src/main/resources/application.conf new file mode 100644 index 00000000..b5464773 --- /dev/null +++ b/pstatus-notifications-workflow-ktor/src/main/resources/application.conf @@ -0,0 +1,13 @@ +ktor { + deployment { + port = 8081 + host = 0.0.0.0 + } + + application { + modules = [gov.cdc.ocio.processingnotifications.ApplicationKt.module] + } + + version = "0.0.1" + } + From 8ba223d5499fc6001e85b5d5f490e1bc1dd96361 Mon Sep 17 00:00:00 2001 From: Manu Govind Kesava Pillai <99700177+manu-govind@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:41:42 -0400 Subject: [PATCH 15/17] Delete pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt Should not be in this commit. This is a graphql file --- .../mutations/response/Response.kt | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt deleted file mode 100644 index a73b2bf9..00000000 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/response/Response.kt +++ /dev/null @@ -1,42 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.mutations.response - -import gov.cdc.ocio.processingstatusapi.mutations.models.NotificationSubscriptionResult -import io.ktor.client.call.* -import io.ktor.client.statement.* -import io.ktor.http.* - -object SubscriptionResponse{ - - /** - * Function to process the http response coming from notifications service - * @param response HttpResponse - */ - @JvmStatic - suspend fun ProcessNotificationResponse(response: HttpResponse): NotificationSubscriptionResult { - if (response.status == HttpStatusCode.OK) { - return response.body() - } else { - throw Exception("Notification service is unavailable. Status:${response.status}") - } - } - - @JvmStatic - @Throws(Exception::class) - /** - * Function to process the http response codes and throw exception accordingly - * @param url String - * @param e Exception - * @param subscriptionId String? - */ - fun ProcessErrorCodes(url: String, e: Exception, subscriptionId: String?) { - val error = e.message!!.substringAfter("Status:").substringBefore(" ") - when (error) { - "500" -> throw Exception("Subscription with subscriptionId = ${subscriptionId} does not exist in the cache") - "400" -> throw Exception("Bad Request: Please check the request and retry") - "401" -> throw Exception("Unauthorized access to notifications service") - "403" -> throw Exception("Access to notifications service is forbidden") - "404" -> throw Exception("${url} not found") - else -> throw Exception(e.message) - } - } -} From 5ab70e504c8c9f8248ed3eb77f1ba57e4cfad45d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 09:55:49 -0500 Subject: [PATCH 16/17] Bump express from 4.19.2 to 4.21.0 in /spikes/graphql/apollo-server (#191) Bumps [express](https://github.com/expressjs/express) from 4.19.2 to 4.21.0. - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md) - [Commits](https://github.com/expressjs/express/compare/4.19.2...4.21.0) --- updated-dependencies: - dependency-name: express dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../graphql/apollo-server/package-lock.json | 100 ++++++++++-------- 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/spikes/graphql/apollo-server/package-lock.json b/spikes/graphql/apollo-server/package-lock.json index 3b042d51..e2a3a135 100644 --- a/spikes/graphql/apollo-server/package-lock.json +++ b/spikes/graphql/apollo-server/package-lock.json @@ -917,9 +917,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -929,7 +929,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -1127,9 +1127,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -1167,36 +1167,36 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -1214,12 +1214,12 @@ "peer": true }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -1593,9 +1593,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/methods": { "version": "1.1.2", @@ -1681,9 +1684,12 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1708,9 +1714,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/priorityqueuejs": { "version": "1.0.0", @@ -1731,11 +1737,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -1822,9 +1828,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -1844,20 +1850,28 @@ "node": ">= 0.8.0" } }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" From 3bdee7b5a74d4a96277ba8a58dd8f7ad39d79808 Mon Sep 17 00:00:00 2001 From: Madhavi Tammineni Date: Tue, 24 Sep 2024 15:06:58 -0400 Subject: [PATCH 17/17] Create report mutation (#186) * Send Date with milliseconds for getUploads. Sort the items in the Page after the page data is returned. * Create Report Mutation * Promoting event-sink-reader from spikes into development * Add test configuration for running the unit tests from gradle * Update graphql-service-ci.yml * Update graphql-service-ci.yml * Add source directories to gradle build * Update graphql-service-ci.yml * Handle different types of content. Add class headers. Add function headers. * Add description headers. Create enums for the different content types(JSON, APPLICATION/JSON, BASE64), upload actions(CREATE, REPLACE) * Remove event-sink-reader from this commit * Updated source sets for tests * Update graphql-service-ci.yml * Code refactoring * Code refactoring * Exception handling * Revert the changes for exception handling at the top level. * Refactor Report Mutations --------- Co-authored-by: uek3-cdc <146258842+uek3-cdc@users.noreply.github.com> --- .github/workflows/graphql-service-ci.yml | 2 +- pstatus-graphql-ktor/build.gradle | 35 +- .../ocio/processingstatusapi/Application.kt | 10 +- .../models/ReportContentType.kt | 17 + .../models/reports/inputs/DataInput.kt | 12 + .../models/reports/inputs/IssueInput.kt | 12 + .../reports/inputs/MessageMetadataInput.kt | 19 + .../models/reports/inputs/ReportInput.kt | 55 +++ .../models/reports/inputs/StageInfoInput.kt | 29 ++ .../models/reports/inputs/TagInput.kt | 12 + .../mutations/ReportMutation.kt | 51 ++ .../processingstatusapi/plugins/GraphQL.kt | 7 +- .../services/ReportMutationService.kt | 436 ++++++++++++++++++ .../loaders/UploadStatusLoaderTest.kt | 13 +- 14 files changed, 691 insertions(+), 19 deletions(-) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportContentType.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/DataInput.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/IssueInput.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/MessageMetadataInput.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/ReportInput.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/StageInfoInput.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/TagInput.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/ReportMutation.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/services/ReportMutationService.kt diff --git a/.github/workflows/graphql-service-ci.yml b/.github/workflows/graphql-service-ci.yml index 76e0975a..bc53f1b3 100644 --- a/.github/workflows/graphql-service-ci.yml +++ b/.github/workflows/graphql-service-ci.yml @@ -18,4 +18,4 @@ jobs: steps: - uses: actions/checkout@v4 - name: Run Gradle Test - run: ./gradlew test \ No newline at end of file + run: ./gradlew test diff --git a/pstatus-graphql-ktor/build.gradle b/pstatus-graphql-ktor/build.gradle index c104b58a..2ee4bf0f 100644 --- a/pstatus-graphql-ktor/build.gradle +++ b/pstatus-graphql-ktor/build.gradle @@ -73,10 +73,6 @@ dependencies { testImplementation "io.ktor:ktor-server-tests-jvm:$ktor_version" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" -// testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' -// testImplementation 'org.mockito:mockito-core:4.6.1' -// testImplementation 'org.mockito:mockito-junit-jupiter:4.6.1' - testImplementation "org.junit.jupiter:junit-jupiter-api:5.8.1" testImplementation "org.junit.jupiter:junit-jupiter-engine:5.8.1" testImplementation "org.mockito:mockito-core:4.5.1" @@ -108,4 +104,35 @@ jib { } } +test { + + // Discover and execute JUnit Platform-based (JUnit 5, JUnit Jupiter) tests + // JUnit 5 has the ability to execute JUnit 4 tests as well + useJUnitPlatform() + + //Change this to "true" if we want to execute unit tests + systemProperty("isTestEnvironment", "false") + + // Set the test classpath, if required +} + +sourceSets { + main { + java { + srcDir 'src/kotlin' + } + resources { + srcDir 'src/resources' + } + } + test { + java { + srcDir 'src/kotlin' + } + resources { + srcDir 'src/resources' + } + } +} + diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt index 6d8ee153..fa298d38 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt @@ -26,6 +26,7 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp // Create a CosmosDB config that can be dependency injected (for health checks) single(createdAtStart = true) { CosmosConfiguration(uri, authKey) } } + return modules(listOf(cosmosModule)) } @@ -34,15 +35,20 @@ fun main(args: Array) { } fun Application.module() { - graphQLModule() - configureRouting() + install(Koin) { loadKoinModules(environment) } + graphQLModule() + configureRouting() + install(ContentNegotiation) { jackson() } + + + // See https://opensource.expediagroup.com/graphql-kotlin/docs/schema-generator/writing-schemas/scalars RuntimeWiring.newRuntimeWiring().scalar(ExtendedScalars.Date) } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportContentType.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportContentType.kt new file mode 100644 index 00000000..fdfb48e7 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportContentType.kt @@ -0,0 +1,17 @@ +package gov.cdc.ocio.processingstatusapi.models + +/** + * Enumeration of the possible sort orders for queries. + */ +enum class ReportContentType (val type: String){ + JSON("application/json"), + JSON_SHORT("json"), + BASE64("base64"); + + companion object { + fun fromString(type: String): ReportContentType { + return values().find { it.type.equals(type, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported content type: $type") + } + } +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/DataInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/DataInput.kt new file mode 100644 index 00000000..794ac068 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/DataInput.kt @@ -0,0 +1,12 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription + +@GraphQLDescription("Input type for tags") +data class DataInput( + @GraphQLDescription("Tag key") + val key: String, + + @GraphQLDescription("Tag value") + val value: String +) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/IssueInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/IssueInput.kt new file mode 100644 index 00000000..bfaa435b --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/IssueInput.kt @@ -0,0 +1,12 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription + +@GraphQLDescription("Input type for issues") +data class IssueInput( + @GraphQLDescription("Issue code") + val code: String? = null, + + @GraphQLDescription("Issue description") + val description: String? = null +) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/MessageMetadataInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/MessageMetadataInput.kt new file mode 100644 index 00000000..a4ebf98a --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/MessageMetadataInput.kt @@ -0,0 +1,19 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import gov.cdc.ocio.processingstatusapi.models.submission.Aggregation + +@GraphQLDescription("Input type for message metadata") +data class MessageMetadataInput( + @GraphQLDescription("Unique Identifier for that message") + val messageUUID: String? = null, + + @GraphQLDescription("MessageHash value") + val messageHash: String? = null, + + @GraphQLDescription("Single or Batch message") + val aggregation: Aggregation? = null, + + @GraphQLDescription("Message Index") + val messageIndex: Int? = null +) \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/ReportInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/ReportInput.kt new file mode 100644 index 00000000..24facc27 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/ReportInput.kt @@ -0,0 +1,55 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import java.time.OffsetDateTime + +@GraphQLDescription("Input type for creating or updating a report") +data class ReportInput( + @GraphQLDescription("Identifier of the report recorded by the database") + val id: String? = null, + + @GraphQLDescription("Upload identifier this report belongs to") + val uploadId: String? = null, + + @GraphQLDescription("Unique report identifier") + val reportId: String? = null, + + @GraphQLDescription("Data stream ID") + val dataStreamId: String? = null, + + @GraphQLDescription("Data stream route") + val dataStreamRoute: String? = null, + + @GraphQLDescription("Date/time of when the upload was first ingested into the data-exchange") + val dexIngestDateTime: OffsetDateTime? = null, + + @GraphQLDescription("Message metadata") + val messageMetadata: MessageMetadataInput? = null, + + @GraphQLDescription("Stage info") + val stageInfo: StageInfoInput? = null, + + @GraphQLDescription("Tags") + val tags: List? = null, + + @GraphQLDescription("Data") + val data: List? = null, + + @GraphQLDescription("Indicates the content type of the content; e.g. JSON, XML") + val contentType: String? = null, + + @GraphQLDescription("Jurisdiction report belongs to; set to null if not applicable") + val jurisdiction: String? = null, + + @GraphQLDescription("Sender ID this report belongs to; set to null if not applicable") + val senderId: String? = null, + + @GraphQLDescription("Data Producer ID stated in the report; set to null if not applicable") + val dataProducerId: String? = null, + + @GraphQLDescription("Content of the report. If the report is JSON then the content will be a map, otherwise, it will be a string") + var content : String? = null, + + @GraphQLDescription("Timestamp when the report was recorded in the database") + val timestamp: OffsetDateTime? = null +) \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/StageInfoInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/StageInfoInput.kt new file mode 100644 index 00000000..d18e93cb --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/StageInfoInput.kt @@ -0,0 +1,29 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import gov.cdc.ocio.processingstatusapi.models.submission.Status +import java.time.OffsetDateTime + +@GraphQLDescription("Input type for stage info") +data class StageInfoInput( + @GraphQLDescription("Service") + val service: String? = null, + + @GraphQLDescription("Stage name a.k.a action") + val action: String? = null, + + @GraphQLDescription("Version") + val version: String? = null, + + @GraphQLDescription("Status- SUCCESS OR FAILURE") + val status: Status? = null, + + @GraphQLDescription("Issues array") + val issues: List? = null, + + @GraphQLDescription("Start processing time") + val startProcessingTime: OffsetDateTime? = null, + + @GraphQLDescription("End processing time") + val endProcessingTime: OffsetDateTime? = null +) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/TagInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/TagInput.kt new file mode 100644 index 00000000..b62caa5a --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/TagInput.kt @@ -0,0 +1,12 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription + +@GraphQLDescription("Input type for tags") +data class TagInput( + @GraphQLDescription("Tag key") + val key: String, + + @GraphQLDescription("Tag value") + val value: String +) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/ReportMutation.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/ReportMutation.kt new file mode 100644 index 00000000..547a14d3 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/ReportMutation.kt @@ -0,0 +1,51 @@ +package gov.cdc.ocio.processingstatusapi.mutations + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import com.expediagroup.graphql.server.operations.Mutation +import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException +import gov.cdc.ocio.processingstatusapi.exceptions.ContentException +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.ReportInput +import gov.cdc.ocio.processingstatusapi.services.ReportMutationService + +/** + * ReportMutationService class handles GraphQL mutations for report creation and replacement. + * + * This service provides a single mutation operation to either create a new report or replace an + * existing report in the system. It utilizes the ReportMutation class to perform the actual + * upsert operation based on the provided input and action. + * + * Annotations: + * - GraphQLDescription: Provides descriptions for the class and its methods for GraphQL documentation. + * + * Dependencies: + * - ReportInput: Represents the input model for report data. + */ +@GraphQLDescription("A Mutation Service to either create a new report or replace an existing report") +class ReportMutation() : Mutation { + + /** + * Upserts a report based on the provided input and action. + * + * This function serves as a GraphQL mutation to create a new report or replace an existing one. + * It delegates the actual upsert logic to the ReportMutation class. + * + * @param input The ReportInput containing details of the report to be created or replaced. + * @param action A string specifying the action to perform: "create" or "replace". + * @return The result of the upsert operation, handled by the ReportMutation class. + */ + @GraphQLDescription("Create upload") + @Suppress("unused") + @Throws(BadRequestException::class, ContentException::class, Exception::class) + fun upsertReport( + @GraphQLDescription( + "*Report Input* to be created or updated:\n" + ) + input: ReportInput, + @GraphQLDescription( + "*Action*: Can be one of the following values\n" + + "`create`: Create new report\n" + + "`replace`: Replace existing report\n" + ) + action: String + ) = ReportMutationService().upsertReport(input, action) +} diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt index b8279707..53d911d3 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt @@ -7,6 +7,7 @@ import com.expediagroup.graphql.server.ktor.* import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDataLoader import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDeadLetterDataLoader import gov.cdc.ocio.processingstatusapi.mutations.NotificationsMutationService +import gov.cdc.ocio.processingstatusapi.mutations.ReportMutation import gov.cdc.ocio.processingstatusapi.queries.* import io.ktor.http.* import io.ktor.serialization.jackson.* @@ -80,6 +81,9 @@ fun Application.graphQLModule() { // install(CORS) { // anyHost() // } + +// val reportMutation by inject() // Inject ReportMutation from Koin + install(GraphQL) { schema { packages = listOf("gov.cdc.ocio.processingstatusapi") @@ -92,7 +96,8 @@ fun Application.graphQLModule() { ) mutations= listOf( - NotificationsMutationService() + NotificationsMutationService(), + ReportMutation() ) // subscriptions = listOf( // ErrorSubscriptionService() diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/services/ReportMutationService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/services/ReportMutationService.kt new file mode 100644 index 00000000..31efca94 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/services/ReportMutationService.kt @@ -0,0 +1,436 @@ +package gov.cdc.ocio.processingstatusapi.services + +import com.azure.cosmos.models.CosmosItemRequestOptions +import com.azure.cosmos.models.CosmosItemResponse +import com.azure.cosmos.models.PartitionKey +import com.azure.json.implementation.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException +import gov.cdc.ocio.processingstatusapi.exceptions.ContentException +import gov.cdc.ocio.processingstatusapi.loaders.CosmosLoader +import gov.cdc.ocio.processingstatusapi.models.ReportContentType +import gov.cdc.ocio.processingstatusapi.models.Report +import gov.cdc.ocio.processingstatusapi.models.reports.* +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.IssueInput +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.MessageMetadataInput +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.ReportInput +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.StageInfoInput +import gov.cdc.ocio.processingstatusapi.models.submission.Issue +import gov.cdc.ocio.processingstatusapi.models.submission.Level +import gov.cdc.ocio.processingstatusapi.models.submission.MessageMetadata +import gov.cdc.ocio.processingstatusapi.models.submission.StageInfo +import java.util.* + +/** + * ReportMutation class handles the creation and replacement of reports in a Cosmos DB. + * + * This class extends the CosmosLoader and provides methods to upsert reports based on specified actions. + * + * Key functionalities include: + * - `upsertReport`: A public method to create or replace a report, validating the input action. + * - `performUpsert`: A private method that contains the core logic for creating or replacing reports. + * - `mapInputToReport`: Transforms a ReportInput object into a Report object. + * - Various mapping methods to convert input data into the appropriate report structures, including + * message metadata, stage info, and issues. + * - `parseContent`: A method to handle different content types (JSON and Base64) and convert them + * into usable formats. + * + */ +class ReportMutationService : CosmosLoader() { + + private val objectMapper = ObjectMapper() + + /** + * Upserts a report based on the provided input and action. + * + * This method either creates a new report or replaces an existing one based on the specified action. + * It validates the input and generates a new ID if the action is "create" and no ID is provided. + * If the action is "replace", it ensures that the report ID is provided and that the report exists. + * + * @param input The ReportInput containing details of the report to be created or replaced. + * @param action A string specifying the action to perform: "create" or "replace". + * @return The updated or newly created Report, or null if the operation fails. + * @throws BadRequestException If the action is invalid or if the ID is improperly provided. + * @throws ContentException If there is an error with the content format. + */ + @Throws(BadRequestException::class, ContentException::class, Exception::class) + fun upsertReport(input: ReportInput, action: String): Report? { + logger.info("ReportId, id = ${input.id}, action = $action") + + return try { + performUpsert(input, action) + } catch (e: BadRequestException) { + logger.error("Bad request while upserting report: ${e.message}", e) + throw e // Re-throwing the BadRequestException + } catch (e: ContentException) { + logger.error("Content error while upserting report: ${e.message}", e) + throw e // Re-throwing the ContentException + } catch (e: Exception) { + logger.error("Unexpected error while upserting report: ${e.message}", e) + throw ContentException("An unexpected error occurred: ${e.message}") // Re-throwing the Exception + } + } + + /** + * Executes the upsert operation for a report. + * + * Validates the action type and performs either a create or replace operation on the report. + * + * * Additionally, checks for the presence of required fields: `dataStreamId`, `dataStreamRoute`, + * * and both `stageInfo.service` and `stageInfo.action`. Throws a BadRequestException if any of + * * these fields are missing. + * + * Generates a new ID for the report if creating, and checks for existence when replacing. + * + * @param input The ReportInput containing details of the report to be created or replaced. + * @param action A string specifying the action to perform: "create" or "replace". + * @return The updated or newly created Report, or null if the operation fails. + * @throws BadRequestException If the action is invalid or the ID is improperly provided. + */ + @Throws(BadRequestException::class, ContentException::class) + private fun performUpsert(input: ReportInput, action: String): Report? { + // Validate action parameter + val upsertAction = UpsertAction.fromString(action) + + // Validate required fields for ReportInput + validateInput(input, upsertAction) + + return try { + val report = mapInputToReport(input) + + when (upsertAction) { + UpsertAction.CREATE -> createReport(report) + UpsertAction.REPLACE -> replaceReport(report) + } + } catch (e: BadRequestException) { + logger.error("Validation error during upsert: ${e.message}", e) + throw e // Re-throwing the BadRequestException + } catch (e: ContentException) { + logger.error("Content error during upsert: ${e.message}", e) + throw e // Re-throwing the ContentException + } catch (e: Exception) { + logger.error("Unexpected error during upsert: ${e.message}", e) + throw ContentException("Failed to perform upsert: ${e.message}") + } + } + + /** + * Maps the given ReportInput to a Report object. + * + * This method extracts the necessary fields from the input and constructs a Report instance. + * It also parses the content based on its type. + * + * @param input The ReportInput containing the details to map to a Report. + * @return A Report object populated with data from the input. + */ + @Throws(ContentException::class) + private fun mapInputToReport(input: ReportInput): Report { + + return try { + + // Parse the content based on its type + val parsedContent = input.content?.let { parseContent(it, input.contentType) } as? Map<*, *>? + + // Set id and reportId to be the same + val reportId = input.id ?: generateNewId() // Generate a new ID if not provided + + + Report( + id = reportId, + uploadId = input.uploadId, + reportId = reportId, //Set reportId to be the same as id + dataStreamId = input.dataStreamId, + dataStreamRoute = input.dataStreamRoute, + dexIngestDateTime = input.dexIngestDateTime, + messageMetadata = input.messageMetadata?.let { mapInputToMessageMetadata(it) }, + stageInfo = input.stageInfo?.let { mapInputToStageInfo(it) }, + tags = input.tags?.associate { it.key to it.value }, + data = input.data?.associate { it.key to it.value }, + contentType = input.contentType, + jurisdiction = input.jurisdiction, + senderId = input.senderId, + dataProducerId = input.dataProducerId, + content = parsedContent, + timestamp = input.timestamp + ) + } catch (e: JsonProcessingException) { + logger.error("JSON processing error mapping input to report: ${e.message}", e) + throw ContentException("Failed to map input to report: ${e.message}") + } catch (e: Exception) { + logger.error("Error mapping input to report: ${e.message}", e) + throw ContentException("Failed to map input to report: ${e.message}") + } + } + + /** + * Maps the given MessageMetadataInput to a MessageMetadata object. + * + * Extracts fields from the input and creates a MessageMetadata instance. + * + * @param input The MessageMetadataInput to map. + * @return A MessageMetadata object populated with data from the input. + */ + private fun mapInputToMessageMetadata(input: MessageMetadataInput): MessageMetadata { + return MessageMetadata( + messageUUID = input.messageUUID, + messageHash = input.messageHash, + aggregation = input.aggregation, + messageIndex = input.messageIndex + ) + } + + /** + * Maps the given StageInfoInput to a StageInfo object. + * + * Extracts fields from the input and creates a StageInfo instance, including issues. + * + * @param input The StageInfoInput to map. + * @return A StageInfo object populated with data from the input. + */ + private fun mapInputToStageInfo(input: StageInfoInput): StageInfo { + return StageInfo( + service = input.service, + action = input.action, + version = input.version, + status = input.status, + issues = input.issues?.map { mapInputToIssue(it) }, + startProcessingTime = input.startProcessingTime, + endProcessingTime = input.endProcessingTime + ) + } + + /** + * Maps the given IssueInput to an Issue object. + * + * Extracts the level and message from the input and creates an Issue instance. + * + * @param input The IssueInput to map. + * @return An Issue object populated with data from the input. + */ + private fun mapInputToIssue(input: IssueInput): Issue { + return Issue( + level = input.code?.let { Level.valueOf(it) }, + message = input.description + ) + } + + /** + * Generates a new unique identifier for a report. + * + * This method creates a UUID string to be used as a unique ID when creating a new report. + * + * @return A unique string identifier. + */ + private fun generateNewId(): String { + // Generate a new unique ID + return java.util.UUID.randomUUID().toString() + } + + /** + * Parses the given content based on the specified content type. + * + * Supports JSON and Base64 content types, converting them into a Map structure. + * + * @param content The content string to parse. + * @param contentType The type of content (e.g., "application/json" or "base64"). + * @return A parsed representation of the content as a Map. + * @throws ContentException If the content format is invalid or unsupported. + */ + @Throws(ContentException::class) + private fun parseContent(content: String, contentType: String?): Any { + + val validContentType = contentType?.let { ReportContentType.fromString(it) } + + return when (validContentType) { + ReportContentType.JSON, ReportContentType.JSON_SHORT -> { + // Parse JSON content into a Map + try { + objectMapper.readValue>(content) + } catch (e: JsonProcessingException) { + logger.error("Invalid JSON format: ${e.message}") + throw ContentException("Invalid JSON format: ${e.message}") + } + } + ReportContentType.BASE64 -> { + try { + // Decode base64 content into a Map, if expected + val decodedBytes = Base64.getDecoder().decode(content) + val decodedString = String(decodedBytes) + // If the decoded base64 string is in JSON format, parse it + objectMapper.readValue>(decodedString) + } catch (e: IllegalArgumentException) { + logger.error("Invalid Base64 string: ${e.message}") + throw ContentException("Invalid Base64 format: ${e.message}") + } catch (e: JsonProcessingException) { + logger.error("Invalid JSON format after base64 decode: ${e.message}") + throw ContentException("Invalid JSON format after base64 decode: ${e.message}") + } + } + else -> { + throw ContentException("Unsupported content type: $contentType") + } + } + } + + + /** + * Validates the input for a report based on the specified action. + * + * This function checks the validity of the provided `input` object based on the + * specified `action` (CREATE or REPLACE). It ensures that the required fields + * are present and valid. Specifically, for the CREATE action, it checks that + * no ID is provided, while for the REPLACE action, it verifies that an ID is + * supplied. Additionally, it validates the presence of `dataStreamId`, + * `dataStreamRoute`, and checks the properties of `stageInfo`. + * + * @param input The ReportInput object to be validated. It must contain the necessary + * fields based on the action specified. + * @param action The UpsertAction indicating the type of operation (CREATE or REPLACE). + * @throws BadRequestException If the input is invalid, such as: + * - For CREATE: ID is provided. + * - For REPLACE: ID is missing. + * - If any of the required fields are missing: dataStreamId, dataStreamRoute, + * or stageInfo (including service and action). + */ + @Throws (BadRequestException::class) + private fun validateInput(input: ReportInput, action: UpsertAction) { + when (action) { + UpsertAction.CREATE -> { + if (!input.id.isNullOrBlank()) { + throw BadRequestException("ID should not be provided for create action.Provided ID: ${input.id}") + } + } + UpsertAction.REPLACE -> { + if (input.id.isNullOrBlank()) { + throw BadRequestException("ID must be provided for replace action.") + } + // Ensure reportId matches id if both are provided + if (!input.reportId.isNullOrBlank() && input.id != input.reportId) { + throw BadRequestException("ID and reportId must be the same for replace action.") + } + } + } + + // Validate dataStreamId and dataStreamRoute fields + if (input.dataStreamId.isNullOrBlank() || input.dataStreamRoute.isNullOrBlank()) { + throw BadRequestException("Missing required fields: dataStreamId and dataStreamRoute must be present.") + } + + // Check if stageInfo is null + if (input.stageInfo == null) { + throw BadRequestException("Missing required field: stageInfo must be present.") + } + + // Check properties of stageInfo + if (input.stageInfo.service.isNullOrBlank() || input.stageInfo.action.isNullOrBlank()) { + throw BadRequestException("Missing required fields in stageInfo: service and action must be present.") + } + } + + /** + * Creates a new report in the database. + * + * This function generates a new ID for the report, validates the provided upload ID, + * and attempts to create the report in the Cosmos DB container. If the report ID + * is provided or if the upload ID is missing, a BadRequestException is thrown. + * If there is an error during the creation process, a ContentException is thrown. + * + * @param report The report object to be created. Must not have an existing ID and must include a valid upload ID. + * @return The created Report object, or null if the creation fails. + * @throws BadRequestException If the report ID is provided or if the upload ID is missing. + * @throws ContentException If there is an error during the report creation process. + */ + @Throws(BadRequestException::class, ContentException:: class) + private fun createReport(report: Report): Report? { + + if (!report.id.isNullOrBlank()) { + throw BadRequestException("ID should not be provided for create action.") + } + + // Validate uploadId + if (report.uploadId.isNullOrBlank()) { + throw BadRequestException("Upload ID must be provided.") + } + + report.id = generateNewId() + report.reportId = report.id + val options = CosmosItemRequestOptions() + + return try { + val createResponse: CosmosItemResponse? = reportsContainer?.createItem(report, PartitionKey(report.uploadId), options) + + // Check if createResponse is null and throw an exception if it is + createResponse?.item ?: throw ContentException("Failed to create report: response was null.") + + } catch (e: Exception) { + logger.error(e.localizedMessage) + throw ContentException("Failed to create report: ${e.message}") + } + } + + + /** + * Replaces an existing report in the database. + * + * This function checks if the report ID is provided and validates the upload ID. + * It attempts to read the existing report from the Cosmos DB container and, + * if found, replaces it with the new report data. If the report ID is missing, + * or if the upload ID is not provided, a BadRequestException is thrown. If + * the report is not found for replacement, another BadRequestException is thrown. + * In case of any error during the database operations, an appropriate exception + * will be thrown. + * + * @param report The report object containing the new data. Must have a valid ID and upload ID. + * @return The updated Report object, or null if the replacement fails. + * @throws BadRequestException If the report ID is missing, the upload ID is missing, or the report is not found. + */ + @Throws(BadRequestException::class, ContentException::class) + private fun replaceReport(report: Report): Report? { + + if (report.id.isNullOrBlank()) { + throw BadRequestException("ID must be provided for replace action.") + } + + // Validate uploadId + if (report.uploadId.isNullOrBlank()) { + throw BadRequestException("Upload ID must be provided.") + } + + return try { + val readResponse: CosmosItemResponse = reportsContainer?.readItem( + report.id!!, + PartitionKey(report.uploadId!!), + Report::class.java + ) ?: throw ContentException("Report with ID ${report.id} not found for replacement.") + + val options = CosmosItemRequestOptions() + val replaceResponse: CosmosItemResponse? = reportsContainer?.replaceItem( + report, + report.id!!, + PartitionKey(report.uploadId!!), + options + ) + + // Check if replaceResponse is null and throw an exception if it is + replaceResponse?.item ?: throw ContentException("Failed to replace report: response was null.") + + } catch (e: Exception) { + logger.error(e.localizedMessage) + throw ContentException("Failed to replace report: ${e.message}") + } + } + +} + +enum class UpsertAction(val action: String) { + CREATE("create"), + REPLACE("replace"); + + companion object { + fun fromString(action: String): UpsertAction { + return entries.find { it.action.equals(action, ignoreCase = true) } + ?: throw BadRequestException("Invalid action specified: $action. Must be 'create' or 'replace'.") + } + } +} diff --git a/pstatus-graphql-ktor/src/test/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoaderTest.kt b/pstatus-graphql-ktor/src/test/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoaderTest.kt index 143c5974..b0542a5a 100644 --- a/pstatus-graphql-ktor/src/test/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoaderTest.kt +++ b/pstatus-graphql-ktor/src/test/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoaderTest.kt @@ -1,31 +1,22 @@ +package gov.cdc.ocio.processingstatusapi.loaders + import data.UploadsStatusDataGenerator import com.azure.cosmos.CosmosContainer import com.azure.cosmos.models.CosmosQueryRequestOptions -import com.azure.cosmos.util.CosmosPagedFlux import com.azure.cosmos.util.CosmosPagedIterable -import com.azure.cosmos.util.UtilBridgeInternal.createCosmosPagedIterable import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException -import gov.cdc.ocio.processingstatusapi.loaders.UploadStatusLoader import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao -import gov.cdc.ocio.processingstatusapi.models.query.PageSummary import gov.cdc.ocio.processingstatusapi.models.query.UploadCounts -import gov.cdc.ocio.processingstatusapi.models.query.UploadsStatus -import gov.cdc.ocio.processingstatusapi.models.submission.MessageMetadata import io.mockk.* import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module import org.koin.test.KoinTest -import org.koin.test.get -import org.mockito.ArgumentMatchers.anyString -import org.mockito.Mockito.* import java.util.* import kotlin.test.assertEquals import kotlin.test.assertFailsWith