From 58c21c68ca883538ac4ac90ce37afb02a5920d2b Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Mon, 1 Apr 2024 09:45:54 -0400 Subject: [PATCH 01/91] pstatus notification --- .github/workflows/ps-notify-remote-cicd.yml | 236 ++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 .github/workflows/ps-notify-remote-cicd.yml diff --git a/.github/workflows/ps-notify-remote-cicd.yml b/.github/workflows/ps-notify-remote-cicd.yml new file mode 100644 index 00000000..05cbb19e --- /dev/null +++ b/.github/workflows/ps-notify-remote-cicd.yml @@ -0,0 +1,236 @@ +name: Trigger Processing Status Notify CI/CD Workflow +on: + workflow_dispatch: + inputs: + data-exchange-hl7-workflow: + description: '(Required) Specify CI/CD workflow associated with one function app to trigger autodeploy from CDCEnt/data-exchange-hl7-devops repo' + required: true + type: choice + default: 'deploy-pstatus-notify.yml' + options: + - deploy-pstatus-notify.yml + commitID: + description: '(Optional) Enter Full Commit Hash to trigger redeploy of prior version' + required: false + type: string + data-exchange-hl7-branch: + description: '(Optional) Enter Source Branch to trigger deployment of tip revision to the development or test environment. Defaults to develop branch' + required: false + type: string + default: 'develop' + targetEnv: + description: 'Environment to deploy' + required: true + type: string + default: 'dev' + + pull_request: + types: + - synchronize + - opened + branches: + - 'develop' + - 'main' + paths: + - processing-status-notifications-fa/** + push: + branches: + - 'develop' + - 'main' + paths: + - processing-status-notifications-fa/** + +jobs: + invoke-manual-cicd-trigger: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + name: Invoke ${{ inputs.data-exchange-hl7-workflow }} + environment: dev + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Gen GitHub App Access Token For Manual Trigger + id: github-app-token + run: | + echo ${{ github.workspace }} + if [ ! -d data-exchange-hl7 ]; then git clone https://github.com/kave/github-app-token.git; fi; + sudo tree -df + cd github-app-token + sudo gem install jwt + echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem + chmod +x ./get-github-app-access-token.sh; + . ./get-github-app-access-token.sh; + echo "access_token=${TOKEN}" >> "$GITHUB_ENV" + - name: Git Commit SHA + id: getsha + run: | + echo "sha=${{ inputs.commitID }}" >> "$GITHUB_ENV" + - name: Manually Dispatch Remote CICD Trigger Event + id: manual-devops-wkflow-dispatch + uses: aurelien-baudet/workflow-dispatch@v2.1.1 + with: + workflow: '${{ github.event.inputs.data-exchange-hl7-workflow }}' + repo: cdcent/data-exchange-hl7-devops + token: ${{ env.access_token }} + inputs: '{ "targetEnv": "${{ github.event.inputs.targetEnv }}", "commitID": "${{ env.sha }}", "data-exchange-hl7-branch": "${{ github.event.inputs.data-exchange-hl7-branch }}" }' + ref: 'main' + wait-for-completion: true + wait-for-completion-timeout: 120m + wait-for-completion-interval: 300s + display-workflow-run-url: true + # workflow-logs: print --- This nice-to-have feature is documented in readme but apparantly not implemented in current version - follow up required + # uses: actions/github-script@v6.4.1 + # with: + # debug: ${{ secrets.ACTIONS_RUNNER_DEBUG }} + # github-token: '${{ env.access_token }}' + # script: | + # try { + # const result = await github.rest.actions.createWorkflowDispatch({ + # owner: 'cdcent', + # repo: 'data-exchange-hl7-devops', + # workflow_id: '${{ github.event.inputs.data-exchange-hl7-workflow }}', + # ref: 'main', + # inputs: { + # commitID: '${{ steps.getsha.outputs.commitID }}', + # 'data-exchange-hl7-branch': '${{ github.event.inputs.data-exchange-hl7-branch }}' + # } + # }) + # console.log(result); + # } catch(error) { + # console.error(error); + # core.setFailed(error); + # } + prepare-remote-cicd: + permissions: write-all + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + environment: dev + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + merge_branch: ${{ steps.gettargetbranch.outputs.target_branch }} + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Get PR Commits + if: ${{ github.event_name }} == 'pull_request' + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + - name: Get User Commits + uses: actions/checkout@v3 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + - name: Get PR Merge Target Branch + id: gettargetbranch + run: | + if [[ ${{ github.event_name }} == 'pull_request' ]]; + then + echo "target_branch=${{ github.base_ref }}" >> "$GITHUB_ENV" + echo "target_branch=${{ github.base_ref }}" >> "$GITHUB_OUTPUT" + else + echo "target_branch=${{ github.ref_name }}" >> "$GITHUB_ENV" + echo "target_branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + # Create a list (matrix) of workflow IDs to trigger based on folders impacted by file changes in current branch + # Convert workflow shell array into a stringified JSON so it can be bound to matrix workflow_id property used in invoke job + # Autodeploy on Push (PR merge) to develop or main branch. All other Push events targeting feature branches will be trated as + # Pull_Request event and will trigger CI (unit test) workflow + - name: Determine Processing Status Workflow(s) to Trigger + id: set-matrix + run: | + workflows=() + commitfldrs=$(git log -m -1 --name-only --pretty="format:"${{ github.sha }}) + echo " Files Changed in PR commit: $commitfldrs" + if [[ ${{ github.event_name }} == 'push' && (${{ github.ref_name }} == 'develop' || ${{ github.ref_name }} == 'main' ) ]]; + then + case $commitfldrs in + *processing-status-api-function-app*) workflows+=("deploy-processing-status.yml") ;;& + *) ;; + esac + else + case $commitfldrs in + *processing-status-api-function-app*) workflows+=("ci-processing-status.yml") ;;& + *) ;; + esac + fi + for value in "${workflows[@]}" + do + echo "$value will be triggered as part of this PR" + echo "matrix=${workflows[@]}" >> "$GITHUB_ENV" + echo "matrix=${workflows[@]}" >> "$GITHUB_OUTPUT" + done + echo "matrix=$(printf '%s\n' "${workflows[@]}" | jq -R . | jq -cs )" >> "$GITHUB_OUTPUT" + echo "matrix=$(printf '%s\n' "${workflows[@]}" | jq -R . | jq -cs )" >> "$GITHUB_ENV" + echo "deploy workflows=${workflows[@]}" + - name: No CI/CD Notification + id: no-cicd-notice + if: fromJson(env.matrix)[0] == null + run: | + echo "No testable,deployable changes Detected in Processing Status Build Workspace" + + trigger-remote-cicd: + if: ${{ github.event_name != 'workflow_dispatch' && fromJson(needs.prepare-remote-cicd.outputs.matrix)[0] != null }} + needs: prepare-remote-cicd + runs-on: ubuntu-latest + name: Dispatch ${{ matrix.workflow_id }} Workflow + strategy: + fail-fast: false + matrix: + workflow_id: ${{fromJson(needs.prepare-remote-cicd.outputs.matrix)}} + environment: dev + steps: + - name: Gen GitHub App Access Token for Automated Trigger + id: github-app-token + run: | + echo ${{ github.workspace }} + if [ ! -d github-app-token ]; then git clone https://github.com/kave/github-app-token.git; fi; + sudo tree -df + cd github-app-token + sudo gem install jwt + echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem + chmod +x ./get-github-app-access-token.sh; + . ./get-github-app-access-token.sh; + echo "access_token=${TOKEN}" >> "$GITHUB_ENV" + - name: Get Commit SHA + id: getsha + run: | + echo "commitID=$(echo ${GITHUB_SHA})" >> $GITHUB_OUTPUT + - name: Automatically Dispatch Remote CICD Trigger Event + id: auto-devops-wkflow-dispatch + if: ${{ matrix.workflow_id != '' }} + uses: aurelien-baudet/workflow-dispatch@v2.1.1 + with: + workflow: ${{ matrix.workflow_id }} + repo: cdcent/data-exchange-hl7-devops + token: ${{ env.access_token }} + inputs: '{ "targetEnv": "dev", "commitID": "${{ steps.getsha.outputs.commitID }}", "data-exchange-hl7-branch": "${{ needs.prepare-remote-cicd.outputs.merge_branch }}" }' + ref: 'main' + wait-for-completion: true + wait-for-completion-timeout: 120m + wait-for-completion-interval: 300s + display-workflow-run-url: true + + # workflow-logs: print + # uses: actions/github-script@v6.4.1 + # with: + # debug: ${{ secrets.ACTIONS_RUNNER_DEBUG }} + # github-token: '${{ env.access_token }}' + # script: | + # try { + # const result = await github.rest.actions.createWorkflowDispatch({ + # owner: 'cdcent', + # repo: 'data-exchange-hl7-devops', + # workflow_id: '${{ matrix.workflow_id }}', + # ref: 'main', + # inputs: { + # commitID: '${{ steps.getsha.outputs.commitID }}', + # 'data-exchange-hl7-branch': '${{ needs.prepare-remote-cicd.outputs.merge_branch }}' + # } + # }) + # console.log(result); + # } catch(error) { + # console.error(error); + # core.setFailed(error); + # } \ No newline at end of file From 43dcf8f15e417d2595fa0507da73363361632550 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 22 May 2024 15:30:03 -0400 Subject: [PATCH 02/91] Initial commit on DASB-421 and DASB-428 --- .../ocio/processingstatusapi/Application.kt | 1 + .../ReportManagerConfig.kt | 1 + .../cosmos/CosmosContainerManager.kt | 6 +- .../cosmos/CosmosRepository.kt | 2 + .../processingstatusapi/plugins/ServiceBus.kt | 101 ++++++++++++++++-- .../plugins/ServiceBusProcessor.kt | 93 ++++++++++------ .../src/main/ps1/SendMessageToQueue.ps1 | 23 ++++ .../src/main/resources/application.conf | 2 + 8 files changed, 186 insertions(+), 43 deletions(-) create mode 100644 pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 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 489b3c56..1c69b8c1 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 @@ -16,6 +16,7 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp val uri = environment.config.property("azure.cosmos_db.client.endpoint").getString() val authKey = environment.config.property("azure.cosmos_db.client.key").getString() single { CosmosRepository(uri, authKey, "Reports", "/uploadId") } + single { CosmosRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } } return modules(listOf(cosmosModule)) } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/ReportManagerConfig.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/ReportManagerConfig.kt index c899bf2b..56abb9d4 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/ReportManagerConfig.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/ReportManagerConfig.kt @@ -8,5 +8,6 @@ package gov.cdc.ocio.processingstatusapi */ class ReportManagerConfig { val reportsContainerName = "Reports" + val reportsDeadLetterContainerName = "Reports-DeadLetter" private val partitionKey = "/uploadId" } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt index 0e214e44..8043a60d 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt @@ -17,8 +17,10 @@ class CosmosContainerManager { val logger = KotlinLogging.logger {} logger.info("Create database $databaseName if not exists...") // Create database if not exists - val databaseResponse = cosmosClient.createDatabaseIfNotExists(databaseName) - return cosmosClient.getDatabase(databaseResponse.properties.id) + // val databaseResponse = cosmosClient.createDatabaseIfNotExists(databaseName) + // return cosmosClient.getDatabase(databaseResponse.properties.id) + + return cosmosClient.getDatabase(databaseName) } fun initDatabaseContainer(uri: String, authKey: String, containerName: String, partitionKey: String): CosmosContainer? { diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt index 28953ba8..80c21451 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt @@ -6,4 +6,6 @@ class CosmosRepository(uri: String, authKey: String, reportsContainerName: Strin val reportsContainer = CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! + + } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index fe716d63..21e9cb06 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -1,11 +1,18 @@ package gov.cdc.ocio.processingstatusapi.plugins +import com.azure.core.amqp.exception.AmqpException +import com.azure.core.exception.AzureException import com.azure.messaging.servicebus.* +import com.azure.messaging.servicebus.models.DeadLetterOptions +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import io.ktor.server.application.* import io.ktor.server.application.hooks.* import io.ktor.server.config.* import io.ktor.util.logging.* +import io.netty.channel.ConnectTimeoutException +import org.apache.qpid.proton.engine.TransportException import java.util.concurrent.TimeUnit internal val LOGGER = KtorSimpleLogger("pstatus-report-sink") @@ -13,6 +20,8 @@ internal val LOGGER = KtorSimpleLogger("pstatus-report-sink") class AzureServiceBusConfiguration(config: ApplicationConfig) { var connectionString: String = config.tryGetString("connection_string") ?: "" var queueName: String = config.tryGetString("queue_name") ?: "" + var topicName: String = config.tryGetString("topic_name") ?: "" + var subscriptionName: String = config.tryGetString("subscription_name") ?: "" } val AzureServiceBus = createApplicationPlugin( @@ -22,8 +31,11 @@ val AzureServiceBus = createApplicationPlugin( val connectionString = pluginConfig.connectionString val queueName = pluginConfig.queueName + val topicName = pluginConfig.topicName + val subscriptionName = pluginConfig.subscriptionName - val processorClient by lazy { +// Initialize Service Bus client for queue + val processorQueueClient by lazy { ServiceBusClientBuilder() .connectionString(connectionString) .processor() @@ -33,31 +45,89 @@ val AzureServiceBus = createApplicationPlugin( .buildProcessorClient() } + // Initialize Service Bus client for topic + val processorTopicClient by lazy { + ServiceBusClientBuilder() + .connectionString(connectionString) + .processor() + .topicName(topicName) + .subscriptionName(subscriptionName) + .processMessage{ context -> processMessage(context) } + .processError { context -> processError(context) } + .buildProcessorClient() + } + // handles received messages @Throws(InterruptedException::class) fun receiveMessages() { - // Create an instance of the processor through the ServiceBusClientBuilder - println("Starting the Azure service bus processor") - println("connectionString = $connectionString, queueName = $queueName") - processorClient.start() + try { + // Create an instance of the processor through the ServiceBusClientBuilder + println("Starting the Azure service bus processor") + println("connectionString = $connectionString, queueName = $queueName, topicName= $topicName, subscriptionName=$subscriptionName") + processorQueueClient.start() + processorTopicClient.start() + } + + catch (e:AmqpException){ + println("Non-ServiceBusException occurred : ${e.message}") + } + catch (e:TransportException){ + println("Non-ServiceBusException occurred : ${e.message}") + } + + catch (e:Exception){ + println("Non-ServiceBusException occurred : ${e.message}") + } + + } + + fun sendMessage() { + val senderClient = ServiceBusClientBuilder() + .connectionString(connectionString) + .sender() + .queueName(queueName) + .buildClient() + try { + val message = ServiceBusMessage("Hello, Service Bus!") + senderClient.sendMessage(message) + println("Message sent to the queue.") + + } + catch (e:AmqpException){ + println("Non-ServiceBusException occurred : ${e.message}") + } + catch (e:TransportException){ + println("Non-ServiceBusException occurred : ${e.message}") + } + + catch (e:Exception){ + println("Non-ServiceBusException occurred : ${e.message}") + } + finally { + senderClient.close() + } } on(MonitoringEvent(ApplicationStarted)) { application -> application.log.info("Server is started") - receiveMessages() + receiveMessages() + // sendMessage() } on(MonitoringEvent(ApplicationStopped)) { application -> application.log.info("Server is stopped") println("Stopping and closing the processor") - processorClient.close() + processorQueueClient.close() + processorTopicClient.close() // Release resources and unsubscribe from events application.environment.monitor.unsubscribe(ApplicationStarted) {} application.environment.monitor.unsubscribe(ApplicationStopped) {} } } + private fun processMessage(context: ServiceBusReceivedMessageContext) { val message = context.message + LOGGER.trace( "Processing message. Session: {}, Sequence #: {}. Contents: {}", message.messageId, @@ -68,11 +138,24 @@ private fun processMessage(context: ServiceBusReceivedMessageContext) { ServiceBusProcessor().withMessage(message.body.toString()) } catch (e: BadRequestException) { LOGGER.warn("Unable to parse the message: {}", e.localizedMessage) - } catch (e: Exception) { + } + catch (e: IllegalArgumentException) { // TODO : Is this the only exception at this time or more generic one??? + LOGGER.warn("Message rejected: {}", e.localizedMessage) + //Writing to deadletter + // TODO : Will this do it for queue and topic based on the context. + // TODO : Should this be "ValidationError" or something generic + context.deadLetter(DeadLetterOptions().setDeadLetterReason("ValidationError").setDeadLetterErrorDescription(e.message)) + + LOGGER.info("Message sent to the dead-letter queue.") + } + catch (e: Exception) { LOGGER.warn("Failed to process service bus message: {}", e.localizedMessage) } + } + + private fun processError(context: ServiceBusErrorContext) { System.out.printf( "Error when receiving messages from namespace: '%s'. Entity: '%s'%n", @@ -106,6 +189,8 @@ private fun processError(context: ServiceBusErrorContext) { } } + + fun Application.serviceBusModule() { install(AzureServiceBus) { // any additional configuration goes here 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 a4c294d4..7c4b9974 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 @@ -1,5 +1,8 @@ package gov.cdc.ocio.processingstatusapi.plugins +import com.azure.messaging.servicebus.ServiceBusClientBuilder +import com.azure.messaging.servicebus.ServiceBusReceivedMessage +import com.azure.messaging.servicebus.models.DeadLetterOptions import com.google.gson.GsonBuilder import com.google.gson.JsonSyntaxException import com.google.gson.ToNumberPolicy @@ -45,7 +48,12 @@ class ServiceBusProcessor { } logger.info { "After Message received = $sbMessage" } createReport(gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) - } catch (e: JsonSyntaxException) { + } + catch (e:IllegalArgumentException){ + println("Validation failed: ${e.message}") + throw e + } + catch (e: JsonSyntaxException) { logger.error("Failed to parse CreateReportSBMessage: ${e.localizedMessage}") throw BadStateException("Unable to interpret the create report message") } @@ -59,44 +67,63 @@ class ServiceBusProcessor { */ @Throws(BadRequestException::class) private fun createReport(createReportMessage: CreateReportSBMessage) { + try { + validateReport(createReportMessage) + val uploadId = createReportMessage.uploadId + val stageName = createReportMessage.stageName + logger.info("Creating report for uploadId = ${uploadId} with stageName = $stageName") + ReportManager().createReportWithUploadId( + createReportMessage.uploadId!!, + createReportMessage.dataStreamId!!, + createReportMessage.dataStreamRoute!!, + createReportMessage.stageName!!, + createReportMessage.contentType!!, + createReportMessage.messageId!!, + createReportMessage.status, + createReportMessage.contentAsString!!, // it was Content I changed to ContentAsString + createReportMessage.dispositionType, + Source.SERVICEBUS + ) + } + catch (e:IllegalArgumentException){ + throw e + } + catch (e: Exception) { + println("Failed to process service bus message:${e.message}") - val uploadId = createReportMessage.uploadId - ?: throw BadRequestException("Missing required field upload_id") - - val dataStreamId = createReportMessage.dataStreamId - ?: throw BadRequestException("Missing required field data_stream_id") - - val dataStreamRoute = createReportMessage.dataStreamRoute - ?: throw BadRequestException("Missing required field data_stream_route") + } - val stageName = createReportMessage.stageName - ?: throw BadRequestException("Missing required field stage_name") + } - val contentType = createReportMessage.contentType - ?: throw BadRequestException("Missing required field content_type") + // Function to validate report + private fun validateReport(createReportMessage: CreateReportSBMessage) { + val missingFields = mutableListOf() - val content: String - try { - content = createReportMessage.contentAsString - ?: throw BadRequestException("Missing required field content") - } catch (ex: BadStateException) { - // assume a bad request - throw BadRequestException(ex.localizedMessage) + if (createReportMessage.uploadId.isNullOrBlank()) { + missingFields.add("uploadId") + } + if (createReportMessage.dataStreamId.isNullOrBlank()) { + missingFields.add("dataStreamId") + } + if (createReportMessage.dataStreamRoute.isNullOrBlank()) { + missingFields.add("dataStreamRoute") + } + if (createReportMessage.stageName.isNullOrBlank()) { + missingFields.add("stageName") + } + if (createReportMessage.contentType.isNullOrBlank()) { + missingFields.add("contentType") + } + if (createReportMessage.contentAsString.isNullOrBlank()) { + missingFields.add("content") } - logger.info("Creating report for uploadId = $uploadId with stageName = $stageName") - ReportManager().createReportWithUploadId( - uploadId, - dataStreamId, - dataStreamRoute, - stageName, - contentType, - createReportMessage.messageId, - createReportMessage.status, - content, - createReportMessage.dispositionType, - Source.SERVICEBUS - ) + if (missingFields.isNotEmpty()) { + val reason ="Missing fields: ${missingFields.joinToString(", ")}" + throw IllegalArgumentException(reason) + } } + + } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 b/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 new file mode 100644 index 00000000..89a7868b --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 @@ -0,0 +1,23 @@ +# Set the parameters for the Service Bus +$connectionString = "Endpoint=sb://ocio-ede-dev-processingstatus-testing.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=EkH3H/i66rBE+xTmTdIqMFmlxzYmsteP7+ASbJMhc5w=" +$queueName = "reports-notifications-queue" + +# Import the necessary assemblies +Add-Type -AssemblyName "System.ServiceModel" +Add-Type -Path (Join-Path -Path (Split-Path $env:PSModulePath -Parent) -ChildPath 'Azure.ServiceBus\2.1.1\lib\netstandard2.0\Microsoft.Azure.ServiceBus.dll') + +# Create a queue client +$queueClient = [Microsoft.Azure.ServiceBus.QueueClient]::new($connectionString, $queueName) + +# Create a message +$messageBody = "Hello, Azure Service Bus!" +$message = [Microsoft.Azure.ServiceBus.Message]::new([System.Text.Encoding]::UTF8.GetBytes($messageBody)) + +# Send the message to the queue +$queueClient.SendAsync($message).GetAwaiter().GetResult() + +# Print confirmation +Write-Output "Sent message: $messageBody" + +# Close the client +$queueClient.CloseAsync().GetAwaiter().GetResult() diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index 1a143ca4..9f1db1db 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -13,6 +13,8 @@ azure { service_bus { connection_string = ${SERVICE_BUS_CONNECTION_STRING} queue_name = ${SERVICE_BUS_REPORT_QUEUE_NAME} + topic_name = ${SERVICE_BUS_REPORT_TOPIC_NAME} + subscription_name = ${SERVICE_BUS_REPORT_TOPIC_SUBSCRIPTION_NAME} } cosmos_db { client { From 2f65c39358193093479c756ec18a1df93fa87567 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 22 May 2024 15:56:08 -0400 Subject: [PATCH 03/91] Initial commit on DASB-421 and DASB-428 --- .../processingstatusapi/plugins/ServiceBus.kt | 25 +++++++++++++------ .../src/main/resources/application.conf | 1 + 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index 21e9cb06..2460c108 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -1,5 +1,6 @@ package gov.cdc.ocio.processingstatusapi.plugins +import com.azure.core.amqp.AmqpTransportType import com.azure.core.amqp.exception.AmqpException import com.azure.core.exception.AzureException import com.azure.messaging.servicebus.* @@ -19,6 +20,7 @@ internal val LOGGER = KtorSimpleLogger("pstatus-report-sink") class AzureServiceBusConfiguration(config: ApplicationConfig) { var connectionString: String = config.tryGetString("connection_string") ?: "" + var serviceBusNamespace: String = config.tryGetString("azure_servicebus_namespace") ?: "" var queueName: String = config.tryGetString("queue_name") ?: "" var topicName: String = config.tryGetString("topic_name") ?: "" var subscriptionName: String = config.tryGetString("subscription_name") ?: "" @@ -30,6 +32,7 @@ val AzureServiceBus = createApplicationPlugin( createConfiguration = ::AzureServiceBusConfiguration) { val connectionString = pluginConfig.connectionString + var serviceBusNamespace= pluginConfig.serviceBusNamespace val queueName = pluginConfig.queueName val topicName = pluginConfig.topicName val subscriptionName = pluginConfig.subscriptionName @@ -38,6 +41,8 @@ val AzureServiceBus = createApplicationPlugin( val processorQueueClient by lazy { ServiceBusClientBuilder() .connectionString(connectionString) + .fullyQualifiedNamespace(serviceBusNamespace) + .transportType(AmqpTransportType.AMQP_WEB_SOCKETS) .processor() .queueName(queueName) .processMessage{ context -> processMessage(context) } @@ -49,6 +54,8 @@ val AzureServiceBus = createApplicationPlugin( val processorTopicClient by lazy { ServiceBusClientBuilder() .connectionString(connectionString) + .fullyQualifiedNamespace(serviceBusNamespace) + .transportType(AmqpTransportType.AMQP_WEB_SOCKETS) .processor() .topicName(topicName) .subscriptionName(subscriptionName) @@ -82,11 +89,15 @@ val AzureServiceBus = createApplicationPlugin( } fun sendMessage() { - val senderClient = ServiceBusClientBuilder() - .connectionString(connectionString) - .sender() - .queueName(queueName) - .buildClient() + val senderClient = ServiceBusClientBuilder() + .connectionString(connectionString) + .fullyQualifiedNamespace(serviceBusNamespace) + .transportType(AmqpTransportType.AMQP_WEB_SOCKETS) + .sender() + .queueName(queueName) + .buildClient() + // val serviceBusMessages = reports.map {ServiceBusMessage(it)} + // senderClient.sendMessages(serviceBusMessages) try { val message = ServiceBusMessage("Hello, Service Bus!") senderClient.sendMessage(message) @@ -110,8 +121,8 @@ val AzureServiceBus = createApplicationPlugin( on(MonitoringEvent(ApplicationStarted)) { application -> application.log.info("Server is started") - receiveMessages() - // sendMessage() + receiveMessages() + // sendMessage() //****This is not working as well**** } on(MonitoringEvent(ApplicationStopped)) { application -> application.log.info("Server is stopped") diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index 9f1db1db..acfac84d 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -12,6 +12,7 @@ ktor { azure { service_bus { connection_string = ${SERVICE_BUS_CONNECTION_STRING} + azure_servicebus_namespace=${AZURE_SERVICE_BUS_NAMESPACE} queue_name = ${SERVICE_BUS_REPORT_QUEUE_NAME} topic_name = ${SERVICE_BUS_REPORT_TOPIC_NAME} subscription_name = ${SERVICE_BUS_REPORT_TOPIC_SUBSCRIPTION_NAME} From d5238e1e241a5144c7b9c93b489d7306173b169f Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 23 May 2024 10:43:30 -0400 Subject: [PATCH 04/91] Initial commit on DASB-421 and DASB-428 --- .../gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index 2460c108..6757c462 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -122,7 +122,7 @@ val AzureServiceBus = createApplicationPlugin( on(MonitoringEvent(ApplicationStarted)) { application -> application.log.info("Server is started") receiveMessages() - // sendMessage() //****This is not working as well**** + sendMessage() //****This is not working as well**** } on(MonitoringEvent(ApplicationStopped)) { application -> application.log.info("Server is stopped") From 63c68cbe334d441c70279f4a11cab369e3730779 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 29 May 2024 10:26:32 -0400 Subject: [PATCH 05/91] Initial commit on DASB-421 and DASB-428 --- .../ocio/processingstatusapi/Application.kt | 4 +- .../ocio/processingstatusapi/ReportManager.kt | 84 +++++++++++++++++++ .../cosmos/CosmosClientManager.kt | 1 + .../cosmos/CosmosContainerManager.kt | 5 +- .../cosmos/CosmosRepository.kt | 10 ++- .../models/DeadLetterReport.kt | 67 +++++++++++++++ .../processingstatusapi/plugins/ServiceBus.kt | 53 ++---------- .../plugins/ServiceBusProcessor.kt | 30 +++++-- 8 files changed, 195 insertions(+), 59 deletions(-) create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt 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 1c69b8c1..39e5aff0 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 @@ -1,5 +1,6 @@ package gov.cdc.ocio.processingstatusapi +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.plugins.configureRouting import gov.cdc.ocio.processingstatusapi.plugins.serviceBusModule @@ -16,8 +17,9 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp val uri = environment.config.property("azure.cosmos_db.client.endpoint").getString() val authKey = environment.config.property("azure.cosmos_db.client.key").getString() single { CosmosRepository(uri, authKey, "Reports", "/uploadId") } - single { CosmosRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } + single { CosmosDeadLetterRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } } + return modules(listOf(cosmosModule)) } 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 a829f748..e2b94def 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 @@ -3,15 +3,18 @@ package gov.cdc.ocio.processingstatusapi import com.azure.cosmos.models.CosmosItemRequestOptions import com.azure.cosmos.models.CosmosQueryRequestOptions import com.azure.cosmos.models.PartitionKey +import com.azure.messaging.servicebus.models.DeadLetterOptions import com.google.gson.GsonBuilder import com.google.gson.ToNumberPolicy import com.google.gson.reflect.TypeToken +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException import gov.cdc.ocio.processingstatusapi.exceptions.InvalidSchemaDefException import gov.cdc.ocio.processingstatusapi.models.DispositionType import gov.cdc.ocio.processingstatusapi.models.Report +import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition import gov.cdc.ocio.processingstatusapi.models.reports.Source import mu.KotlinLogging @@ -19,6 +22,8 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.* import io.netty.handler.codec.http.HttpResponseStatus +import java.sql.Timestamp +import java.time.LocalDateTime /** * The report manager interacts directly with CosmosDB to persist and retrieve reports. @@ -28,6 +33,7 @@ import io.netty.handler.codec.http.HttpResponseStatus class ReportManager: KoinComponent { private val cosmosRepository by inject() + private val cosmosDeadLetterRepository by inject() private val logger = KotlinLogging.logger {} @@ -241,6 +247,84 @@ class ReportManager: KoinComponent { throw BadStateException("Failed to create reportId = ${stageReport.reportId}, uploadId = $uploadId") } + @Throws(BadStateException::class) + fun createDeadLetterReport(uploadId: String, + dataStreamId: String, + dataStreamRoute: String, + dispositionType: DispositionType, + contentType: String, + content: String, + deadLetterReasons: List + ): String { + + val deadLetterReportId = UUID.randomUUID().toString() + val deadLetterReport = ReportDeadLetter().apply { + this.id = deadLetterReportId + this.uploadId = uploadId + this.dataStreamId = dataStreamId + this.dataStreamRoute = dataStreamRoute + this.dispositionType= dispositionType.toString() + this.contentType = contentType + this.deadLetterReasons= deadLetterReasons + if (contentType.lowercase() == "json") { + val typeObject = object : TypeToken?>() {}.type + val jsonMap: Map = gson.fromJson(content, typeObject) + this.content = jsonMap + } else + this.content = content + } + + var attempts = 0 + do { + try { + val response = cosmosDeadLetterRepository.reportsDeadLetterContainer.createItem( + deadLetterReport, + PartitionKey(uploadId), + CosmosItemRequestOptions() + ) + + logger.info("Creating dead-letter report, response http status code = ${response?.statusCode}, attempt = ${attempts + 1}, uploadId = $uploadId") + if (response != null) { + when (response.statusCode) { + HttpResponseStatus.OK.code(), HttpResponseStatus.CREATED.code() -> { + logger.info("Created report with reportId = ${response.item?.reportId}, uploadId = $uploadId") + + return deadLetterReportId + } + + HttpResponseStatus.TOO_MANY_REQUESTS.code() -> { + // See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/performance-tips?tabs=trace-net-core#429 + // https://learn.microsoft.com/en-us/rest/api/cosmos-db/common-cosmosdb-rest-response-headers + // https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large?tabs=resource-specific + val recommendedDuration = response.responseHeaders["x-ms-retry-after-ms"] + logger.warn("Received 429 (too many requests) from cosmosdb, attempt ${attempts + 1}, will retry after $recommendedDuration millis, uploadId = $uploadId") + val waitMillis = recommendedDuration?.toLong() + Thread.sleep(waitMillis ?: DEFAULT_RETRY_INTERVAL_MILLIS) + } + + else -> { + // Need to retry regardless + val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) + logger.warn("Received response code ${response.statusCode}, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") + Thread.sleep(retryAfterDurationMillis) + } + } + } else { + val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) + logger.warn("Received null response from cosmosdb, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") + Thread.sleep(retryAfterDurationMillis) + } + } catch (e: Exception) { + val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) + logger.error("CreateReport: Exception: ${e.localizedMessage}, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") + Thread.sleep(retryAfterDurationMillis) + } + + } while (attempts++ < MAX_RETRY_ATTEMPTS) + + throw BadStateException("Failed to create dead-letterReport reportId = ${deadLetterReport.reportId}, uploadId = $uploadId") + } + private fun getCalculatedRetryDuration(attempt: Int): Long { return DEFAULT_RETRY_INTERVAL_MILLIS * (attempt + 1) } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt index 5e1854c7..54fdb132 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt @@ -16,6 +16,7 @@ class CosmosClientManager { .endpoint(uri) .key(authKey) .consistencyLevel(ConsistencyLevel.EVENTUAL) + .gatewayMode() .contentResponseOnWriteEnabled(true) .clientTelemetryEnabled(false) .buildClient() diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt index 8043a60d..49b28d97 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt @@ -17,8 +17,9 @@ class CosmosContainerManager { val logger = KotlinLogging.logger {} logger.info("Create database $databaseName if not exists...") // Create database if not exists - // val databaseResponse = cosmosClient.createDatabaseIfNotExists(databaseName) - // return cosmosClient.getDatabase(databaseResponse.properties.id) + //TODO : These 2 lines are throwing exceptions + // val databaseResponse = cosmosClient.createDatabaseIfNotExists(databaseName) + // return cosmosClient.getDatabase(databaseResponse.properties.id) return cosmosClient.getDatabase(databaseName) } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt index 80c21451..23c5eaca 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt @@ -3,9 +3,11 @@ package gov.cdc.ocio.processingstatusapi.cosmos import org.koin.core.component.KoinComponent class CosmosRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { + val reportsContainer = + CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! +} - val reportsContainer = CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! - - - +class CosmosDeadLetterRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { + val reportsDeadLetterContainer = + CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt new file mode 100644 index 00000000..ba618c26 --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt @@ -0,0 +1,67 @@ +package gov.cdc.ocio.processingstatusapi.models + +import com.google.gson.* +import com.google.gson.annotations.SerializedName +import java.time.LocalDateTime +import java.util.* + +/** + * Report for a given stage. + * + * @property uploadId String? + * @property reportId String? + * @property dataStreamId String? + * @property dataStreamRoute String? + * @property dispositionType DispositionType? + * @property dexIngestionDateTime DateTime? + * @property timestamp Date + * @property contentType String? + * @property content String? + * @property deadLetterReasons List + */ +data class ReportDeadLetter( + + var id : String? = null, + + @SerializedName("upload_id") + var uploadId: String? = null, + + @SerializedName("report_id") + var reportId: String? = null, + + @SerializedName("data_stream_id") + var dataStreamId: String? = null, + + @SerializedName("data_stream_route") + var dataStreamRoute: String? = null, + + @SerializedName("disposition_type") + var dispositionType: String? = null, + + + @SerializedName("content_type") + var contentType : String? = null, + + var content: Any? = null, + + val dexIngestDateTime: LocalDateTime = LocalDateTime.now(), + + val timestamp: Date = Date(), + + var deadLetterReasons:List? = null +) { + val contentAsString: String? + get() { + if (content == null) return null + + return when (contentType?.lowercase(Locale.getDefault())) { + "json" -> { + if (content is LinkedHashMap<*, *>) + Gson().toJson(content, MutableMap::class.java).toString() + else + content.toString() + } + else -> content.toString() + } + } +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index 6757c462..e922bb7a 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -50,7 +50,7 @@ val AzureServiceBus = createApplicationPlugin( .buildProcessorClient() } - // Initialize Service Bus client for topic +// Initialize Service Bus client for topics val processorTopicClient by lazy { ServiceBusClientBuilder() .connectionString(connectionString) @@ -88,41 +88,9 @@ val AzureServiceBus = createApplicationPlugin( } - fun sendMessage() { - val senderClient = ServiceBusClientBuilder() - .connectionString(connectionString) - .fullyQualifiedNamespace(serviceBusNamespace) - .transportType(AmqpTransportType.AMQP_WEB_SOCKETS) - .sender() - .queueName(queueName) - .buildClient() - // val serviceBusMessages = reports.map {ServiceBusMessage(it)} - // senderClient.sendMessages(serviceBusMessages) - try { - val message = ServiceBusMessage("Hello, Service Bus!") - senderClient.sendMessage(message) - println("Message sent to the queue.") - - } - catch (e:AmqpException){ - println("Non-ServiceBusException occurred : ${e.message}") - } - catch (e:TransportException){ - println("Non-ServiceBusException occurred : ${e.message}") - } - - catch (e:Exception){ - println("Non-ServiceBusException occurred : ${e.message}") - } - finally { - senderClient.close() - } - } - on(MonitoringEvent(ApplicationStarted)) { application -> application.log.info("Server is started") receiveMessages() - sendMessage() //****This is not working as well**** } on(MonitoringEvent(ApplicationStopped)) { application -> application.log.info("Server is stopped") @@ -135,7 +103,6 @@ val AzureServiceBus = createApplicationPlugin( } } - private fun processMessage(context: ServiceBusReceivedMessageContext) { val message = context.message @@ -146,27 +113,23 @@ private fun processMessage(context: ServiceBusReceivedMessageContext) { message.body ) try { - ServiceBusProcessor().withMessage(message.body.toString()) + ServiceBusProcessor().withMessage(message) } catch (e: BadRequestException) { LOGGER.warn("Unable to parse the message: {}", e.localizedMessage) } catch (e: IllegalArgumentException) { // TODO : Is this the only exception at this time or more generic one??? LOGGER.warn("Message rejected: {}", e.localizedMessage) - //Writing to deadletter - // TODO : Will this do it for queue and topic based on the context. - // TODO : Should this be "ValidationError" or something generic - context.deadLetter(DeadLetterOptions().setDeadLetterReason("ValidationError").setDeadLetterErrorDescription(e.message)) - - LOGGER.info("Message sent to the dead-letter queue.") + val deadLetterOptions = DeadLetterOptions() + .setDeadLetterReason("Validation failed") + .setDeadLetterErrorDescription(e.message) + context.deadLetter(deadLetterOptions) + LOGGER.info("Message sent to the dead-letter queue.") } catch (e: Exception) { LOGGER.warn("Failed to process service bus message: {}", e.localizedMessage) } } - - - private fun processError(context: ServiceBusErrorContext) { System.out.printf( "Error when receiving messages from namespace: '%s'. Entity: '%s'%n", @@ -200,8 +163,6 @@ private fun processError(context: ServiceBusErrorContext) { } } - - fun Application.serviceBusModule() { install(AzureServiceBus) { // any additional configuration goes here 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 7c4b9974..6020fd0d 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 @@ -12,6 +12,11 @@ import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage import gov.cdc.ocio.processingstatusapi.models.reports.Source import mu.KotlinLogging +import java.sql.Timestamp +import java.time.Instant +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter import java.util.* /** @@ -36,8 +41,10 @@ class ServiceBusProcessor { * @throws BadRequestException */ @Throws(BadRequestException::class) - fun withMessage(message: String) { - var sbMessage = message + fun withMessage(message: ServiceBusReceivedMessage) { + var sbMessageId = message.messageId + var sbMessage = message.body.toString() + var sbMessageStatus = message.state.name try { logger.info { "Before Message received = $sbMessage" } if (sbMessage.contains("destination_id")) { @@ -47,7 +54,7 @@ class ServiceBusProcessor { sbMessage = sbMessage.replace("event_type", "data_stream_route") } logger.info { "After Message received = $sbMessage" } - createReport(gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) + createReport(sbMessageId,sbMessageStatus,gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) } catch (e:IllegalArgumentException){ println("Validation failed: ${e.message}") @@ -66,7 +73,7 @@ class ServiceBusProcessor { * @throws BadRequestException */ @Throws(BadRequestException::class) - private fun createReport(createReportMessage: CreateReportSBMessage) { + private fun createReport(messageId:String, messageStatus:String,createReportMessage: CreateReportSBMessage) { try { validateReport(createReportMessage) val uploadId = createReportMessage.uploadId @@ -78,8 +85,8 @@ class ServiceBusProcessor { createReportMessage.dataStreamRoute!!, createReportMessage.stageName!!, createReportMessage.contentType!!, - createReportMessage.messageId!!, - createReportMessage.status, + messageId, //createReportMessage.messageId is null + messageStatus, //createReportMessage.status is null createReportMessage.contentAsString!!, // it was Content I changed to ContentAsString createReportMessage.dispositionType, Source.SERVICEBUS @@ -120,6 +127,17 @@ class ServiceBusProcessor { if (missingFields.isNotEmpty()) { val reason ="Missing fields: ${missingFields.joinToString(", ")}" + + // Write the content of the deadletter reports to CosmosDb + ReportManager().createDeadLetterReport( + createReportMessage.uploadId!!, + createReportMessage.dataStreamId!!, + createReportMessage.dataStreamRoute!!, + createReportMessage.dispositionType, + createReportMessage.contentType!!, + createReportMessage.contentAsString!!, + missingFields) + throw IllegalArgumentException(reason) } } From 7cccd2189e9b695ae7dce5bd0b3ab960189ada45 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 31 May 2024 16:02:33 -0400 Subject: [PATCH 06/91] Initial commit on DASB-39 --- .../ocio/processingstatusapi/ReportManager.kt | 29 +++++-------------- .../models/DeadLetterReport.kt | 17 +---------- .../ocio/processingstatusapi/models/Report.kt | 17 +---------- .../models/reports/CreateReportSBMessage.kt | 15 ---------- .../models/reports/SchemaDefinition.kt | 6 ++-- .../plugins/ServiceBusProcessor.kt | 17 +++++++---- 6 files changed, 25 insertions(+), 76 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 e2b94def..a676a2af 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 @@ -4,6 +4,7 @@ import com.azure.cosmos.models.CosmosItemRequestOptions import com.azure.cosmos.models.CosmosQueryRequestOptions import com.azure.cosmos.models.PartitionKey import com.azure.messaging.servicebus.models.DeadLetterOptions +import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.ToNumberPolicy import com.google.gson.reflect.TypeToken @@ -65,7 +66,7 @@ class ReportManager: KoinComponent { contentType: String, messageId: String?, status: String?, - content: String, + content: Any?, dispositionType: DispositionType, source: Source ): String { @@ -102,7 +103,7 @@ class ReportManager: KoinComponent { contentType: String, messageId: String?, status: String?, - content: String, + content: Any?, dispositionType: DispositionType, source: Source): String { @@ -160,7 +161,7 @@ class ReportManager: KoinComponent { contentType: String, messageId: String?, status: String?, - content: String, + content: Any?, source: Source): String { val stageReportId = UUID.randomUUID().toString() val stageReport = Report().apply { @@ -176,7 +177,7 @@ class ReportManager: KoinComponent { if (contentType.lowercase() == "json") { val typeObject = object : TypeToken?>() {}.type - val jsonMap: Map = gson.fromJson(content, typeObject) + val jsonMap: Map = gson.fromJson(Gson().toJson(content, MutableMap::class.java).toString(), typeObject) this.content = jsonMap } else this.content = content @@ -196,21 +197,7 @@ class ReportManager: KoinComponent { when (response.statusCode) { HttpResponseStatus.OK.code(), HttpResponseStatus.CREATED.code() -> { logger.info("Created report with reportId = ${response.item?.reportId}, uploadId = $uploadId") - val enableReportForwarding = System.getenv("EnableReportForwarding") -// if (enableReportForwarding.equals("True", ignoreCase = true)) { -// // Send message to reports-notifications-queue -// val message = NotificationReport( -// response.item?.reportId, -// uploadId, dataStreamId, dataStreamRoute, -// stageName, -// contentType, -// content, -// messageId, -// status, -// source -// ) -// reportMgrConfig.serviceBusSender.sendMessage(ServiceBusMessage(message.toString())) -// } + return stageReportId } @@ -253,7 +240,7 @@ class ReportManager: KoinComponent { dataStreamRoute: String, dispositionType: DispositionType, contentType: String, - content: String, + content: Any?, deadLetterReasons: List ): String { @@ -268,7 +255,7 @@ class ReportManager: KoinComponent { this.deadLetterReasons= deadLetterReasons if (contentType.lowercase() == "json") { val typeObject = object : TypeToken?>() {}.type - val jsonMap: Map = gson.fromJson(content, typeObject) + val jsonMap: Map = gson.fromJson(Gson().toJson(content, MutableMap::class.java).toString(), typeObject) this.content = jsonMap } else this.content = content diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt index ba618c26..4a3e96fc 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt @@ -49,19 +49,4 @@ data class ReportDeadLetter( val timestamp: Date = Date(), var deadLetterReasons:List? = null -) { - val contentAsString: String? - get() { - if (content == null) return null - - return when (contentType?.lowercase(Locale.getDefault())) { - "json" -> { - if (content is LinkedHashMap<*, *>) - Gson().toJson(content, MutableMap::class.java).toString() - else - content.toString() - } - else -> content.toString() - } - } -} +) 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 697e438e..f1c2b086 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 @@ -49,19 +49,4 @@ data class Report( var content: Any? = null, val timestamp: Date = Date() -) { - val contentAsString: String? - get() { - if (content == null) return null - - return when (contentType?.lowercase(Locale.getDefault())) { - "json" -> { - if (content is LinkedHashMap<*, *>) - Gson().toJson(content, MutableMap::class.java).toString() - else - content.toString() - } - else -> content.toString() - } - } -} +) 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/CreateReportSBMessage.kt index 1b127713..244857f4 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/CreateReportSBMessage.kt @@ -45,20 +45,5 @@ class CreateReportSBMessage: ServiceBusMessage() { // content type will be a Map<*, *>. val content: Any? = null - val contentAsString: String? - get() { - if (content == null) return null - return when (contentType?.lowercase(Locale.getDefault())) { - "json" -> { - val typeObject = object : TypeToken?>() {}.type - try { - Gson().toJson(content as Map<*, *>, typeObject) - } catch (e: ClassCastException) { - throw BadStateException("content_type indicates json, but the content is not in JSON format") - } - } - else -> content.toString() - } - } } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/SchemaDefinition.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/SchemaDefinition.kt index 11d41bce..205d39d8 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/SchemaDefinition.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/SchemaDefinition.kt @@ -2,6 +2,7 @@ package gov.cdc.ocio.processingstatusapi.models.reports import com.google.gson.Gson import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken import gov.cdc.ocio.processingstatusapi.exceptions.InvalidSchemaDefException /** @@ -67,10 +68,9 @@ open class SchemaDefinition(@SerializedName("schema_name") var schemaName: Strin companion object { @Throws(InvalidSchemaDefException::class) - fun fromJsonString(jsonContent: String?): SchemaDefinition { + fun fromJsonString(jsonContent: Any?): SchemaDefinition { if (jsonContent == null) throw InvalidSchemaDefException("Missing schema definition") - - val schemaDefinition = Gson().fromJson(jsonContent, SchemaDefinition::class.java) + val schemaDefinition= Gson().fromJson(Gson().toJson(jsonContent, MutableMap::class.java).toString(), SchemaDefinition::class.java) if (schemaDefinition?.schemaName.isNullOrEmpty()) throw InvalidSchemaDefException("Invalid schema_name provided") 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 6020fd0d..1f513cec 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 @@ -87,7 +87,7 @@ class ServiceBusProcessor { createReportMessage.contentType!!, messageId, //createReportMessage.messageId is null messageStatus, //createReportMessage.status is null - createReportMessage.contentAsString!!, // it was Content I changed to ContentAsString + createReportMessage.content!!, // it was Content I changed to ContentAsString createReportMessage.dispositionType, Source.SERVICEBUS ) @@ -121,7 +121,7 @@ class ServiceBusProcessor { if (createReportMessage.contentType.isNullOrBlank()) { missingFields.add("contentType") } - if (createReportMessage.contentAsString.isNullOrBlank()) { + if (isNullOrEmpty(createReportMessage.content)) { missingFields.add("content") } @@ -135,13 +135,20 @@ class ServiceBusProcessor { createReportMessage.dataStreamRoute!!, createReportMessage.dispositionType, createReportMessage.contentType!!, - createReportMessage.contentAsString!!, + createReportMessage.content!!, missingFields) throw IllegalArgumentException(reason) } } - - + private fun isNullOrEmpty(value: Any?): Boolean { + return when (value) { + null -> true + is String -> value.isEmpty() + is Collection<*> -> value.isEmpty() + is Map<*, *> -> value.isEmpty() + else -> false // You can adjust this to your needs + } + } } \ No newline at end of file From ff46193c8d29e2a91e2ce1038fa7576e16f8f486 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 31 May 2024 16:16:01 -0400 Subject: [PATCH 07/91] Code cleanup on DASB-39 --- .../cdc/ocio/processingstatusapi/ReportManager.kt | 4 +--- .../processingstatusapi/models/DeadLetterReport.kt | 1 - .../cdc/ocio/processingstatusapi/models/Report.kt | 2 +- .../models/reports/CreateReportSBMessage.kt | 6 +----- .../models/reports/SchemaDefinition.kt | 1 - .../plugins/ServiceBusProcessor.kt | 13 ++++--------- 6 files changed, 7 insertions(+), 20 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 a676a2af..0a753828 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 @@ -3,7 +3,6 @@ package gov.cdc.ocio.processingstatusapi import com.azure.cosmos.models.CosmosItemRequestOptions import com.azure.cosmos.models.CosmosQueryRequestOptions import com.azure.cosmos.models.PartitionKey -import com.azure.messaging.servicebus.models.DeadLetterOptions import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.ToNumberPolicy @@ -23,8 +22,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.* import io.netty.handler.codec.http.HttpResponseStatus -import java.sql.Timestamp -import java.time.LocalDateTime + /** * The report manager interacts directly with CosmosDB to persist and retrieve reports. diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt index 4a3e96fc..631e2971 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt @@ -13,7 +13,6 @@ import java.util.* * @property dataStreamId String? * @property dataStreamRoute String? * @property dispositionType DispositionType? - * @property dexIngestionDateTime DateTime? * @property timestamp Date * @property contentType String? * @property content String? 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 f1c2b086..84a96e4b 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 @@ -1,6 +1,6 @@ package gov.cdc.ocio.processingstatusapi.models -import com.google.gson.* + import com.google.gson.annotations.SerializedName import java.util.* 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/CreateReportSBMessage.kt index 244857f4..ba7dc2e5 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/CreateReportSBMessage.kt @@ -1,12 +1,8 @@ package gov.cdc.ocio.processingstatusapi.models.reports -import com.google.gson.Gson import com.google.gson.annotations.SerializedName -import com.google.gson.reflect.TypeToken -import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException import gov.cdc.ocio.processingstatusapi.models.ServiceBusMessage -import java.lang.ClassCastException -import java.util.* + /** * Create a report service bus message. diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/SchemaDefinition.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/SchemaDefinition.kt index 205d39d8..450e640a 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/SchemaDefinition.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/SchemaDefinition.kt @@ -2,7 +2,6 @@ package gov.cdc.ocio.processingstatusapi.models.reports import com.google.gson.Gson import com.google.gson.annotations.SerializedName -import com.google.gson.reflect.TypeToken import gov.cdc.ocio.processingstatusapi.exceptions.InvalidSchemaDefException /** 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 1f513cec..f86c1110 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 @@ -1,8 +1,7 @@ package gov.cdc.ocio.processingstatusapi.plugins -import com.azure.messaging.servicebus.ServiceBusClientBuilder + import com.azure.messaging.servicebus.ServiceBusReceivedMessage -import com.azure.messaging.servicebus.models.DeadLetterOptions import com.google.gson.GsonBuilder import com.google.gson.JsonSyntaxException import com.google.gson.ToNumberPolicy @@ -12,11 +11,7 @@ import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage import gov.cdc.ocio.processingstatusapi.models.reports.Source import mu.KotlinLogging -import java.sql.Timestamp -import java.time.Instant -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.format.DateTimeFormatter + import java.util.* /** @@ -42,9 +37,9 @@ class ServiceBusProcessor { */ @Throws(BadRequestException::class) fun withMessage(message: ServiceBusReceivedMessage) { - var sbMessageId = message.messageId + val sbMessageId = message.messageId var sbMessage = message.body.toString() - var sbMessageStatus = message.state.name + val sbMessageStatus = message.state.name try { logger.info { "Before Message received = $sbMessage" } if (sbMessage.contains("destination_id")) { From 647bfc7a82e376d16db6978e0b6344ebcf42d618 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Mon, 3 Jun 2024 11:57:40 -0400 Subject: [PATCH 08/91] Code cleanup on DASB-39 --- .../gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt index 631e2971..4941efea 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt @@ -1,6 +1,6 @@ package gov.cdc.ocio.processingstatusapi.models -import com.google.gson.* + import com.google.gson.annotations.SerializedName import java.time.LocalDateTime import java.util.* From 1a832f86a50d31a33d784ad878987dc22a412f31 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 5 Jun 2024 10:14:48 -0400 Subject: [PATCH 09/91] Updated Readme --- pstatus-report-sink-ktor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/README.md b/pstatus-report-sink-ktor/README.md index a5e6d231..9f529c5c 100644 --- a/pstatus-report-sink-ktor/README.md +++ b/pstatus-report-sink-ktor/README.md @@ -1,5 +1,5 @@ # Overview -This project is the processing status report sink. It listens for messages on an Azure Service bus, validates the messages, and if validated persists them to CosmosDB. +This project is the processing status report sink. It listens for messages on an Azure Service bus queues and topics, validates the messages, and if validated persists them to CosmosDB. If the validation fails due to missing fields or malformed data, then the message is persisted in cosmosdb under a new dead-letter container and the message is also sent to the dead-letter queue under the configured topic subscription(if the message was processed using the topic listener) This is a microservice built using Ktor that can be built as a docker container image. From 04fe08e3a2d77845ca6f6e70d0a110839915a635 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 5 Jun 2024 11:30:42 -0400 Subject: [PATCH 10/91] Removed the ps1 code --- .../src/main/ps1/SendMessageToQueue.ps1 | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 b/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 index 89a7868b..e69de29b 100644 --- a/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 +++ b/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 @@ -1,23 +0,0 @@ -# Set the parameters for the Service Bus -$connectionString = "Endpoint=sb://ocio-ede-dev-processingstatus-testing.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=EkH3H/i66rBE+xTmTdIqMFmlxzYmsteP7+ASbJMhc5w=" -$queueName = "reports-notifications-queue" - -# Import the necessary assemblies -Add-Type -AssemblyName "System.ServiceModel" -Add-Type -Path (Join-Path -Path (Split-Path $env:PSModulePath -Parent) -ChildPath 'Azure.ServiceBus\2.1.1\lib\netstandard2.0\Microsoft.Azure.ServiceBus.dll') - -# Create a queue client -$queueClient = [Microsoft.Azure.ServiceBus.QueueClient]::new($connectionString, $queueName) - -# Create a message -$messageBody = "Hello, Azure Service Bus!" -$message = [Microsoft.Azure.ServiceBus.Message]::new([System.Text.Encoding]::UTF8.GetBytes($messageBody)) - -# Send the message to the queue -$queueClient.SendAsync($message).GetAwaiter().GetResult() - -# Print confirmation -Write-Output "Sent message: $messageBody" - -# Close the client -$queueClient.CloseAsync().GetAwaiter().GetResult() From 10c057536628a1e1a76af7a4d0c89557c827f138 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 5 Jun 2024 11:33:05 -0400 Subject: [PATCH 11/91] Removed the ps1 code --- pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 diff --git a/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 b/pstatus-report-sink-ktor/src/main/ps1/SendMessageToQueue.ps1 deleted file mode 100644 index e69de29b..00000000 From 111da861821ac72055020eeb9ba52440dfef9f11 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 5 Jun 2024 11:42:09 -0400 Subject: [PATCH 12/91] Initial commit for DASB-433 --- .../ocio/processingstatusapi/Application.kt | 3 + .../cosmos/CosmosClientManager.kt | 1 + .../cosmos/CosmosRepository.kt | 7 ++- .../dataloaders/ReportDataLoader.kt | 17 +++++- .../loaders/CosmosDeadLetterLoader.kt | 17 ++++++ .../loaders/ReportDeadLetterLoader.kt | 55 +++++++++++++++++++ .../models/ReportDeadLetter.kt | 52 ++++++++++++++++++ .../processingstatusapi/plugins/GraphQL.kt | 4 +- 8 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt 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 34858d55..caa1941f 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 @@ -1,5 +1,6 @@ package gov.cdc.ocio.processingstatusapi +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.plugins.graphQLModule import graphql.scalars.ExtendedScalars @@ -16,6 +17,8 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp val uri = environment.config.property("azure.cosmos_db.client.endpoint").getString() val authKey = environment.config.property("azure.cosmos_db.client.key").getString() single(createdAtStart = true) { CosmosRepository(uri, authKey, "Reports", "/uploadId") } + single(createdAtStart = true) { CosmosDeadLetterRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } + } return modules(listOf(cosmosModule)) } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt index 7690f004..f952f533 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt @@ -16,6 +16,7 @@ class CosmosClientManager { .endpoint(uri) .key(authKey) .consistencyLevel(ConsistencyLevel.EVENTUAL) + .gatewayMode() .contentResponseOnWriteEnabled(true) .clientTelemetryEnabled(false) .buildClient() diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt index 869c7791..14f5dfe5 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt @@ -6,4 +6,9 @@ class CosmosRepository(uri: String, authKey: String, reportsContainerName: Strin val reportsContainer = CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey) -} \ No newline at end of file +} + +class CosmosDeadLetterRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { + val reportsDeadLetterContainer = + CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! +} diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/dataloaders/ReportDataLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/dataloaders/ReportDataLoader.kt index 4c3a1af7..cb129b1f 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/dataloaders/ReportDataLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/dataloaders/ReportDataLoader.kt @@ -1,20 +1,33 @@ package gov.cdc.ocio.processingstatusapi.dataloaders import com.expediagroup.graphql.dataloader.KotlinDataLoader +import gov.cdc.ocio.processingstatusapi.loaders.ReportDeadLetterLoader import gov.cdc.ocio.processingstatusapi.models.Report import gov.cdc.ocio.processingstatusapi.loaders.ReportLoader +import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter import kotlinx.coroutines.runBlocking import graphql.GraphQLContext import org.dataloader.DataLoader import org.dataloader.DataLoaderFactory -import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletableFuture.* val ReportDataLoader = object : KotlinDataLoader { override val dataLoaderName = "REPORTS_LOADER" override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> - CompletableFuture.supplyAsync { + supplyAsync { runBlocking { ReportLoader().search(ids).toMutableList() } } } } + + +val ReportDeadLetterDataLoader = object : KotlinDataLoader { + override val dataLoaderName = "REPORTS_DEAD_LETTER_LOADER" + override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = + DataLoaderFactory.newDataLoader { ids -> + supplyAsync { + runBlocking { ReportDeadLetterLoader().search(ids).toMutableList() } + } + } +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt new file mode 100644 index 00000000..989a2ac7 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt @@ -0,0 +1,17 @@ +package gov.cdc.ocio.processingstatusapi.loaders + +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository +import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +open class CosmosDeadLetterLoader: KoinComponent { + + private val cosmosRepository by inject() + + protected val reportsDeadLetterContainerName = "Reports-DeadLetter" + + protected val reportsDeadLetterContainer = cosmosRepository.reportsDeadLetterContainer + + protected val logger = KotlinLogging.logger {} +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt new file mode 100644 index 00000000..d897f0ca --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -0,0 +1,55 @@ +package gov.cdc.ocio.processingstatusapi.loaders + +import com.azure.cosmos.models.CosmosQueryRequestOptions +import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter +import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao +import java.time.ZoneOffset + +class ReportDeadLetterLoader : CosmosDeadLetterLoader() { + + fun getByUploadId(uploadId: String): List { + val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.uploadId = '$uploadId'" + + val reportItems = reportsDeadLetterContainer.queryItems( + reportsSqlQuery, CosmosQueryRequestOptions(), + ReportDao::class.java + ) + + val reports = mutableListOf() + reportItems?.forEach { reports.add(daoToReport(it)) } + + return reports + } + fun search(ids: List): List { + val quotedIds = ids.joinToString("\",\"", "\"", "\"") + + val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.id in ($quotedIds)" + + val reportItems = reportsDeadLetterContainer.queryItems( + reportsSqlQuery, CosmosQueryRequestOptions(), + ReportDao::class.java + ) + + val reports = mutableListOf() + reportItems?.forEach { reports.add(daoToReport(it)) } + + return reports + } + + + + private fun daoToReport(reportDao: ReportDao): ReportDeadLetter { + return ReportDeadLetter().apply { + this.id = reportDao.id + this.uploadId = reportDao.uploadId + this.reportId = reportDao.reportId + this.dataStreamId = reportDao.dataStreamId + this.dataStreamRoute = reportDao.dataStreamRoute + this.messageId = reportDao.messageId + this.status = reportDao.status + this.timestamp = reportDao.timestamp?.toInstant()?.atOffset(ZoneOffset.UTC) + this.contentType = reportDao.contentType + this.content = reportDao.contentAsType + } + } +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt new file mode 100644 index 00000000..b5098820 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt @@ -0,0 +1,52 @@ +package gov.cdc.ocio.processingstatusapi.models + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition +import java.time.OffsetDateTime + +/** + * Report for a given stage. + * + * @property uploadId String? + * @property reportId String? + * @property dataStreamId String? + * @property dataStreamRoute String? + * @property contentType String? + * @property messageId String? + * @property status String? + * @property content String? + * @property timestamp OffsetDateTime + */ +@GraphQLDescription("Contains Report DeadLetter content.") +data class ReportDeadLetter ( + + @GraphQLDescription("Identifier of the report recorded by the database") + var id : String? = null, + + @GraphQLDescription("Upload identifier this report belongs to") + var uploadId: String? = null, + + @GraphQLDescription("Unique report identifier") + var reportId: String? = null, + + @GraphQLDescription("Data stream ID") + var dataStreamId: String? = null, + + @GraphQLDescription("Data stream route") + var dataStreamRoute: String? = null, + + @GraphQLDescription("Indicates the content type of the content; e.g. JSON, XML") + var contentType : String? = null, + + @GraphQLDescription("Message id this report belongs to; set to null if not applicable") + var messageId: String? = null, + + @GraphQLDescription("Status this report is indicating, such as success or failure") + var status : String? = null, + + @GraphQLDescription("Content of the report. If the report is JSON then the content will be shown as JSON. Otherwise, the content is a base64 encoded string.") + var content: SchemaDefinition? = null, + + @GraphQLDescription("Datestamp the report was recorded in the database") + var timestamp: OffsetDateTime? = null +) \ No newline at end of file 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 02325e50..04377c23 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 @@ -5,6 +5,7 @@ import com.auth0.jwt.algorithms.Algorithm import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory 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.queries.HealthQueryService import gov.cdc.ocio.processingstatusapi.queries.ReportCountsQueryService import gov.cdc.ocio.processingstatusapi.queries.ReportQueryService @@ -75,7 +76,8 @@ fun Application.graphQLModule() { } engine { dataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory( - ReportDataLoader + ReportDataLoader, + ReportDeadLetterDataLoader ) } } From f3fd0939e9a344366127cc965f67461fc8fee84c Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 6 Jun 2024 10:24:36 -0400 Subject: [PATCH 13/91] Initial commit for DASB-433 --- .../loaders/CosmosDeadLetterLoader.kt | 2 +- .../loaders/ReportDeadLetterLoader.kt | 17 ++++++++- .../processingstatusapi/plugins/GraphQL.kt | 6 ++-- .../queries/ReportDeadLetterQueryService.kt | 36 +++++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt index 989a2ac7..590f3502 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt @@ -9,7 +9,7 @@ open class CosmosDeadLetterLoader: KoinComponent { private val cosmosRepository by inject() - protected val reportsDeadLetterContainerName = "Reports-DeadLetter" + protected val reportsDeadLetterContainerName = "ReportsDeadLetter" protected val reportsDeadLetterContainer = cosmosRepository.reportsDeadLetterContainer diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt index d897f0ca..19d9a9ed 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -8,7 +8,22 @@ import java.time.ZoneOffset class ReportDeadLetterLoader : CosmosDeadLetterLoader() { fun getByUploadId(uploadId: String): List { - val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.uploadId = '$uploadId'" + val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.id = '$uploadId'" + + val reportItems = reportsDeadLetterContainer.queryItems( + reportsSqlQuery, CosmosQueryRequestOptions(), + ReportDao::class.java + ) + + val reports = mutableListOf() + reportItems?.forEach { reports.add(daoToReport(it)) } + + return reports + } + fun getByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String, startDate:String, endDate:String): List { + val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + + "and dataStreamRoute= '$dataStreamRoute' and r.timestamp >='$startDate' " + + "and r.timestamp='$endDate'" val reportItems = reportsDeadLetterContainer.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), 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 04377c23..84a2f579 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 @@ -6,10 +6,7 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory 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.queries.HealthQueryService -import gov.cdc.ocio.processingstatusapi.queries.ReportCountsQueryService -import gov.cdc.ocio.processingstatusapi.queries.ReportQueryService -import gov.cdc.ocio.processingstatusapi.queries.UploadQueryService +import gov.cdc.ocio.processingstatusapi.queries.* import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.server.application.* @@ -67,6 +64,7 @@ fun Application.graphQLModule() { HealthQueryService(), ReportQueryService(), ReportCountsQueryService(), + ReportDeadLetterQueryService(), UploadQueryService() ) // subscriptions = listOf( diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt new file mode 100644 index 00000000..bfec75fe --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt @@ -0,0 +1,36 @@ +package gov.cdc.ocio.processingstatusapi.queries + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import com.expediagroup.graphql.server.operations.Query +import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDataLoader +import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDeadLetterDataLoader +import gov.cdc.ocio.processingstatusapi.loaders.ReportDeadLetterLoader +import gov.cdc.ocio.processingstatusapi.models.Report +import gov.cdc.ocio.processingstatusapi.loaders.ReportLoader +import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter +import graphql.schema.DataFetchingEnvironment +import java.util.concurrent.CompletableFuture + +class ReportDeadLetterQueryService : Query { + + @GraphQLDescription("Return all the dead-letter reports associated with the provided uploadId") + @Suppress("unused") + fun getDeadLetterReportsByUploadId(uploadId: String) = ReportDeadLetterLoader().getByUploadId(uploadId) + + @GraphQLDescription("Return all the dead-letter reports associated with the provided datastreamId, datastreamroute and timestamp date range") + @Suppress("unused") + fun getDeadLetterReportsReportsByDataStream(dataStreamId: String, dataStreamRoute:String, startDate:String, endDate:String) + = ReportDeadLetterLoader().getByDataStreamByDateRange(dataStreamId,dataStreamRoute,startDate,endDate) + + @GraphQLDescription("Return list of dead-letter reports based on ReportSearchParameters options") + @Suppress("unused") + fun searchDeadLetterReports(params: ReportDeadLetterSearchParameters, dfe: DataFetchingEnvironment): CompletableFuture> = + dfe.getDataLoader(ReportDeadLetterDataLoader.dataLoaderName) + .loadMany(params.ids) +} + +@GraphQLDescription("Parameters for searching for reports") +data class ReportDeadLetterSearchParameters( + @GraphQLDescription("Array of report IDs to search for and retrieve") + val ids: List +) \ No newline at end of file From 5c7becd1287270b9b0ff851cdec82541b7daf399 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 6 Jun 2024 10:25:57 -0400 Subject: [PATCH 14/91] Modified the CosmosContainerManager to use the original way of creating a cosmos db --- .../processingstatusapi/cosmos/CosmosContainerManager.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt index 49b28d97..0c60e9bc 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt @@ -17,11 +17,9 @@ class CosmosContainerManager { val logger = KotlinLogging.logger {} logger.info("Create database $databaseName if not exists...") // Create database if not exists - //TODO : These 2 lines are throwing exceptions - // val databaseResponse = cosmosClient.createDatabaseIfNotExists(databaseName) - // return cosmosClient.getDatabase(databaseResponse.properties.id) - - return cosmosClient.getDatabase(databaseName) + //Alternate way is : - return cosmosClient.getDatabase(databaseName) + val databaseResponse = cosmosClient.createDatabaseIfNotExists(databaseName) + return cosmosClient.getDatabase(databaseResponse.properties.id) } fun initDatabaseContainer(uri: String, authKey: String, containerName: String, partitionKey: String): CosmosContainer? { From 119813eea37129aa3004cb5d7da227ea786543e3 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 7 Jun 2024 13:45:35 -0400 Subject: [PATCH 15/91] Added the graphQL query to get a count of deadletter reports for a given data stream id and date range. Optionally a data stream route may be provided as a parameter. for DASB-433 --- .../loaders/ReportDeadLetterLoader.kt | 53 +++++++++++++++---- .../queries/ReportDeadLetterQueryService.kt | 10 ++-- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt index 19d9a9ed..c9847b9d 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -3,7 +3,9 @@ package gov.cdc.ocio.processingstatusapi.loaders import com.azure.cosmos.models.CosmosQueryRequestOptions import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao +import java.text.SimpleDateFormat import java.time.ZoneOffset +import java.util.* class ReportDeadLetterLoader : CosmosDeadLetterLoader() { @@ -20,20 +22,51 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { return reports } - fun getByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String, startDate:String, endDate:String): List { - val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + - "and dataStreamRoute= '$dataStreamRoute' and r.timestamp >='$startDate' " + - "and r.timestamp='$endDate'" - val reportItems = reportsDeadLetterContainer.queryItems( + fun getByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String, startDate:String, endDate:String): List { + val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed + val startDateParam: Long = formatter.parse(startDate).toInstant().toEpochMilli() + val endDateParam : Long = formatter.parse(endDate).toInstant().toEpochMilli() + + val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + + "and r.dataStreamRoute= '$dataStreamRoute' and r.timestamp >=$startDateParam " + + "and r.timestamp<=$endDateParam" + + val reportItems = reportsDeadLetterContainer.queryItems( + reportsSqlQuery, CosmosQueryRequestOptions(), + ReportDao::class.java + ) + + val reports = mutableListOf() + reportItems?.forEach { reports.add(daoToReport(it)) } + + return reports + } + + fun getCountByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String?, startDate:String, endDate:String): Int{ + val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed + val startDateParam: Long = formatter.parse(startDate).toInstant().toEpochMilli() + val endDateParam : Long = formatter.parse(endDate).toInstant().toEpochMilli() + + val reportsSqlQuery = "select value count(1) from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + + "and r.timestamp >=$startDateParam " + + "and r.timestamp<=$endDateParam" + if (dataStreamRoute!=null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" + + val reportItems = reportsDeadLetterContainer.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), - ReportDao::class.java + Int::class.java ) + var count = 0 + if (reportItems.iterator().hasNext()) { + count = reportItems.iterator().next() + println("Count of records: $count") + } else { + println("Count of records: 0") - val reports = mutableListOf() - reportItems?.forEach { reports.add(daoToReport(it)) } - - return reports + } + return count } fun search(ids: List): List { val quotedIds = ids.joinToString("\",\"", "\"", "\"") diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt index bfec75fe..c2af2306 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt @@ -2,11 +2,8 @@ package gov.cdc.ocio.processingstatusapi.queries import com.expediagroup.graphql.generator.annotations.GraphQLDescription import com.expediagroup.graphql.server.operations.Query -import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDataLoader import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDeadLetterDataLoader import gov.cdc.ocio.processingstatusapi.loaders.ReportDeadLetterLoader -import gov.cdc.ocio.processingstatusapi.models.Report -import gov.cdc.ocio.processingstatusapi.loaders.ReportLoader import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter import graphql.schema.DataFetchingEnvironment import java.util.concurrent.CompletableFuture @@ -19,9 +16,14 @@ class ReportDeadLetterQueryService : Query { @GraphQLDescription("Return all the dead-letter reports associated with the provided datastreamId, datastreamroute and timestamp date range") @Suppress("unused") - fun getDeadLetterReportsReportsByDataStream(dataStreamId: String, dataStreamRoute:String, startDate:String, endDate:String) + fun getDeadLetterReportsByDataStream(dataStreamId: String, dataStreamRoute:String, startDate:String, endDate:String) = ReportDeadLetterLoader().getByDataStreamByDateRange(dataStreamId,dataStreamRoute,startDate,endDate) + @GraphQLDescription("Return count of dead-letter reports associated with the provided datastreamId, (optional) datastreamroute and timestamp date range") + @Suppress("unused") + fun getDeadLetterReportsCountByDataStream(dataStreamId: String, dataStreamRoute:String?, startDate:String, endDate:String) + = ReportDeadLetterLoader().getCountByDataStreamByDateRange(dataStreamId,dataStreamRoute,startDate,endDate) + @GraphQLDescription("Return list of dead-letter reports based on ReportSearchParameters options") @Suppress("unused") fun searchDeadLetterReports(params: ReportDeadLetterSearchParameters, dfe: DataFetchingEnvironment): CompletableFuture> = From f89aa0d66b3abb4a29a740fae1cdc233fb92cda0 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 12 Jun 2024 11:55:40 -0400 Subject: [PATCH 16/91] Added unit tests and based on that modified code accordingly --- .../gradle/wrapper/gradle-wrapper.properties | 4 +- pstatus-report-sink-ktor/build.gradle | 45 +++++ .../ocio/processingstatusapi/ReportManager.kt | 27 ++- .../processingstatusapi/plugins/ServiceBus.kt | 13 +- .../plugins/ServiceBusProcessor.kt | 56 +++--- ...rvice_bus_content_missing_schema_name.json | 10 + ...ce_bus_content_missing_schema_version.json | 10 + .../data/service_bus_escape_quoted_json.json | 8 + .../kotlin/data/service_bus_good_message.json | 144 ++++++++++++++ .../data/service_bus_good_message_V1.json | 144 ++++++++++++++ .../data/service_bus_missing_content.json | 7 + .../service_bus_missing_content_type.json | 7 + .../service_bus_missing_data_stream_id.json | 7 + ...service_bus_missing_data_stream_route.json | 7 + .../data/service_bus_missing_stage_name.json | 7 + .../data/service_bus_missing_upload_id.json | 143 ++++++++++++++ .../src/test/kotlin/test/ServiceBusTests.kt | 181 ++++++++++++++++++ 17 files changed, 777 insertions(+), 43 deletions(-) create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_content_missing_schema_name.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_content_missing_schema_version.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_escape_quoted_json.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_good_message.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_good_message_V1.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_content.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_content_type.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_data_stream_id.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_data_stream_route.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_stage_name.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_upload_id.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt diff --git a/processing-status-api-function-app/gradle/wrapper/gradle-wrapper.properties b/processing-status-api-function-app/gradle/wrapper/gradle-wrapper.properties index bdc9a83b..fbcb72cf 100644 --- a/processing-status-api-function-app/gradle/wrapper/gradle-wrapper.properties +++ b/processing-status-api-function-app/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ +#Fri Jun 07 10:39:15 EDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip -networkTimeout=10000 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index 7383d277..1f06272e 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -45,6 +45,51 @@ dependencies { implementation("io.insert-koin:koin-ktor:3.5.6") testImplementation("io.ktor:ktor-server-tests-jvm") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") + + implementation "com.microsoft.azure.functions:azure-functions-java-library:3.0.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" + implementation 'io.opentelemetry:opentelemetry-api:1.29.0' + implementation 'io.opentelemetry:opentelemetry-sdk:1.29.0' + implementation 'io.opentelemetry:opentelemetry-exporter-logging:1.29.0' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.29.0' + implementation 'io.opentelemetry:opentelemetry-semconv:1.29.0-alpha' + 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.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' + implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9' + + implementation 'org.owasp.encoder:encoder:1.2.3' + implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.3.1' + + 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" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + testImplementation "org.mockito:mockito-inline:3.11.2" + testImplementation "io.mockk:mockk:1.13.9" + + +} +test { + useTestNG() + testLogging { + events "passed", "skipped", "failed" + } + systemProperty("isTestEnvironment", "true") + + // Set the test classpath, if required } jib { 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 0a753828..05ca3a13 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 @@ -76,8 +76,21 @@ class ReportManager: KoinComponent { } catch(e: Exception) { throw BadRequestException("Malformed message: ${e.localizedMessage}") } - - return createReport(uploadId, dataStreamId, dataStreamRoute, stageName, contentType, messageId, status, content, dispositionType, source) + if (System.getProperty("isTestEnvironment") != "true") { + return createReport( + uploadId, + dataStreamId, + dataStreamRoute, + stageName, + contentType, + messageId, + status, + content, + dispositionType, + source + ) + } + return uploadId // this is just as a fallback } /** @@ -233,11 +246,11 @@ class ReportManager: KoinComponent { } @Throws(BadStateException::class) - fun createDeadLetterReport(uploadId: String, - dataStreamId: String, - dataStreamRoute: String, + fun createDeadLetterReport(uploadId: String?, + dataStreamId: String?, + dataStreamRoute: String?, dispositionType: DispositionType, - contentType: String, + contentType: String?, content: Any?, deadLetterReasons: List ): String { @@ -251,7 +264,7 @@ class ReportManager: KoinComponent { this.dispositionType= dispositionType.toString() this.contentType = contentType this.deadLetterReasons= deadLetterReasons - if (contentType.lowercase() == "json") { + if (contentType?.lowercase() == "json") { val typeObject = object : TypeToken?>() {}.type val jsonMap: Map = gson.fromJson(Gson().toJson(content, MutableMap::class.java).toString(), typeObject) this.content = jsonMap diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index e922bb7a..28d92e7b 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -2,17 +2,13 @@ package gov.cdc.ocio.processingstatusapi.plugins import com.azure.core.amqp.AmqpTransportType import com.azure.core.amqp.exception.AmqpException -import com.azure.core.exception.AzureException import com.azure.messaging.servicebus.* import com.azure.messaging.servicebus.models.DeadLetterOptions -import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager -import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import io.ktor.server.application.* import io.ktor.server.application.hooks.* import io.ktor.server.config.* import io.ktor.util.logging.* -import io.netty.channel.ConnectTimeoutException import org.apache.qpid.proton.engine.TransportException import java.util.concurrent.TimeUnit @@ -116,14 +112,15 @@ private fun processMessage(context: ServiceBusReceivedMessageContext) { ServiceBusProcessor().withMessage(message) } catch (e: BadRequestException) { LOGGER.warn("Unable to parse the message: {}", e.localizedMessage) - } - catch (e: IllegalArgumentException) { // TODO : Is this the only exception at this time or more generic one??? - LOGGER.warn("Message rejected: {}", e.localizedMessage) val deadLetterOptions = DeadLetterOptions() .setDeadLetterReason("Validation failed") .setDeadLetterErrorDescription(e.message) context.deadLetter(deadLetterOptions) - LOGGER.info("Message sent to the dead-letter queue.") + LOGGER.info("Message sent to the dead-letter queue.") + } + catch (e: IllegalArgumentException) { // TODO : Is this the only exception at this time or more generic one??? + LOGGER.warn("Message rejected: {}", e.localizedMessage) + } catch (e: Exception) { LOGGER.warn("Failed to process service bus message: {}", e.localizedMessage) 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 f86c1110..072196bb 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 @@ -51,7 +51,7 @@ class ServiceBusProcessor { logger.info { "After Message received = $sbMessage" } createReport(sbMessageId,sbMessageStatus,gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) } - catch (e:IllegalArgumentException){ + catch (e:BadRequestException){ println("Validation failed: ${e.message}") throw e } @@ -74,20 +74,22 @@ class ServiceBusProcessor { val uploadId = createReportMessage.uploadId val stageName = createReportMessage.stageName logger.info("Creating report for uploadId = ${uploadId} with stageName = $stageName") - ReportManager().createReportWithUploadId( - createReportMessage.uploadId!!, - createReportMessage.dataStreamId!!, - createReportMessage.dataStreamRoute!!, - createReportMessage.stageName!!, - createReportMessage.contentType!!, - messageId, //createReportMessage.messageId is null - messageStatus, //createReportMessage.status is null - createReportMessage.content!!, // it was Content I changed to ContentAsString - createReportMessage.dispositionType, - Source.SERVICEBUS - ) + + ReportManager().createReportWithUploadId( + createReportMessage.uploadId!!, + createReportMessage.dataStreamId!!, + createReportMessage.dataStreamRoute!!, + createReportMessage.stageName!!, + createReportMessage.contentType!!, + messageId, //createReportMessage.messageId is null + messageStatus, //createReportMessage.status is null + createReportMessage.content!!, // it was Content I changed to ContentAsString + createReportMessage.dispositionType, + Source.SERVICEBUS + ) + } - catch (e:IllegalArgumentException){ + catch (e:BadRequestException){ throw e } catch (e: Exception) { @@ -122,18 +124,20 @@ class ServiceBusProcessor { if (missingFields.isNotEmpty()) { val reason ="Missing fields: ${missingFields.joinToString(", ")}" - - // Write the content of the deadletter reports to CosmosDb - ReportManager().createDeadLetterReport( - createReportMessage.uploadId!!, - createReportMessage.dataStreamId!!, - createReportMessage.dataStreamRoute!!, - createReportMessage.dispositionType, - createReportMessage.contentType!!, - createReportMessage.content!!, - missingFields) - - throw IllegalArgumentException(reason) + //This should not run for unit tests + if (System.getProperty("isTestEnvironment") != "true") { + // Write the content of the deadletter reports to CosmosDb + ReportManager().createDeadLetterReport( + createReportMessage.uploadId, + createReportMessage.dataStreamId, + createReportMessage.dataStreamRoute, + createReportMessage.dispositionType, + createReportMessage.contentType, + createReportMessage.content, + missingFields + ) + } + throw BadRequestException(reason) } } diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_content_missing_schema_name.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_content_missing_schema_name.json new file mode 100644 index 00000000..9bd417c0 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_content_missing_schema_name.json @@ -0,0 +1,10 @@ +{ + "upload_id": "4f99cfde-7c83-44e5-bd49-206ca4df7e16", + "data_stream_id": "dex-testing", + "data_stream_route": "test-event1", + "stage_name": "dex-hl7-validation", + "content_type": "json", + "content": { + "schema_version": "0.0.1" + } +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_content_missing_schema_version.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_content_missing_schema_version.json new file mode 100644 index 00000000..f39dd2c0 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_content_missing_schema_version.json @@ -0,0 +1,10 @@ +{ + "upload_id": "4f99cfde-7c83-44e5-bd49-206ca4df7e16", + "data_stream_id": "dex-testing", + "data_stream_route": "test-event1", + "stage_name": "dex-hl7-validation", + "content_type": "json", + "content": { + "schema_name": "dex-hl7-validation" + } +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_escape_quoted_json.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_escape_quoted_json.json new file mode 100644 index 00000000..75b1c8a4 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_escape_quoted_json.json @@ -0,0 +1,8 @@ +{ + "upload_id": "12345678", + "data_stream_id": "dex-testing", + "data_stream_route": "test-event1", + "stage_name": "dex-upload", + "content_type": "json", + "content": "{\"schema_name\":\"upload\",\"schema_version\":\"1.0\",\"tguid\":\"12345678\",\"offset\":27472691,\"size\":27472691,\"filename\":\"some_upload1.csv\",\"meta_destination_id\":\"ndlp\",\"meta_ext_event\":\"routineImmunization\",\"end_time_epoch_millis\":1700009141546,\"start_time_epoch_millis\":1700009137234,\"metadata\":{\"filename\":\"10MB-test-file\",\"filetype\":\"text\/plain\",\"meta_destination_id\":\"ndlp\",\"meta_ext_event\":\"routineImmunization\",\"meta_ext_source\":\"IZGW\",\"meta_ext_sourceversion\":\"V2022-12-31\",\"meta_ext_entity\":\"DD2\",\"meta_username\":\"ygj6@cdc.gov\",\"meta_ext_objectkey\":\"2b18d70c-8559-11ee-b9d1-0242ac120002\",\"meta_ext_filename\":\"10MB-test-file\",\"meta_ext_submissionperiod\":\"1\"}}" +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_good_message.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_good_message.json new file mode 100644 index 00000000..cb416e79 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_good_message.json @@ -0,0 +1,144 @@ +{ + "upload_id": "4f99cfde-7c83-44e5-bd49-206ca4df7e16", + "data_stream_id": "dex-testing", + "data_stream_route": "test-event1", + "stage_name": "dex-hl7-validation", + "content_type": "json", + "content": { + "id": "dd1c8c4b-a18d-4449-aaf3-5f0ff505bf38", + "message_uuid": "dd1c8c4b-a18d-4449-aaf3-5f0ff505bf38", + "message_info": { + "event_code": "10150", + "route": "vaccine_preventable_diseases", + "reporting_jurisdiction": "13", + "type": "CASE", + "local_record_id": "SAI" + }, + "metadata": { + "provenance": { + "event_id": "9b36fb63-e01e-0066-11ab-2ee31d06a24b", + "event_timestamp": "2023-12-14T16:36:47.910526Z", + "file_uuid": "03cb333c-46c0-4a96-8bab-bf494df87528", + "file_path": "https://tfedemessagestoragedev.blob.core.windows.net/hl7ingress/postman-dtx0-c679a16e-fbfc-4388-9147-18d8a605abc0.txt", + "file_timestamp": "2023-12-14T11:36:47-05:00", + "file_size": 14813, + "single_or_batch": "SINGLE", + "message_hash": "74ada45a7c41c98c574b6c4f5c8e1d26", + "ext_system_provider": "POSTMAN", + "ext_original_file_name": "postman-dtx0-c679a16e-fbfc-4388-9147-18d8a605abc0.txt", + "message_index": 1, + "ext_original_file_timestamp": "2023-03-09T12:05:04.074", + "source_metadata": { + "orginal_file_name": "test-d4731f4d-b119-4d78-a0b1-d322658d1d3d.txt" + } + }, + "processes": [ + { + "status": "SUCCESS", + "process_name": "RECEIVER", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:48.847", + "eventhub_offset": 1125281432952, + "eventhub_sequence_number": 89766, + "configs": [], + "start_processing_time": "2023-12-14T11:36:48.892-05:00", + "end_processing_time": "2023-12-14T11:36:48.982-05:00" + }, + { + "status": "SUCCESS", + "report": { + "entries": [], + "status": "SUCCESS" + }, + "process_name": "REDACTOR", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:49.253", + "eventhub_offset": 1189705961896, + "eventhub_sequence_number": 86965, + "configs": [ + "case_config.txt" + ], + "start_processing_time": "2023-12-14T11:36:49.34-05:00", + "end_processing_time": "2023-12-14T11:36:49.436-05:00" + }, + { + "status": "SUCCESS", + "report": { + "entries": { + "structure": [ + { + "line": 4, + "column": 64, + "path": "OBX[1]-5[1]", + "description": "The primitive Field OBX-5 (Observation Value) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 6, + "column": 68, + "path": "OBX[3]-5[1]", + "description": "The primitive Field OBX-5 (Observation Value) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 2, + "column": 236, + "path": "PID[1]-11[1].9", + "description": "The primitive Component PID-11.9 (County/Parish Code) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 2, + "column": 195, + "path": "PID[1]-11[1].4", + "description": "The primitive Component PID-11.4 (State or Province) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 1, + "column": 226, + "path": "MSH[1]-10[1]", + "description": "The length of Field MSH-10 (Message Control ID) must be within the range [1, 20]. Value \\u003d \\u0027RIBD_N_Meningitidis_Case_Invalid_03\\u0027", + "category": "Length", + "classification": "Warning" + } + ], + "content": [], + "value-set": [] + }, + "error-count": { + "structure": 0, + "value-set": 0, + "content": 0 + }, + "warning-count": { + "structure": 5, + "value-set": 0, + "content": 0 + }, + "status": "VALID_MESSAGE" + }, + "process_name": "STRUCTURE-VALIDATOR", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:50.097", + "eventhub_offset": 1120986506816, + "eventhub_sequence_number": 96087, + "configs": [ + "NOTF_ORU_V3.0" + ], + "start_processing_time": "2023-12-14T11:36:50.133-05:00", + "end_processing_time": "2023-12-14T11:36:50.205-05:00" + } + ] + }, + "summary": { + "current_status": "VALID_MESSAGE" + }, + "schema_version": "0.0.1", + "schema_name": "dex-hl7-validation" + } +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_good_message_V1.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_good_message_V1.json new file mode 100644 index 00000000..74b6cd46 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_good_message_V1.json @@ -0,0 +1,144 @@ +{ + "upload_id": "4f99cfde-7c83-44e5-bd49-206ca4df7e16", + "destination_id": "dex-testing", + "event_type": "test-event1", + "stage_name": "dex-hl7-validation", + "content_type": "json", + "content": { + "id": "dd1c8c4b-a18d-4449-aaf3-5f0ff505bf38", + "message_uuid": "dd1c8c4b-a18d-4449-aaf3-5f0ff505bf38", + "message_info": { + "event_code": "10150", + "route": "vaccine_preventable_diseases", + "reporting_jurisdiction": "13", + "type": "CASE", + "local_record_id": "SAI" + }, + "metadata": { + "provenance": { + "event_id": "9b36fb63-e01e-0066-11ab-2ee31d06a24b", + "event_timestamp": "2023-12-14T16:36:47.910526Z", + "file_uuid": "03cb333c-46c0-4a96-8bab-bf494df87528", + "file_path": "https://tfedemessagestoragedev.blob.core.windows.net/hl7ingress/postman-dtx0-c679a16e-fbfc-4388-9147-18d8a605abc0.txt", + "file_timestamp": "2023-12-14T11:36:47-05:00", + "file_size": 14813, + "single_or_batch": "SINGLE", + "message_hash": "74ada45a7c41c98c574b6c4f5c8e1d26", + "ext_system_provider": "POSTMAN", + "ext_original_file_name": "postman-dtx0-c679a16e-fbfc-4388-9147-18d8a605abc0.txt", + "message_index": 1, + "ext_original_file_timestamp": "2023-03-09T12:05:04.074", + "source_metadata": { + "orginal_file_name": "test-d4731f4d-b119-4d78-a0b1-d322658d1d3d.txt" + } + }, + "processes": [ + { + "status": "SUCCESS", + "process_name": "RECEIVER", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:48.847", + "eventhub_offset": 1125281432952, + "eventhub_sequence_number": 89766, + "configs": [], + "start_processing_time": "2023-12-14T11:36:48.892-05:00", + "end_processing_time": "2023-12-14T11:36:48.982-05:00" + }, + { + "status": "SUCCESS", + "report": { + "entries": [], + "status": "SUCCESS" + }, + "process_name": "REDACTOR", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:49.253", + "eventhub_offset": 1189705961896, + "eventhub_sequence_number": 86965, + "configs": [ + "case_config.txt" + ], + "start_processing_time": "2023-12-14T11:36:49.34-05:00", + "end_processing_time": "2023-12-14T11:36:49.436-05:00" + }, + { + "status": "SUCCESS", + "report": { + "entries": { + "structure": [ + { + "line": 4, + "column": 64, + "path": "OBX[1]-5[1]", + "description": "The primitive Field OBX-5 (Observation Value) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 6, + "column": 68, + "path": "OBX[3]-5[1]", + "description": "The primitive Field OBX-5 (Observation Value) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 2, + "column": 236, + "path": "PID[1]-11[1].9", + "description": "The primitive Component PID-11.9 (County/Parish Code) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 2, + "column": 195, + "path": "PID[1]-11[1].4", + "description": "The primitive Component PID-11.4 (State or Province) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 1, + "column": 226, + "path": "MSH[1]-10[1]", + "description": "The length of Field MSH-10 (Message Control ID) must be within the range [1, 20]. Value \\u003d \\u0027RIBD_N_Meningitidis_Case_Invalid_03\\u0027", + "category": "Length", + "classification": "Warning" + } + ], + "content": [], + "value-set": [] + }, + "error-count": { + "structure": 0, + "value-set": 0, + "content": 0 + }, + "warning-count": { + "structure": 5, + "value-set": 0, + "content": 0 + }, + "status": "VALID_MESSAGE" + }, + "process_name": "STRUCTURE-VALIDATOR", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:50.097", + "eventhub_offset": 1120986506816, + "eventhub_sequence_number": 96087, + "configs": [ + "NOTF_ORU_V3.0" + ], + "start_processing_time": "2023-12-14T11:36:50.133-05:00", + "end_processing_time": "2023-12-14T11:36:50.205-05:00" + } + ] + }, + "summary": { + "current_status": "VALID_MESSAGE" + }, + "schema_version": "0.0.1", + "schema_name": "dex-hl7-validation" + } +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_content.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_content.json new file mode 100644 index 00000000..34bfc52b --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_content.json @@ -0,0 +1,7 @@ +{ + "upload_id": "12345678", + "stage_name": "dex-stage1", + "data_stream_id": "dex-testing", + "data_stream_route": "test-event1", + "content_type": "json" +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_content_type.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_content_type.json new file mode 100644 index 00000000..d863dc53 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_content_type.json @@ -0,0 +1,7 @@ +{ + "upload_id": "12345678", + "stage_name": "dex-stage1", + "data_stream_id": "dex-testing", + "data_stream_route": "test-event1" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_data_stream_id.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_data_stream_id.json new file mode 100644 index 00000000..5033bc4d --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_data_stream_id.json @@ -0,0 +1,7 @@ +{ + "upload_id": "12345678", + "data_stream_route": "test-event1", + "stage_name": "dex-stage1", + "content_type": "json", + "content": "" +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_data_stream_route.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_data_stream_route.json new file mode 100644 index 00000000..bbe6926a --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_data_stream_route.json @@ -0,0 +1,7 @@ +{ + "upload_id": "12345678", + "stage_name": "dex-stage1", + "data_stream_id": "dex-testing", + "content_type": "json", + "content": "" +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_stage_name.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_stage_name.json new file mode 100644 index 00000000..4dda4740 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_stage_name.json @@ -0,0 +1,7 @@ +{ + "upload_id": "12345678", + "data_stream_id": "dex-testing", + "data_stream_route": "test-event1", + "content_type": "json", + "content": "" +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_upload_id.json b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_upload_id.json new file mode 100644 index 00000000..df0e2405 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/service_bus_missing_upload_id.json @@ -0,0 +1,143 @@ +{ + "data_stream_route": "test-event1", + "stage_name": "dex-stage1", + "data_stream_id": "dex-testing", + "content_type": "json", + "content": { + "id": "dd1c8c4b-a18d-4449-aaf3-5f0ff505bf38", + "message_uuid": "dd1c8c4b-a18d-4449-aaf3-5f0ff505bf38", + "message_info": { + "event_code": "10150", + "route": "vaccine_preventable_diseases", + "reporting_jurisdiction": "13", + "type": "CASE", + "local_record_id": "SAI" + }, + "metadata": { + "provenance": { + "event_id": "9b36fb63-e01e-0066-11ab-2ee31d06a24b", + "event_timestamp": "2023-12-14T16:36:47.910526Z", + "file_uuid": "03cb333c-46c0-4a96-8bab-bf494df87528", + "file_path": "https://tfedemessagestoragedev.blob.core.windows.net/hl7ingress/postman-dtx0-c679a16e-fbfc-4388-9147-18d8a605abc0.txt", + "file_timestamp": "2023-12-14T11:36:47-05:00", + "file_size": 14813, + "single_or_batch": "SINGLE", + "message_hash": "74ada45a7c41c98c574b6c4f5c8e1d26", + "ext_system_provider": "POSTMAN", + "ext_original_file_name": "postman-dtx0-c679a16e-fbfc-4388-9147-18d8a605abc0.txt", + "message_index": 1, + "ext_original_file_timestamp": "2023-03-09T12:05:04.074", + "source_metadata": { + "orginal_file_name": "test-d4731f4d-b119-4d78-a0b1-d322658d1d3d.txt" + } + }, + "processes": [ + { + "status": "SUCCESS", + "process_name": "RECEIVER", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:48.847", + "eventhub_offset": 1125281432952, + "eventhub_sequence_number": 89766, + "configs": [], + "start_processing_time": "2023-12-14T11:36:48.892-05:00", + "end_processing_time": "2023-12-14T11:36:48.982-05:00" + }, + { + "status": "SUCCESS", + "report": { + "entries": [], + "status": "SUCCESS" + }, + "process_name": "REDACTOR", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:49.253", + "eventhub_offset": 1189705961896, + "eventhub_sequence_number": 86965, + "configs": [ + "case_config.txt" + ], + "start_processing_time": "2023-12-14T11:36:49.34-05:00", + "end_processing_time": "2023-12-14T11:36:49.436-05:00" + }, + { + "status": "SUCCESS", + "report": { + "entries": { + "structure": [ + { + "line": 4, + "column": 64, + "path": "OBX[1]-5[1]", + "description": "The primitive Field OBX-5 (Observation Value) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 6, + "column": 68, + "path": "OBX[3]-5[1]", + "description": "The primitive Field OBX-5 (Observation Value) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 2, + "column": 236, + "path": "PID[1]-11[1].9", + "description": "The primitive Component PID-11.9 (County/Parish Code) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 2, + "column": 195, + "path": "PID[1]-11[1].4", + "description": "The primitive Component PID-11.4 (State or Province) contains at least one unescaped delimiter", + "category": "Unescaped Separator", + "classification": "Warning" + }, + { + "line": 1, + "column": 226, + "path": "MSH[1]-10[1]", + "description": "The length of Field MSH-10 (Message Control ID) must be within the range [1, 20]. Value \\u003d \\u0027RIBD_N_Meningitidis_Case_Invalid_03\\u0027", + "category": "Length", + "classification": "Warning" + } + ], + "content": [], + "value-set": [] + }, + "error-count": { + "structure": 0, + "value-set": 0, + "content": 0 + }, + "warning-count": { + "structure": 5, + "value-set": 0, + "content": 0 + }, + "status": "VALID_MESSAGE" + }, + "process_name": "STRUCTURE-VALIDATOR", + "process_version": "1.0.0", + "eventhub_queued_time": "2023-12-14T16:36:50.097", + "eventhub_offset": 1120986506816, + "eventhub_sequence_number": 96087, + "configs": [ + "NOTF_ORU_V3.0" + ], + "start_processing_time": "2023-12-14T11:36:50.133-05:00", + "end_processing_time": "2023-12-14T11:36:50.205-05:00" + } + ] + }, + "summary": { + "current_status": "VALID_MESSAGE" + }, + "schema_version": "0.0.1", + "schema_name": "dex-hl7-validation" + } +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt b/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt new file mode 100644 index 00000000..a940bb8a --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt @@ -0,0 +1,181 @@ + +package test + +import com.google.gson.Gson +import com.microsoft.azure.functions.ExecutionContext +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager +import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException +import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage +import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition +import gov.cdc.ocio.processingstatusapi.plugins.ServiceBusProcessor +import io.mockk.* +import org.mockito.Mockito +import org.testng.Assert +import org.testng.annotations.BeforeMethod +import org.testng.annotations.Test +import java.io.File +import com.azure.messaging.servicebus.ServiceBusReceivedMessage + +class ServiceBusTests { + + private lateinit var context: ExecutionContext + + @BeforeMethod + fun setUp() { + context = Mockito.mock(ExecutionContext::class.java) + mockkObject(CosmosContainerManager) + every { CosmosContainerManager.initDatabaseContainer(any(), any(),any(), any()) } returns null + + } + @Test + fun testParseJsonContentSchemaDefinition() { + val testMessage = File("./src/test/kotlin/data/service_bus_good_message.json").readText() + + val createReportSBMessage = Gson().fromJson(testMessage, CreateReportSBMessage::class.java) + val schemaDefinition = SchemaDefinition.fromJsonString(createReportSBMessage.content) + + Assert.assertEquals(schemaDefinition.schemaName, "dex-hl7-validation") + Assert.assertEquals(schemaDefinition.schemaVersion, "0.0.1") + } + @Test + fun testServiceBusMessageMissingUploadId() { + val testMessage =File("./src/test/kotlin/data/service_bus_missing_upload_id.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + // Mock file + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Missing fields: uploadId" + } + Assert.assertTrue(exceptionThrown) + } + @Test + fun testServiceBusMessageMissingDataStreamId() { + val testMessage = File("./src/test/kotlin/data/service_bus_missing_data_stream_id.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Missing fields: dataStreamId, content" + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testServiceBusMessageMissingRoute() { + val testMessage = File("./src/test/kotlin/data/service_bus_missing_data_stream_route.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Missing fields: dataStreamRoute, content" + } + Assert.assertTrue(exceptionThrown) + } + @Test + fun testServiceBusMessageMissingStageName() { + val testMessage = File("./src/test/kotlin/data/service_bus_missing_stage_name.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Missing fields: stageName, content" + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testServiceBusMessageMissingContentType() { + val testMessage = File("./src/test/kotlin/data/service_bus_missing_content_type.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Missing fields: contentType, content" + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testServiceBusMessageMissingContent() { + val testMessage = File("./src/test/kotlin/data/service_bus_missing_content.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Missing fields: content" + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testServiceBusMessageContentMissingSchemaName() { + val testMessage = File("./src/test/kotlin/data/service_bus_content_missing_schema_name.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Invalid schema definition: Invalid schema_name provided" + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testServiceBusMessageContentMissingSchemaVersion() { + val testMessage = File("./src/test/kotlin/data/service_bus_content_missing_schema_version.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Invalid schema definition: Invalid schema_version provided" + } + Assert.assertTrue(exceptionThrown) + } + @Test + fun testServiceBusGoodMessage_V1() { + val testMessage = File("./src/test/kotlin/data/service_bus_good_message_V1.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } + + @Test + fun testServiceBusGoodMessage() { + val testMessage = File("./src/test/kotlin/data/service_bus_good_message.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } + + @Test + fun testServiceBusMessageEscapeQuotedJson() { + val testMessage = File("./src/test/kotlin/data/service_bus_escape_quoted_json.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage == "Malformed message: class java.lang.String cannot be cast to class java.util.Map (java.lang.String and java.util.Map are in module java.base of loader 'bootstrap')" + } + Assert.assertTrue(exceptionThrown) + } +} + +fun createServiceBusReceivedMessageFromString(messageBody: String): ServiceBusReceivedMessage { + val message = mockk(relaxed = true) + val messageId ="MessageId123" + val status ="Active" + every{ message.messageId } returns messageId + every{ message.state.name } returns status + every { message.body.toString() } returns messageBody + return message +} + + From b389b09b1eefe2c709915ceb935939d706f414f3 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 12 Jun 2024 15:48:29 -0400 Subject: [PATCH 17/91] Added class and function headers Added missing stageName from dead-letter report --- .../ocio/processingstatusapi/ReportManager.kt | 18 +++++++++++++ .../models/DeadLetterReport.kt | 5 +++- .../processingstatusapi/plugins/ServiceBus.kt | 27 ++++++++++++++++++- .../plugins/ServiceBusProcessor.kt | 10 ++++++- 4 files changed, 57 insertions(+), 3 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 05ca3a13..2914c66d 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 @@ -244,11 +244,24 @@ class ReportManager: KoinComponent { throw BadStateException("Failed to create reportId = ${stageReport.reportId}, uploadId = $uploadId") } + /** + * Creates a dead-letter report if there is a malformed data or missing required fields + * + * @param uploadId String + * @param dataStreamId String + * @param dataStreamRoute String + * @param stageName String + * @param contentType String + * @param content String + * @return String + * @throws BadStateException + */ @Throws(BadStateException::class) fun createDeadLetterReport(uploadId: String?, dataStreamId: String?, dataStreamRoute: String?, + stageName: String?, dispositionType: DispositionType, contentType: String?, content: Any?, @@ -261,6 +274,7 @@ class ReportManager: KoinComponent { this.uploadId = uploadId this.dataStreamId = dataStreamId this.dataStreamRoute = dataStreamRoute + this.stageName= stageName this.dispositionType= dispositionType.toString() this.contentType = contentType this.deadLetterReasons= deadLetterReasons @@ -323,6 +337,10 @@ class ReportManager: KoinComponent { throw BadStateException("Failed to create dead-letterReport reportId = ${deadLetterReport.reportId}, uploadId = $uploadId") } + /** + * The function which calculates the interval after which the retry should occur + * @param attempt Int + */ private fun getCalculatedRetryDuration(attempt: Int): Long { return DEFAULT_RETRY_INTERVAL_MILLIS * (attempt + 1) } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt index 4941efea..0de9b983 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt @@ -6,7 +6,7 @@ import java.time.LocalDateTime import java.util.* /** - * Report for a given stage. + * Dead-LetterReport when there is missing fields or malformed data. * * @property uploadId String? * @property reportId String? @@ -34,6 +34,9 @@ data class ReportDeadLetter( @SerializedName("data_stream_route") var dataStreamRoute: String? = null, + @SerializedName("stage_name") + var stageName: String? = null, + @SerializedName("disposition_type") var dispositionType: String? = null, diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index 28d92e7b..6a1190e2 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -14,6 +14,11 @@ import java.util.concurrent.TimeUnit internal val LOGGER = KtorSimpleLogger("pstatus-report-sink") +/** + * Class which initializes configuration values + * @param config ApplicationConfig + * + */ class AzureServiceBusConfiguration(config: ApplicationConfig) { var connectionString: String = config.tryGetString("connection_string") ?: "" var serviceBusNamespace: String = config.tryGetString("azure_servicebus_namespace") ?: "" @@ -60,7 +65,12 @@ val AzureServiceBus = createApplicationPlugin( .buildProcessorClient() } - // handles received messages + /** + * Function which starts receiving messages from queues and topics + * @throws AmqpException + * @throws TransportException + * @throws Exception generic + */ @Throws(InterruptedException::class) fun receiveMessages() { try { @@ -99,6 +109,13 @@ val AzureServiceBus = createApplicationPlugin( } } +/** + * Function which processes the message received in the queue or topics + * @param context ServiceBusReceivedMessageContext + * @throws BadRequestException + * @throws IllegalArgumentException + * @throws Exception generic + */ private fun processMessage(context: ServiceBusReceivedMessageContext) { val message = context.message @@ -127,6 +144,11 @@ private fun processMessage(context: ServiceBusReceivedMessageContext) { } } + +/** + * Function to handle and process the error generated during the processing of messages from queue or topics + * @param context ServiceBusErrorContext + */ private fun processError(context: ServiceBusErrorContext) { System.out.printf( "Error when receiving messages from namespace: '%s'. Entity: '%s'%n", @@ -160,6 +182,9 @@ private fun processError(context: ServiceBusErrorContext) { } } +/** + * The main application module which runs always + */ fun Application.serviceBusModule() { install(AzureServiceBus) { // any additional configuration goes here 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 072196bb..55816aaa 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 @@ -99,7 +99,11 @@ class ServiceBusProcessor { } - // Function to validate report + /** + * Function to validate report attributes for missing required fields + * @param createReportMessage CreateReportSBMessage + * @throws BadRequestException + */ private fun validateReport(createReportMessage: CreateReportSBMessage) { val missingFields = mutableListOf() @@ -131,6 +135,7 @@ class ServiceBusProcessor { createReportMessage.uploadId, createReportMessage.dataStreamId, createReportMessage.dataStreamRoute, + createReportMessage.stageName, createReportMessage.dispositionType, createReportMessage.contentType, createReportMessage.content, @@ -141,6 +146,9 @@ class ServiceBusProcessor { } } + /** Function to check whether the value is null or empty based on its type + * @param value Any + */ private fun isNullOrEmpty(value: Any?): Boolean { return when (value) { null -> true From f036cc75c7b0ced151904c2ad5b5a501f5c40c4d Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Thu, 13 Jun 2024 07:12:17 -0500 Subject: [PATCH 18/91] DASB-466 - Fixed the graphql description of the service health check (#116) --- .../cdc/ocio/processingstatusapi/queries/HealthQueryService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt index bb25be2d..f219f002 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt @@ -38,7 +38,7 @@ class HealthQueryService : Query { private val logger = KotlinLogging.logger {} - @GraphQLDescription("Return a single report from the provided uploadId") + @GraphQLDescription("Performs a service health check of the processing status API and it's dependencies.") @Suppress("unused") fun getHealth(): HealthCheck { var cosmosDBHealthy = false From 4ae75aee61ba972ed9aa8a54650105c23d762c74 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 13 Jun 2024 17:15:26 -0400 Subject: [PATCH 19/91] Updated the code based on PR comments --- pstatus-report-sink-ktor/build.gradle | 3 +- .../ocio/processingstatusapi/Application.kt | 20 +- .../ocio/processingstatusapi/ReportManager.kt | 191 +++++++++--------- .../cosmos/CosmosContainerManager.kt | 12 ++ .../cosmos/CosmosRepository.kt | 21 +- .../models/reports/CreateReportSBMessage.kt | 2 +- .../processingstatusapi/plugins/ServiceBus.kt | 10 +- .../plugins/ServiceBusProcessor.kt | 100 +++++---- 8 files changed, 211 insertions(+), 148 deletions(-) diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index 1f06272e..1e98b9ba 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -87,7 +87,8 @@ test { testLogging { events "passed", "skipped", "failed" } - systemProperty("isTestEnvironment", "true") + //Change this to "true" if we want to execute unit tests + systemProperty("isTestEnvironment", "false") // Set the test classpath, if required } 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 39e5aff0..62493c2e 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 @@ -10,23 +10,35 @@ import io.ktor.server.netty.* import org.koin.core.KoinApplication import org.koin.dsl.module import org.koin.ktor.plugin.Koin -import org.koin.mp.KoinPlatform.getKoin + +/** + * Load the environment configuration values + * Instantiate a singleton CosmosDatabase container instance + * @param environment ApplicationEnvironment + */ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinApplication { val cosmosModule = module { val uri = environment.config.property("azure.cosmos_db.client.endpoint").getString() val authKey = environment.config.property("azure.cosmos_db.client.key").getString() - single { CosmosRepository(uri, authKey, "Reports", "/uploadId") } - single { CosmosDeadLetterRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } + single(createdAtStart = true) { CosmosRepository(uri, authKey, "Reports", "/uploadId") } + single(createdAtStart = true) { CosmosDeadLetterRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } } return modules(listOf(cosmosModule)) } +/** + * The main function + * @param args Array + */ fun main(args: Array) { embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true) } +/** + * The main application module which always runs and loads other modules + */ fun Application.module() { configureRouting() serviceBusModule() @@ -34,6 +46,4 @@ fun Application.module() { loadKoinModules(environment) } - // Preload the koin module so the CosmosDB client is already initialized on the first call - getKoin().get() } 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 2914c66d..e08e7bef 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 @@ -123,14 +123,14 @@ class ReportManager: KoinComponent { logger.info("Replacing report(s) with stage name = $stageName") // Delete all stages matching the report ID with the same stage name val sqlQuery = "select * from ${reportMgrConfig.reportsContainerName} r where r.uploadId = '$uploadId' and r.stageName = '$stageName'" - val items = cosmosRepository.reportsContainer.queryItems( + val items = cosmosRepository.reportsContainer?.queryItems( sqlQuery, CosmosQueryRequestOptions(), Report::class.java ) if ((items?.count() ?: 0) > 0) { try { items?.forEach { - cosmosRepository.reportsContainer.deleteItem( + cosmosRepository.reportsContainer?.deleteItem( it.id, PartitionKey(it.uploadId), CosmosItemRequestOptions() @@ -193,56 +193,7 @@ class ReportManager: KoinComponent { } else this.content = content } - - var attempts = 0 - do { - try { - val response = cosmosRepository.reportsContainer.createItem( - stageReport, - PartitionKey(uploadId), - CosmosItemRequestOptions() - ) - - logger.info("Creating report, response http status code = ${response?.statusCode}, attempt = ${attempts + 1}, uploadId = $uploadId") - if (response != null) { - when (response.statusCode) { - HttpResponseStatus.OK.code(), HttpResponseStatus.CREATED.code() -> { - logger.info("Created report with reportId = ${response.item?.reportId}, uploadId = $uploadId") - - return stageReportId - } - - HttpResponseStatus.TOO_MANY_REQUESTS.code() -> { - // See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/performance-tips?tabs=trace-net-core#429 - // https://learn.microsoft.com/en-us/rest/api/cosmos-db/common-cosmosdb-rest-response-headers - // https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large?tabs=resource-specific - val recommendedDuration = response.responseHeaders["x-ms-retry-after-ms"] - logger.warn("Received 429 (too many requests) from cosmosdb, attempt ${attempts + 1}, will retry after $recommendedDuration millis, uploadId = $uploadId") - val waitMillis = recommendedDuration?.toLong() - Thread.sleep(waitMillis ?: DEFAULT_RETRY_INTERVAL_MILLIS) - } - - else -> { - // Need to retry regardless - val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) - logger.warn("Received response code ${response.statusCode}, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") - Thread.sleep(retryAfterDurationMillis) - } - } - } else { - val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) - logger.warn("Received null response from cosmosdb, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") - Thread.sleep(retryAfterDurationMillis) - } - } catch (e: Exception) { - val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) - logger.error("CreateReport: Exception: ${e.localizedMessage}, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") - Thread.sleep(retryAfterDurationMillis) - } - - } while (attempts++ < MAX_RETRY_ATTEMPTS) - - throw BadStateException("Failed to create reportId = ${stageReport.reportId}, uploadId = $uploadId") + return createReportItem(uploadId,stageReportId,stageReport) } /** * Creates a dead-letter report if there is a malformed data or missing required fields @@ -272,79 +223,129 @@ class ReportManager: KoinComponent { val deadLetterReport = ReportDeadLetter().apply { this.id = deadLetterReportId this.uploadId = uploadId + this.reportId = deadLetterReportId this.dataStreamId = dataStreamId this.dataStreamRoute = dataStreamRoute this.stageName= stageName this.dispositionType= dispositionType.toString() this.contentType = contentType this.deadLetterReasons= deadLetterReasons - if (contentType?.lowercase() == "json") { + if (contentType?.lowercase() == "json" && !isNullOrEmpty(content)) { val typeObject = object : TypeToken?>() {}.type val jsonMap: Map = gson.fromJson(Gson().toJson(content, MutableMap::class.java).toString(), typeObject) this.content = jsonMap } else this.content = content } + return createReportItem(uploadId,deadLetterReportId,deadLetterReport) + } + /** + * The function which calculates the interval after which the retry should occur + * @param attempt Int + */ + private fun getCalculatedRetryDuration(attempt: Int): Long { + return DEFAULT_RETRY_INTERVAL_MILLIS * (attempt + 1) + } + /** Function to check whether the value is null or empty based on its type + * @param value Any + */ + private fun isNullOrEmpty(value: Any?): Boolean { + return when (value) { + null -> true + is String -> value.isEmpty() + is Collection<*> -> value.isEmpty() + is Map<*, *> -> value.isEmpty() + else -> false // You can adjust this to your needs + } + } + + fun createReportItem(uploadId: String?, reportId:String, reportType:Any) : String{ + + var responseReportId = "" + var reportTypeName = "Report" + var statusCode:Int? = null + var isValidResponse = false + var recommendedDuration:String?= null var attempts = 0 + do { try { - val response = cosmosDeadLetterRepository.reportsDeadLetterContainer.createItem( - deadLetterReport, - PartitionKey(uploadId), - CosmosItemRequestOptions() - ) + //use when here to determing whether report type is StageReport or DeadLetterReport + when (reportType) { + is Report -> { + val response = cosmosRepository.reportsContainer?.createItem( + reportType, + PartitionKey(uploadId), + CosmosItemRequestOptions()) - logger.info("Creating dead-letter report, response http status code = ${response?.statusCode}, attempt = ${attempts + 1}, uploadId = $uploadId") - if (response != null) { - when (response.statusCode) { - HttpResponseStatus.OK.code(), HttpResponseStatus.CREATED.code() -> { - logger.info("Created report with reportId = ${response.item?.reportId}, uploadId = $uploadId") - - return deadLetterReportId - } + isValidResponse = response!=null + responseReportId = response?.item?.reportId ?: "0" + statusCode = response?.statusCode + recommendedDuration = response?.responseHeaders?.get("x-ms-retry-after-ms") - HttpResponseStatus.TOO_MANY_REQUESTS.code() -> { - // See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/performance-tips?tabs=trace-net-core#429 - // https://learn.microsoft.com/en-us/rest/api/cosmos-db/common-cosmosdb-rest-response-headers - // https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large?tabs=resource-specific - val recommendedDuration = response.responseHeaders["x-ms-retry-after-ms"] - logger.warn("Received 429 (too many requests) from cosmosdb, attempt ${attempts + 1}, will retry after $recommendedDuration millis, uploadId = $uploadId") - val waitMillis = recommendedDuration?.toLong() - Thread.sleep(waitMillis ?: DEFAULT_RETRY_INTERVAL_MILLIS) - } + } + is ReportDeadLetter -> { + val response = cosmosDeadLetterRepository.reportsDeadLetterContainer?.createItem( + reportType, + PartitionKey(uploadId), + CosmosItemRequestOptions()) - else -> { - // Need to retry regardless - val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) - logger.warn("Received response code ${response.statusCode}, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") - Thread.sleep(retryAfterDurationMillis) - } + isValidResponse = response!=null + reportTypeName ="dead-letter report" + responseReportId = response?.item?.reportId ?: "0" + statusCode = response?.statusCode + recommendedDuration = response?.responseHeaders?.get("x-ms-retry-after-ms") } - } else { - val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) - logger.warn("Received null response from cosmosdb, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") - Thread.sleep(retryAfterDurationMillis) } - } catch (e: Exception) { + logger.info("Creating ${reportTypeName}, response http status code = ${statusCode}, attempt = ${attempts + 1}, uploadId = $uploadId") + if(isValidResponse){ + + when (statusCode) { + HttpResponseStatus.OK.code(), HttpResponseStatus.CREATED.code() -> { + logger.info("Created report with reportId = ${responseReportId}, uploadId = $uploadId") + return reportId + } + + HttpResponseStatus.TOO_MANY_REQUESTS.code() -> { + // See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/performance-tips?tabs=trace-net-core#429 + // https://learn.microsoft.com/en-us/rest/api/cosmos-db/common-cosmosdb-rest-response-headers + // https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large?tabs=resource-specific + + logger.warn("Received 429 (too many requests) from cosmosdb, attempt ${attempts + 1}, will retry after $recommendedDuration millis, uploadId = $uploadId") + val waitMillis = recommendedDuration?.toLong() + Thread.sleep(waitMillis ?: DEFAULT_RETRY_INTERVAL_MILLIS) + } + + else -> { + // Need to retry regardless + val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) + logger.warn("Received response code ${statusCode}, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") + Thread.sleep(retryAfterDurationMillis) + } + } + } + else { + val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) + logger.warn("Received null response from cosmosdb, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") + Thread.sleep(retryAfterDurationMillis) + } + } + catch (e: Exception) { val retryAfterDurationMillis = getCalculatedRetryDuration(attempts) logger.error("CreateReport: Exception: ${e.localizedMessage}, attempt ${attempts + 1}, will retry after $retryAfterDurationMillis millis, uploadId = $uploadId") Thread.sleep(retryAfterDurationMillis) } - } while (attempts++ < MAX_RETRY_ATTEMPTS) - throw BadStateException("Failed to create dead-letterReport reportId = ${deadLetterReport.reportId}, uploadId = $uploadId") - } - /** - * The function which calculates the interval after which the retry should occur - * @param attempt Int - */ - private fun getCalculatedRetryDuration(attempt: Int): Long { - return DEFAULT_RETRY_INTERVAL_MILLIS * (attempt + 1) + } while (attempts++ < MAX_RETRY_ATTEMPTS) + throw BadStateException("Failed to create dead-letterReport reportId = ${responseReportId}, uploadId = $uploadId") } + + + companion object { const val DEFAULT_RETRY_INTERVAL_MILLIS = 500L const val MAX_RETRY_ATTEMPTS = 100 diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt index 0c60e9bc..15b79c6f 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt @@ -12,6 +12,11 @@ class CosmosContainerManager { companion object { + /** + * Function which creates the Cosmos db if not exists , and returns the db instance with which we can get a container instance + * @param cosmosClient CosmosClient + * @param databaseName String + */ @Throws(Exception::class) fun createDatabaseIfNotExists(cosmosClient: CosmosClient, databaseName: String): CosmosDatabase? { val logger = KotlinLogging.logger {} @@ -22,6 +27,13 @@ class CosmosContainerManager { return cosmosClient.getDatabase(databaseResponse.properties.id) } + /** + * The function which creates the cosmos container instance + * @param uri String + * @param authKey String + * @param containerName String + * @param partitionKey String + */ fun initDatabaseContainer(uri: String, authKey: String, containerName: String, partitionKey: String): CosmosContainer? { val logger = KotlinLogging.logger {} try { diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt index 23c5eaca..f29c6c3e 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt @@ -2,12 +2,27 @@ package gov.cdc.ocio.processingstatusapi.cosmos import org.koin.core.component.KoinComponent +/** + * The class which initializes and creates an instance of a cosmos db reports container + * @param uri :String + * @param authKey:String + * @param reportsContainerName:String + * @param partitionKey:String + * + */ class CosmosRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { val reportsContainer = - CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! + CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey) } - +/** + * The class which initializes and creates an instance of a cosmos db reports deadletter container + * @param uri :String + * @param authKey:String + * @param reportsContainerName:String + * @param partitionKey:String + * + */ class CosmosDeadLetterRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { val reportsDeadLetterContainer = - CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! + CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey) } \ No newline at end of file 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/CreateReportSBMessage.kt index ba7dc2e5..dc32c817 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/CreateReportSBMessage.kt @@ -39,7 +39,7 @@ class CreateReportSBMessage: ServiceBusMessage() { // content will vary depending on content_type so make it any. For example, if content_type is json then the // content type will be a Map<*, *>. - val content: Any? = null + var content: Any? = null } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index 6a1190e2..1f82ff54 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -33,7 +33,7 @@ val AzureServiceBus = createApplicationPlugin( createConfiguration = ::AzureServiceBusConfiguration) { val connectionString = pluginConfig.connectionString - var serviceBusNamespace= pluginConfig.serviceBusNamespace + val serviceBusNamespace= pluginConfig.serviceBusNamespace val queueName = pluginConfig.queueName val topicName = pluginConfig.topicName val subscriptionName = pluginConfig.subscriptionName @@ -127,7 +127,9 @@ private fun processMessage(context: ServiceBusReceivedMessageContext) { ) try { ServiceBusProcessor().withMessage(message) - } catch (e: BadRequestException) { + } + //This will handle all missing required fields, invalid schema definition and malformed json all under the BadRequest exception and writes to dead-letter queue or topics depending on the context + catch (e: BadRequestException) { LOGGER.warn("Unable to parse the message: {}", e.localizedMessage) val deadLetterOptions = DeadLetterOptions() .setDeadLetterReason("Validation failed") @@ -135,10 +137,6 @@ private fun processMessage(context: ServiceBusReceivedMessageContext) { context.deadLetter(deadLetterOptions) LOGGER.info("Message sent to the dead-letter queue.") } - catch (e: IllegalArgumentException) { // TODO : Is this the only exception at this time or more generic one??? - LOGGER.warn("Message rejected: {}", e.localizedMessage) - - } catch (e: Exception) { LOGGER.warn("Failed to process service bus message: {}", e.localizedMessage) } 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 55816aaa..f8467ac4 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 @@ -8,7 +8,9 @@ import com.google.gson.ToNumberPolicy import gov.cdc.ocio.processingstatusapi.ReportManager import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException +import gov.cdc.ocio.processingstatusapi.exceptions.InvalidSchemaDefException import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage +import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition import gov.cdc.ocio.processingstatusapi.models.reports.Source import mu.KotlinLogging @@ -49,13 +51,11 @@ class ServiceBusProcessor { sbMessage = sbMessage.replace("event_type", "data_stream_route") } logger.info { "After Message received = $sbMessage" } - createReport(sbMessageId,sbMessageStatus,gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) - } - catch (e:BadRequestException){ + createReport(sbMessageId, sbMessageStatus, gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) + } catch (e: BadRequestException) { println("Validation failed: ${e.message}") throw e - } - catch (e: JsonSyntaxException) { + } catch (e: JsonSyntaxException) { logger.error("Failed to parse CreateReportSBMessage: ${e.localizedMessage}") throw BadStateException("Unable to interpret the create report message") } @@ -68,31 +68,29 @@ class ServiceBusProcessor { * @throws BadRequestException */ @Throws(BadRequestException::class) - private fun createReport(messageId:String, messageStatus:String,createReportMessage: CreateReportSBMessage) { + private fun createReport(messageId: String, messageStatus: String, createReportMessage: CreateReportSBMessage) { try { validateReport(createReportMessage) val uploadId = createReportMessage.uploadId val stageName = createReportMessage.stageName logger.info("Creating report for uploadId = ${uploadId} with stageName = $stageName") - ReportManager().createReportWithUploadId( - createReportMessage.uploadId!!, - createReportMessage.dataStreamId!!, - createReportMessage.dataStreamRoute!!, - createReportMessage.stageName!!, - createReportMessage.contentType!!, - messageId, //createReportMessage.messageId is null - messageStatus, //createReportMessage.status is null - createReportMessage.content!!, // it was Content I changed to ContentAsString - createReportMessage.dispositionType, - Source.SERVICEBUS - ) - - } - catch (e:BadRequestException){ - throw e - } - catch (e: Exception) { + ReportManager().createReportWithUploadId( + createReportMessage.uploadId!!, + createReportMessage.dataStreamId!!, + createReportMessage.dataStreamRoute!!, + createReportMessage.stageName!!, + createReportMessage.contentType!!, + messageId, //createReportMessage.messageId is null + messageStatus, //createReportMessage.status is null + createReportMessage.content!!, // it was Content I changed to ContentAsString + createReportMessage.dispositionType, + Source.SERVICEBUS + ) + + } catch (e: BadRequestException) { + throw e + } catch (e: Exception) { println("Failed to process service bus message:${e.message}") } @@ -100,37 +98,53 @@ class ServiceBusProcessor { } /** - * Function to validate report attributes for missing required fields + * Function to validate report attributes for missing required fields, for schema validation and malformed content message * @param createReportMessage CreateReportSBMessage * @throws BadRequestException */ private fun validateReport(createReportMessage: CreateReportSBMessage) { - val missingFields = mutableListOf() + val invalidData = mutableListOf() + var reason = "" if (createReportMessage.uploadId.isNullOrBlank()) { - missingFields.add("uploadId") + invalidData.add("uploadId") } if (createReportMessage.dataStreamId.isNullOrBlank()) { - missingFields.add("dataStreamId") + invalidData.add("dataStreamId") } if (createReportMessage.dataStreamRoute.isNullOrBlank()) { - missingFields.add("dataStreamRoute") + invalidData.add("dataStreamRoute") } if (createReportMessage.stageName.isNullOrBlank()) { - missingFields.add("stageName") + invalidData.add("stageName") } if (createReportMessage.contentType.isNullOrBlank()) { - missingFields.add("contentType") + invalidData.add("contentType") } if (isNullOrEmpty(createReportMessage.content)) { - missingFields.add("content") + invalidData.add("content") } + if (invalidData.isNotEmpty()) { + reason = "Missing fields: ${invalidData.joinToString(", ")}" + } else { + try { + SchemaDefinition.fromJsonString(createReportMessage.content) + } catch (e: InvalidSchemaDefException) { + reason = "Invalid schema definition: ${e.localizedMessage}" + invalidData.add(reason) + + } catch (e: Exception) { + reason = "Malformed message: ${e.localizedMessage}" + invalidData.add(reason) + //convert content to base64 encoded string + createReportMessage.content = convertToStringOrBase64(createReportMessage.content) + } + } - if (missingFields.isNotEmpty()) { - val reason ="Missing fields: ${missingFields.joinToString(", ")}" + if (invalidData.isNotEmpty()) { //This should not run for unit tests if (System.getProperty("isTestEnvironment") != "true") { - // Write the content of the deadletter reports to CosmosDb + // Write the content of the dead-letter reports to CosmosDb ReportManager().createDeadLetterReport( createReportMessage.uploadId, createReportMessage.dataStreamId, @@ -139,11 +153,11 @@ class ServiceBusProcessor { createReportMessage.dispositionType, createReportMessage.contentType, createReportMessage.content, - missingFields + invalidData ) } throw BadRequestException(reason) - } + } } /** Function to check whether the value is null or empty based on its type @@ -158,4 +172,16 @@ class ServiceBusProcessor { else -> false // You can adjust this to your needs } } + + /** + * Convert the malformed content to base64 encoded string + * @param obj Any + */ + private fun convertToStringOrBase64(obj: Any?): String { + val bytes = when (obj) { + is ByteArray -> obj + else -> obj.toString().toByteArray() + } + return Base64.getEncoder().encodeToString(bytes) + } } \ No newline at end of file From 1dd3dee0bbff5d774bf95eff0ccab4b23c2bd3db Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 13 Jun 2024 17:33:03 -0400 Subject: [PATCH 20/91] Added code to check for base64 encoded before writing to DeadLetter Cosmos container --- .../gov/cdc/ocio/processingstatusapi/ReportManager.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 e08e7bef..f272eae2 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 @@ -230,7 +230,7 @@ class ReportManager: KoinComponent { this.dispositionType= dispositionType.toString() this.contentType = contentType this.deadLetterReasons= deadLetterReasons - if (contentType?.lowercase() == "json" && !isNullOrEmpty(content)) { + if (contentType?.lowercase() == "json" && !isNullOrEmpty(content) && !isBase64Encoded(content.toString())) { val typeObject = object : TypeToken?>() {}.type val jsonMap: Map = gson.fromJson(Gson().toJson(content, MutableMap::class.java).toString(), typeObject) this.content = jsonMap @@ -260,6 +260,11 @@ class ReportManager: KoinComponent { } } + private fun isBase64Encoded(value: String): Boolean { + val base64Pattern = "^[A-Za-z0-9+/]+={0,2}$" + return value.matches(base64Pattern.toRegex()) + } + fun createReportItem(uploadId: String?, reportId:String, reportType:Any) : String{ var responseReportId = "" From 82203c732a5985e56752acb97404689af997595d Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 13 Jun 2024 18:24:44 -0400 Subject: [PATCH 21/91] Added function headers --- .../cdc/ocio/processingstatusapi/ReportManager.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 f272eae2..9ca70187 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 @@ -260,12 +260,23 @@ class ReportManager: KoinComponent { } } + /** + * The function which checks whether the passed string is Base64 Encoded or not using Regex + * @param value String + */ private fun isBase64Encoded(value: String): Boolean { val base64Pattern = "^[A-Za-z0-9+/]+={0,2}$" return value.matches(base64Pattern.toRegex()) } - fun createReportItem(uploadId: String?, reportId:String, reportType:Any) : String{ + /** + * The common function which writes to cosmos container based on the report type + * @param uploadId String + * @param reportId String + * @reportType Any + */ + + private fun createReportItem(uploadId: String?, reportId:String, reportType:Any) : String{ var responseReportId = "" var reportTypeName = "Report" From 91800a47c053ec1590b7484003d81fce0ed8d872 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 14 Jun 2024 17:16:11 -0400 Subject: [PATCH 22/91] Modifications made based on PR comments --- .../loaders/ReportDeadLetterLoader.kt | 79 ++++++++++++++----- .../queries/ReportDeadLetterQueryService.kt | 8 +- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt index c9847b9d..0acf0600 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -3,12 +3,20 @@ package gov.cdc.ocio.processingstatusapi.loaders import com.azure.cosmos.models.CosmosQueryRequestOptions import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao +import gov.cdc.ocio.processingstatusapi.utils.SqlClauseBuilder import java.text.SimpleDateFormat import java.time.ZoneOffset import java.util.* - +import mu.KotlinLogging +/** + * Class for generating reporting queries from cosmos db container which is then wrapped in a graphQl query service + */ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { + /** + * Function that returns a list of DeadLetterReports based on uploadId + * @param uploadId String + */ fun getByUploadId(uploadId: String): List { val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.id = '$uploadId'" @@ -23,15 +31,21 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { return reports } - fun getByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String, startDate:String, endDate:String): List { + /** + * Function which returns list of ReportDeadLetter based on the specified parameters + * @param dataStreamId String + * @param dataStreamRoute String + * @param startDate String + * @param endDate String + */ + fun getByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String, startDate:String?, endDate:String?, daysInterval: Int?): List { val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed - val startDateParam: Long = formatter.parse(startDate).toInstant().toEpochMilli() - val endDateParam : Long = formatter.parse(endDate).toInstant().toEpochMilli() + val timeRangeWhereClause = SqlClauseBuilder().buildSqlClauseForDateRange(daysInterval, getFormattedDateAsString(startDate), getFormattedDateAsString(endDate)) - val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + - "and r.dataStreamRoute= '$dataStreamRoute' and r.timestamp >=$startDateParam " + - "and r.timestamp<=$endDateParam" + val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + + "and r.dataStreamRoute= '$dataStreamRoute' " + + "and $timeRangeWhereClause" val reportItems = reportsDeadLetterContainer.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), @@ -44,15 +58,22 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { return reports } - fun getCountByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String?, startDate:String, endDate:String): Int{ + /** + * Function which returns count of ReportDeadLetter items based on the specified parameters + * @param dataStreamId String + * @param dataStreamRoute String? + * @param startDate String + * @param endDate String + */ + fun getCountByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String?, startDate:String?, endDate:String?, daysInterval:Int?): Int{ + val logger = KotlinLogging.logger {} val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed - val startDateParam: Long = formatter.parse(startDate).toInstant().toEpochMilli() - val endDateParam : Long = formatter.parse(endDate).toInstant().toEpochMilli() - val reportsSqlQuery = "select value count(1) from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + - "and r.timestamp >=$startDateParam " + - "and r.timestamp<=$endDateParam" + if (dataStreamRoute!=null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" + val timeRangeWhereClause = SqlClauseBuilder().buildSqlClauseForDateRange(daysInterval, startDate, endDate) + + val reportsSqlQuery = "select value count(1) from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + + "and $timeRangeWhereClause " + if (dataStreamRoute!=null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" val reportItems = reportsDeadLetterContainer.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), @@ -61,13 +82,17 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { var count = 0 if (reportItems.iterator().hasNext()) { count = reportItems.iterator().next() - println("Count of records: $count") - } else { - println("Count of records: 0") + logger.info("Count of records: $count") + } else { + logger.info("Count of records: 0") } return count } + + /** + * + */ fun search(ids: List): List { val quotedIds = ids.joinToString("\",\"", "\"", "\"") @@ -77,16 +102,17 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { reportsSqlQuery, CosmosQueryRequestOptions(), ReportDao::class.java ) - val reports = mutableListOf() reportItems?.forEach { reports.add(daoToReport(it)) } return reports } - - - private fun daoToReport(reportDao: ReportDao): ReportDeadLetter { + /** + * Function which converts cosmos data object to Report obhect + * @param reportDao ReportDao + */ + private fun daoToReport(reportDao: ReportDao): ReportDeadLetter { return ReportDeadLetter().apply { this.id = reportDao.id this.uploadId = reportDao.uploadId @@ -100,4 +126,17 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { this.content = reportDao.contentAsType } } + + /** + * Function which converts the inputted date to expected date format + * @param inputDate String + */ + private fun getFormattedDateAsString(inputDate:String?):String?{ + if(inputDate == null) return null + val inputDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + val outputDateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'") + val date: Date = inputDateFormat.parse(inputDate) + val outputDateString = outputDateFormat.format(date) + return outputDateString + } } \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt index c2af2306..34057298 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportDeadLetterQueryService.kt @@ -16,13 +16,13 @@ class ReportDeadLetterQueryService : Query { @GraphQLDescription("Return all the dead-letter reports associated with the provided datastreamId, datastreamroute and timestamp date range") @Suppress("unused") - fun getDeadLetterReportsByDataStream(dataStreamId: String, dataStreamRoute:String, startDate:String, endDate:String) - = ReportDeadLetterLoader().getByDataStreamByDateRange(dataStreamId,dataStreamRoute,startDate,endDate) + fun getDeadLetterReportsByDataStream(dataStreamId: String, dataStreamRoute:String, startDate:String?, endDate:String?, daysInterval :Int?) + = ReportDeadLetterLoader().getByDataStreamByDateRange(dataStreamId,dataStreamRoute,startDate,endDate,daysInterval) @GraphQLDescription("Return count of dead-letter reports associated with the provided datastreamId, (optional) datastreamroute and timestamp date range") @Suppress("unused") - fun getDeadLetterReportsCountByDataStream(dataStreamId: String, dataStreamRoute:String?, startDate:String, endDate:String) - = ReportDeadLetterLoader().getCountByDataStreamByDateRange(dataStreamId,dataStreamRoute,startDate,endDate) + fun getDeadLetterReportsCountByDataStream(dataStreamId: String, dataStreamRoute:String?, startDate:String?, endDate:String?, daysInterval:Int?) + = ReportDeadLetterLoader().getCountByDataStreamByDateRange(dataStreamId,dataStreamRoute,startDate,endDate,daysInterval) @GraphQLDescription("Return list of dead-letter reports based on ReportSearchParameters options") @Suppress("unused") From 69eca394eef407578bc0280540136e575ccb5706 Mon Sep 17 00:00:00 2001 From: Madhavi Tammineni Date: Tue, 18 Jun 2024 12:20:11 -0400 Subject: [PATCH 23/91] Remove Tracing --- .../build.gradle | 7 - .../FunctionJavaWrappers.java | 70 -------- .../status/GetReportCountsFunction.kt | 1 - .../functions/status/GetStatusFunction.kt | 93 +---------- .../traces/AddSpanToTraceFunction.kt | 152 ------------------ .../functions/traces/CreateTraceFunction.kt | 111 ------------- .../functions/traces/GetSpanFunction.kt | 111 ------------- .../functions/traces/GetTraceFunction.kt | 122 -------------- .../functions/traces/TraceUtils.kt | 69 -------- .../processingstatusapi/model/StatusResult.kt | 15 -- .../processingstatusapi/model/traces/Base.kt | 11 -- .../processingstatusapi/model/traces/Data.kt | 10 -- .../processingstatusapi/model/traces/P1.kt | 8 - .../model/traces/Processes.kt | 8 - .../model/traces/Reference.kt | 9 -- .../model/traces/SpanResult.kt | 24 --- .../processingstatusapi/model/traces/Spans.kt | 16 -- .../processingstatusapi/model/traces/Tags.kt | 10 -- .../model/traces/TraceDao.kt | 31 ---- .../model/traces/TraceResult.kt | 142 ---------------- .../opentelemetry/OpenTelemetryConfig.kt | 72 --------- .../src/test/kotlin/FunctionWrapperTest.kt | 63 -------- .../test/status/GetStatusFunctionTests.kt | 9 +- .../traces/AddSpanToTraceFunctionTests.kt | 71 -------- .../test/traces/CreateTraceFunctionTests.kt | 102 ------------ .../test/traces/GetSpanFunctionTests.kt | 72 --------- .../test/traces/GetTraceFunctionTests.kt | 87 ---------- 27 files changed, 4 insertions(+), 1492 deletions(-) delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/AddSpanToTraceFunction.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/CreateTraceFunction.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/GetSpanFunction.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/GetTraceFunction.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/TraceUtils.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Base.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Data.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/P1.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Processes.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Reference.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/SpanResult.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Spans.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Tags.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/TraceDao.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/TraceResult.kt delete mode 100644 processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/opentelemetry/OpenTelemetryConfig.kt delete mode 100644 processing-status-api-function-app/src/test/kotlin/test/traces/AddSpanToTraceFunctionTests.kt delete mode 100644 processing-status-api-function-app/src/test/kotlin/test/traces/CreateTraceFunctionTests.kt delete mode 100644 processing-status-api-function-app/src/test/kotlin/test/traces/GetSpanFunctionTests.kt delete mode 100644 processing-status-api-function-app/src/test/kotlin/test/traces/GetTraceFunctionTests.kt diff --git a/processing-status-api-function-app/build.gradle b/processing-status-api-function-app/build.gradle index 165f983f..55146c95 100644 --- a/processing-status-api-function-app/build.gradle +++ b/processing-status-api-function-app/build.gradle @@ -33,11 +33,6 @@ dependencies { 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" - implementation 'io.opentelemetry:opentelemetry-api:1.29.0' - implementation 'io.opentelemetry:opentelemetry-sdk:1.29.0' - implementation 'io.opentelemetry:opentelemetry-exporter-logging:1.29.0' - implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.29.0' - implementation 'io.opentelemetry:opentelemetry-semconv:1.29.0-alpha' 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' @@ -55,8 +50,6 @@ dependencies { implementation 'org.owasp.encoder:encoder:1.2.3' implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.3.1' - 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" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" diff --git a/processing-status-api-function-app/src/main/java/gov/cdc/ocio/processingstatusapi/FunctionJavaWrappers.java b/processing-status-api-function-app/src/main/java/gov/cdc/ocio/processingstatusapi/FunctionJavaWrappers.java index cd284403..1b437022 100644 --- a/processing-status-api-function-app/src/main/java/gov/cdc/ocio/processingstatusapi/FunctionJavaWrappers.java +++ b/processing-status-api-function-app/src/main/java/gov/cdc/ocio/processingstatusapi/FunctionJavaWrappers.java @@ -13,10 +13,6 @@ import gov.cdc.ocio.processingstatusapi.functions.status.GetUploadStatusFunction; import gov.cdc.ocio.processingstatusapi.functions.reports.ServiceBusProcessor; import gov.cdc.ocio.processingstatusapi.functions.status.GetStatusFunction; -import gov.cdc.ocio.processingstatusapi.functions.traces.AddSpanToTraceFunction; -import gov.cdc.ocio.processingstatusapi.functions.traces.CreateTraceFunction; -import gov.cdc.ocio.processingstatusapi.functions.traces.GetSpanFunction; -import gov.cdc.ocio.processingstatusapi.functions.traces.GetTraceFunction; import gov.cdc.ocio.processingstatusapi.model.DispositionType; import org.owasp.encoder.Encode; @@ -35,72 +31,6 @@ public HttpResponseMessage healthCheck( return new HealthCheckFunction(request).run(); } - @FunctionName("CreateTrace") - public HttpResponseMessage createTrace( - @HttpTrigger( - name = "req", - methods = {HttpMethod.POST}, - route = "trace", - authLevel = AuthorizationLevel.ANONYMOUS) HttpRequestMessage> request) { - return new CreateTraceFunction(request).create(); - } - - @FunctionName("TraceStartSpan") - public HttpResponseMessage traceStartSpan( - @HttpTrigger( - name = "req", - methods = {HttpMethod.PUT}, - route = "trace/startSpan/{traceId}/{parentSpanId}", - authLevel = AuthorizationLevel.ANONYMOUS) HttpRequestMessage> request, - @BindingName("traceId") String traceId, - @BindingName("parentSpanId") String parentSpanId) { - return new AddSpanToTraceFunction(request).startSpan(traceId, parentSpanId); - } - - @FunctionName("TraceStopSpan") - public HttpResponseMessage traceStopSpan( - @HttpTrigger( - name = "req", - methods = {HttpMethod.PUT}, - route = "trace/stopSpan/{traceId}/{spanId}", - authLevel = AuthorizationLevel.ANONYMOUS) HttpRequestMessage> request, - @BindingName("traceId") String traceId, - @BindingName("spanId") String spanId) { - return new AddSpanToTraceFunction(request).stopSpan(traceId, spanId); - } - - @FunctionName("GetTraceByTraceId") - public HttpResponseMessage getTraceByTraceId( - @HttpTrigger( - name = "req", - methods = {HttpMethod.GET}, - route = "trace/traceId/{traceId}", - authLevel = AuthorizationLevel.ANONYMOUS) HttpRequestMessage> request, - @BindingName("traceId") String traceId) { - return new GetTraceFunction(request).withTraceId(traceId); - } - - @FunctionName("GetTraceByUploadId") - public HttpResponseMessage getTraceByUploadId( - @HttpTrigger( - name = "req", - methods = {HttpMethod.GET}, - route = "trace/uploadId/{uploadId}", - authLevel = AuthorizationLevel.ANONYMOUS) HttpRequestMessage> request, - @BindingName("uploadId") String uploadId) { - return new GetTraceFunction(request).withUploadId(uploadId); - } - - @FunctionName("GetTraceSpan") - public HttpResponseMessage getTraceSpanByUploadIdStageName( - @HttpTrigger( - name = "req", - methods = {HttpMethod.GET}, - route = "trace/span", - authLevel = AuthorizationLevel.ANONYMOUS) HttpRequestMessage> request) { - return new GetSpanFunction(request).withQueryParams(); - } - /*** * Process a message from the service bus queue. * diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetReportCountsFunction.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetReportCountsFunction.kt index 1ae09f1e..1cc2bf54 100644 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetReportCountsFunction.kt +++ b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetReportCountsFunction.kt @@ -13,7 +13,6 @@ import gov.cdc.ocio.processingstatusapi.model.reports.* import gov.cdc.ocio.processingstatusapi.model.reports.stagereports.HL7Debatch import gov.cdc.ocio.processingstatusapi.model.reports.stagereports.HL7Redactor import gov.cdc.ocio.processingstatusapi.model.reports.stagereports.HL7Validation -import gov.cdc.ocio.processingstatusapi.model.traces.* import gov.cdc.ocio.processingstatusapi.utils.DateUtils import gov.cdc.ocio.processingstatusapi.utils.JsonUtils import gov.cdc.ocio.processingstatusapi.utils.PageUtils diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetStatusFunction.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetStatusFunction.kt index 69cee096..a9c501ec 100644 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetStatusFunction.kt +++ b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetStatusFunction.kt @@ -6,12 +6,10 @@ import com.microsoft.azure.functions.HttpRequestMessage import com.microsoft.azure.functions.HttpResponseMessage import com.microsoft.azure.functions.HttpStatus import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager -import gov.cdc.ocio.processingstatusapi.functions.traces.TraceUtils import gov.cdc.ocio.processingstatusapi.model.* import gov.cdc.ocio.processingstatusapi.model.reports.Report import gov.cdc.ocio.processingstatusapi.model.reports.ReportDao import gov.cdc.ocio.processingstatusapi.model.reports.ReportSerializer -import gov.cdc.ocio.processingstatusapi.model.traces.* import gov.cdc.ocio.processingstatusapi.utils.JsonUtils import mu.KotlinLogging import java.util.* @@ -49,29 +47,17 @@ class GetStatusFunction( .create() /** - * Retrieve a complete status (traces + reports) for the provided upload ID. + * Retrieve a complete status (reports) for the provided upload ID. * * @param uploadId String * @return HttpResponseMessage */ fun withUploadId(uploadId: String): HttpResponseMessage { - val traces = TraceUtils.getTraces(uploadId) - var traceResult: TraceResult? = null - if (traces != null) { - if (traces.size != 1) { - return request - .createResponseBuilder(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Trace inconsistency found, expected exactly one trace for uploadId = $uploadId, but got ${traces.size}") - .build() - } - traceResult = TraceResult.buildFromTrace(traces[0]) - } - val reportResult = getReport(uploadId) - // Need at least one or the other (report or trace) - if (traceResult == null && reportResult == null) { + // Need at least one (report) + if (reportResult == null) { return request .createResponseBuilder(HttpStatus.BAD_REQUEST) .body("Invalid uploadId provided") @@ -79,10 +65,6 @@ class GetStatusFunction( } val status = StatusResult().apply { - this.uploadId = traceResult?.uploadId - this.dataStreamId = traceResult?.dataStreamId - this.dataStreamRoute = traceResult?.dataStreamRoute - trace = traceResult?.let { TraceDao.buildFromTraceResult(it) } reports = reportResult?.reports } @@ -97,75 +79,6 @@ class GetStatusFunction( val excludedSpanTags = listOf("spanMark", "span.kind", "internal.span.format", "otel.library.name") } -// private fun getTraces(uploadId: String): TraceResult? { -// val queryService: QueryServiceBlockingStub = jaeger.createBlockingQueryService() -// WaitUtils.untilQueryHasTag(queryService, SERVICE_NAME, "foo", "bar") -// -// val operations: GetOperationsResponse = queryService -// .getOperations(GetOperationsRequest.newBuilder().setService(SERVICE_NAME).build()) -// Assert.assertEquals(1, operations.getOperationNamesList().size()) -// Assert.assertEquals(OPERATION_NAME, operations.getOperationNamesList().get(0)) -// -// return null -// } - -// fun createBlockingQueryService(): QueryServiceBlockingStub { -// val channel = ManagedChannelBuilder.forTarget( -// java.lang.String.format( -// "localhost:%d", -// getQueryPort() -// ) -// ).usePlaintext().build() -// return QueryServiceGrpc.newBlockingStub(channel) -// } -// -// fun runFindTraces() { -// val queryHostPort = "172.17.0.1:16686" -// val channel = ManagedChannelBuilder.forTarget(queryHostPort).usePlaintext().build() -// val queryService: QueryServiceBlockingStub = QueryServiceGrpc.newBlockingStub(channel) -// val query = TraceQueryParameters.newBuilder().setServiceName("frontend") -// .build() -// val traceProto: Iterator = queryService.findTraces( -// FindTracesRequest.newBuilder().setQuery(query).build() -// ) -// val protoSpans: MutableList = ArrayList() -// while (traceProto.hasNext()) { -// protoSpans.addAll(traceProto.next().getSpansList()) -// } -// val traces: Trace = Converter.toModel(protoSpans) -// val graph: Graph = GraphCreator.create(traces) -// val errorSpans: Set = NumberOfErrors.calculate(graph) -// val result: MutableMap> = LinkedHashMap() -// for (errorSpan in errorSpans) { -// for (log in errorSpan.logs) { -// val err: String = log.fields.get(Tags.ERROR.getKey()) -// if (err != null) { -// var traceIdCount = result[err] -// if (traceIdCount == null) { -// traceIdCount = LinkedHashMap() -// result[err] = traceIdCount -// } -// var count = traceIdCount[errorSpan.traceId] -// if (count == null) { -// count = 0 -// } -// traceIdCount[errorSpan.traceId] = ++count -// } -// } -// } -// println("Error type, traceID and error count:") -// for ((key, value) in result) { -// System.out.printf("error type: %s\n", key) -// for ((key1, value1) in value) { -// System.out -// .printf("\tTraceID: %s, count %d\n", key1, value1) -// } -// } -// val height: Int = TraceHeight.calculate(graph) -// val networkLatencies: Map> = NetworkLatency.calculate(graph) -// System.out.printf("Trace height = %d\n", height) -// System.out.printf("Network latencies = %s\n", networkLatencies) -// } private fun getReport(uploadId: String): ReportDao? { diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/AddSpanToTraceFunction.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/AddSpanToTraceFunction.kt deleted file mode 100644 index 7f510d89..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/AddSpanToTraceFunction.kt +++ /dev/null @@ -1,152 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.functions.traces - -import com.google.gson.Gson -import com.microsoft.azure.functions.HttpRequestMessage -import com.microsoft.azure.functions.HttpResponseMessage -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException -import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException -import gov.cdc.ocio.processingstatusapi.model.traces.Tags -import gov.cdc.ocio.processingstatusapi.model.traces.TraceResult -import gov.cdc.ocio.processingstatusapi.opentelemetry.OpenTelemetryConfig -import io.opentelemetry.api.trace.* -import io.opentelemetry.context.Context -import mu.KotlinLogging -import java.util.* - - -/** - * Create a processing status span for a given trace. - */ -class AddSpanToTraceFunction( - private val request: HttpRequestMessage> -) { - private val logger = KotlinLogging.logger {} - - private val openTelemetry by lazy { - OpenTelemetryConfig.initOpenTelemetry() - } - - private val tracer = openTelemetry?.getTracer(AddSpanToTraceFunction::class.java.name) - - private val requestBody = request.body.orElse("") - - /** - * For a given HTTP request, this method creates a processing status span for a given trace. - * In order to process, the HTTP request must contain stageName and spanMark. - * - * @param traceId String - * @param parentSpanId String parent span ID - * @return HttpResponseMessage - resultant HTTP response message for the given request - */ - fun startSpan( - traceId: String, - parentSpanId: String - ): HttpResponseMessage { - - val stageName = request.queryParameters["stageName"] - ?: return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("stageName is required") - .build() - - logger.info("stageName: $stageName") - - val result: TraceResult - if (tracer !=null) { - try { - // See if we were given any optional tags - var tags: Array? = null - try { - if (requestBody.isNotBlank()) { - tags = Gson().fromJson(requestBody, Array::class.java) - } - } catch (e: Exception) { - throw BadRequestException("Failed to parse the optional metadata tags in the request body") - } - - val spanContext = SpanContext.createFromRemoteParent( - traceId, - parentSpanId, - TraceFlags.getSampled(), - TraceState.getDefault() - ) - - val span = tracer.spanBuilder(stageName) - .setParent(Context.current().with(Span.wrap(spanContext))) - .startSpan() - - tags?.forEach { - if (it.key != null && it.value != null) - span.setAttribute(it.key!!, it.value!!) - } - span.end() - - result = TraceResult().apply { - this.traceId = span.spanContext.traceId - this.spanId = span.spanContext.spanId - } - - } catch (ex: BadRequestException) { - return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body(ex.localizedMessage) - .build() - } - } else { - result = TraceResult().apply { - this.traceId = TraceUtils.TRACING_DISABLED - this.spanId = TraceUtils.TRACING_DISABLED - } - } - - return request - .createResponseBuilder(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(result) - .build() - } - - /** - * For a given HTTP request, this method creates a processing status span for a given trace. - * In order to process, the HTTP request must contain stageName and spanMark. - * - * @param traceId String - * @param spanId String - * @return HttpResponseMessage - resultant HTTP response message for the given request - * @throws BadRequestException - * @throws BadStateException - */ - fun stopSpan( - traceId: String, - spanId: String - ): HttpResponseMessage { - - if (tracer != null) { - try { - val spanContext = SpanContext.createFromRemoteParent( - traceId, - spanId, - TraceFlags.getSampled(), - TraceState.getDefault() - ) - val span = tracer.spanBuilder(spanId) - .setParent(Context.current().with(Span.wrap(spanContext))) - .startSpan() - span.end() - - } catch (ex: BadRequestException) { - return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body(ex.localizedMessage) - .build() - } - } - - return request - .createResponseBuilder(HttpStatus.OK) - .header("Content-Type", "application/json") - .build() - } - -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/CreateTraceFunction.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/CreateTraceFunction.kt deleted file mode 100644 index 1ddf0312..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/CreateTraceFunction.kt +++ /dev/null @@ -1,111 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.functions.traces - -import com.microsoft.azure.functions.HttpRequestMessage -import com.microsoft.azure.functions.HttpResponseMessage -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.opentelemetry.OpenTelemetryConfig -import gov.cdc.ocio.processingstatusapi.model.traces.TraceResult -import mu.KotlinLogging -import java.util.* - - -/** - * Creates a new distributed tracing trace for the given HTTP request - */ -class CreateTraceFunction( - private val request: HttpRequestMessage> -) { - private val logger = KotlinLogging.logger {} - - private val openTelemetry by lazy { - OpenTelemetryConfig.initOpenTelemetry() - } - - private val tracer = openTelemetry?.getTracer(CreateTraceFunction::class.java.name) - - private val uploadId = request.queryParameters["uploadId"] - - private var destinationId = request.queryParameters["destinationId"] - - private var dataStreamId = request.queryParameters["dataStreamId"] - - private var eventType = request.queryParameters["eventType"] - - private var dataStreamRoute = request.queryParameters["dataStreamRoute"] - - /** - * Creates a new distributed tracing trace for the given HTTP request. - * In order to process, the HTTP request must contain uploadId - * - * @return HttpResponseMessage - resultant HTTP response message for the given request - */ - fun create(): HttpResponseMessage { - dataStreamId = dataStreamId ?: destinationId - dataStreamRoute = dataStreamRoute ?: eventType - // Verify the request is complete and properly formatted - checkRequired()?.let { return it } - - logger.info("uploadId: $uploadId") - - val result: TraceResult - if (tracer != null) { - val span = tracer.spanBuilder(PARENT_SPAN).startSpan() - span.setAttribute("uploadId", uploadId!!) - span.setAttribute("dataStreamId", dataStreamId!!) - span.setAttribute("dataStreamRoute", dataStreamRoute!!) - span.end() - - result = TraceResult().apply { - traceId = span.spanContext.traceId - spanId = span.spanContext.spanId - } - } else { - result = TraceResult().apply { - traceId = TraceUtils.TRACING_DISABLED - spanId = TraceUtils.TRACING_DISABLED - } - } - - return request - .createResponseBuilder(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(result) - .build() - } - - /** - * Checks that all the required query parameters are present in order to process the request. If not, - * an appropriate HTTP response message is generated with the details. - * - * @return HttpResponseMessage? - */ - private fun checkRequired(): HttpResponseMessage? { - - if (uploadId == null) { - return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("uploadId is required") - .build() - } - - if (dataStreamId == null) { - return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("destinationId or dataStreamId is required") - .build() - } - - if (dataStreamRoute == null) { - return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("eventType or dataStreamRoute is required") - .build() - } - - return null - } - - companion object { - private const val PARENT_SPAN = "PARENT_SPAN" - } -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/GetSpanFunction.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/GetSpanFunction.kt deleted file mode 100644 index 7c3f7b89..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/GetSpanFunction.kt +++ /dev/null @@ -1,111 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.functions.traces - -import com.microsoft.azure.functions.HttpRequestMessage -import com.microsoft.azure.functions.HttpResponseMessage -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.model.traces.* -import gov.cdc.ocio.processingstatusapi.utils.JsonUtils -import mu.KotlinLogging -import java.util.* - -/** - * Collection of functions to get traces. - */ -class GetSpanFunction( - private val request: HttpRequestMessage> -) { - private val logger = KotlinLogging.logger {} - - private val gson = JsonUtils.getGsonBuilderWithUTC() - - fun withQueryParams(): HttpResponseMessage { - - val uploadId = request.queryParameters["uploadId"] - - val stageName = request.queryParameters["stageName"] - - if (!uploadId.isNullOrBlank() && !stageName.isNullOrBlank()) { - - var latestMatchingSpan: SpanResult? - if (TraceUtils.tracingEnabled) { - var attempts = 0 - var traces: List - do { - // Attempt to locate the trace by uploadId - traces = TraceUtils.getTraces(uploadId) - ?: return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("The uploadId provided was not found.") - .build() - - latestMatchingSpan = checkForStageNameInTraces(traces, stageName) - if (latestMatchingSpan != null) { - break - } - Thread.sleep(500) - } while (attempts++ < 20) // try for up to 10 seconds - - if (traces.size != 1) { - return request - .createResponseBuilder(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Trace inconsistency found, expected exactly one trace for uploadId = $uploadId, but got ${traces.size}") - .build() - } - } else { - latestMatchingSpan = SpanResult().apply { - this.spanId = TraceUtils.TRACING_DISABLED - this.traceId = TraceUtils.TRACING_DISABLED - this.stageName = stageName - this.timestamp = Date() - } - } - - return if (latestMatchingSpan != null) { - // Found a match, return it - request - .createResponseBuilder(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(gson.toJson(latestMatchingSpan)) - .build() - } else { - request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("Found the uploadId provided, but not the stageName") - .build() - } - } - - return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("Unrecognized combination of query parameters provided") - .build() - } - - /** - * Check for a span in the given trace with stage name provide. - * - * @param traces List - * @param stageName String - * @return SpanResult? - */ - private fun checkForStageNameInTraces(traces: List, stageName: String): SpanResult? { - if (traces.size != 1) - return null - - val traceResult = TraceResult.buildFromTrace(traces[0]) - - // Found the trace by uploadId, now try to see if we can find at least one stage with a matching name. - val latestMatchingSpan = traceResult.spans - ?.filter { span -> span.stageName == stageName } - ?.sortedBy { spanResult -> spanResult.timestamp } // natural sort order - oldest first to newest - ?.lastOrNull() // get the last one, which will be the most recent timestamp - - if (latestMatchingSpan != null) { - // Found a match, return it - return latestMatchingSpan - } - - return null - } - -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/GetTraceFunction.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/GetTraceFunction.kt deleted file mode 100644 index 91aba519..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/GetTraceFunction.kt +++ /dev/null @@ -1,122 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.functions.traces - -import com.google.gson.* -import com.microsoft.azure.functions.HttpRequestMessage -import com.microsoft.azure.functions.HttpResponseMessage -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.model.traces.* -import gov.cdc.ocio.processingstatusapi.utils.JsonUtils -import mu.KotlinLogging -import java.util.* - -/** - * Collection of functions to get traces. - */ -class GetTraceFunction( - private val request: HttpRequestMessage> -) { - private val logger = KotlinLogging.logger {} - - private val gson = JsonUtils.getGsonBuilderWithUTC() - - /** - * For a given HTTP request, this method fetches trace information for a given traceId. - * - * @param traceId String - * @return HttpResponseMessage - resultant HTTP response message for the given request - */ - fun withTraceId(traceId: String): HttpResponseMessage { - - logger.info("Trace Id = $traceId") - - var traceResult: TraceResult? = null - if (TraceUtils.tracingEnabled) { - val traceEndPoint = System.getenv("JAEGER_TRACE_END_POINT") + "api/traces/$traceId" - logger.info("traceEndPoint: $traceEndPoint") - var attempts = 0 - do { - val response = khttp.get(traceEndPoint) - val obj = response.jsonObject - logger.info("$obj") - - if (response.statusCode == HttpStatus.OK.value()) { - val traceModel = Gson().fromJson(obj.toString(), Base::class.java) - traceResult = TraceResult.buildFromTrace(traceModel.data[0]) - break - } - Thread.sleep(500) - } while (attempts++ < 20) // try for up to 10 seconds - } else { - traceResult = disabledTraceResult - } - - if (traceResult != null) { - return request - .createResponseBuilder(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(gson.toJson(traceResult)) - .build() - } - - return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("The trace identifier provided was not found.") - .build() - } - - /** - * For a given HTTP request, this method fetches trace information for a given uploadId. - * - * @param uploadId String - * @return HttpResponseMessage - resultant HTTP response message for the given request - */ - fun withUploadId(uploadId: String): HttpResponseMessage { - - logger.info("Upload Id = $uploadId") - - var traceResult: TraceResult? = null - if (TraceUtils.tracingEnabled) { - var attempts = 0 - do { - val traces = TraceUtils.getTraces(uploadId) - ?: return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("The uploadId provided was not found.") - .build() - - if (traces.size == 1) { - traceResult = TraceResult.buildFromTrace(traces[0]) - break - } - - Thread.sleep(500) - } while (attempts++ < 20) // try for up to 10 seconds - } else { - traceResult = disabledTraceResult - } - - if (traceResult != null) { - return request - .createResponseBuilder(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(gson.toJson(traceResult)) - .build() - } - - return request - .createResponseBuilder(HttpStatus.BAD_REQUEST) - .body("The upload identifier provided was not found.") - .build() - } - - companion object { - private val disabledTraceResult = TraceResult().apply { - this.traceId = TraceUtils.TRACING_DISABLED - this.dataStreamId = TraceUtils.TRACING_DISABLED - this.spanId = TraceUtils.TRACING_DISABLED - this.dataStreamRoute = TraceUtils.TRACING_DISABLED - this.uploadId = TraceUtils.TRACING_DISABLED - } - } - -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/TraceUtils.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/TraceUtils.kt deleted file mode 100644 index 4b8706c2..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/traces/TraceUtils.kt +++ /dev/null @@ -1,69 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.functions.traces - -import com.google.gson.Gson -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.model.traces.Base -import gov.cdc.ocio.processingstatusapi.model.traces.Data -import gov.cdc.ocio.processingstatusapi.model.traces.TraceResult - -class TraceUtils { - - companion object { - - const val TRACING_DISABLED = "TracingDisabled" - - val tracingEnabled = System.getenv("EnableTracing").equals("True", ignoreCase = true) - - /** - * Locate all traces for the given uploadId. - * - * @param uploadId String - * @return List? - */ - fun getTraces(uploadId: String): List? { - // TODO: This is very temporary! - // This is very inefficient and won't scale at all. Once a better understanding - // of querying, and if it can be used to search by a tag then we'll use that. Otherwise, we may need to - // query the storage directly; i.e. elasticsearch, once it is available - - val traceEndPoint = System.getenv("JAEGER_TRACE_END_POINT") + "api/traces?limit=20000&service=dex-processing-status" - val response = khttp.get(traceEndPoint) - - if (response.statusCode != HttpStatus.OK.value()) { - return null - } - - val obj = response.jsonObject - val traces = Gson().fromJson(obj.toString(), Base::class.java) - val traceMatches = traces.data.filter { data -> - data.spans.any { span -> - span.tags.any { tag -> - tag.key.equals("uploadId") && tag.value.equals(uploadId) - } - } - } - - return traceMatches - } - - /** - * Get a trace for the given trace id and span id. - * - * @param traceId String - * @return TraceResult? - */ - fun getTrace(traceId: String): TraceResult? { - val traceEndPoint = System.getenv("JAEGER_TRACE_END_POINT")+"api/traces/$traceId" - - val response = khttp.get(traceEndPoint) - val obj = response.jsonObject - - if (response.statusCode != HttpStatus.OK.value()) { - return null - } - - val traceModel = Gson().fromJson(obj.toString(), Base::class.java) - return TraceResult.buildFromTrace(traceModel.data[0]) - } - } -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/StatusResult.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/StatusResult.kt index a17e5c96..ba2574f2 100644 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/StatusResult.kt +++ b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/StatusResult.kt @@ -1,22 +1,7 @@ package gov.cdc.ocio.processingstatusapi.model -import com.google.gson.annotations.SerializedName import gov.cdc.ocio.processingstatusapi.model.reports.Report -import gov.cdc.ocio.processingstatusapi.model.traces.TraceDao data class StatusResult( - - @SerializedName("upload_id") - var uploadId: String? = null, - - @SerializedName("data_stream_id") - var dataStreamId: String? = null, - - @SerializedName("data_stream_route") - var dataStreamRoute: String? = null, - - @SerializedName("trace") - var trace: TraceDao? = null, - var reports: List? = null ) \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Base.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Base.kt deleted file mode 100644 index 3404fb43..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Base.kt +++ /dev/null @@ -1,11 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces -import com.google.gson.annotations.SerializedName - -class Base { - @SerializedName("data" ) var data : ArrayList = arrayListOf() - @SerializedName("total" ) var total : Int? = null - @SerializedName("limit" ) var limit : Int? = null - @SerializedName("offset" ) var offset : Int? = null - @SerializedName("errors" ) var errors : String? = null - -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Data.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Data.kt deleted file mode 100644 index 8d0b4f0b..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Data.kt +++ /dev/null @@ -1,10 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces -import com.google.gson.annotations.SerializedName - -class Data { - - @SerializedName("traceID" ) var traceID : String? = null - @SerializedName("spans" ) var spans : ArrayList = arrayListOf() - @SerializedName("processes" ) var processes : Processes? = Processes() - @SerializedName("warnings" ) var warnings : String? = null -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/P1.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/P1.kt deleted file mode 100644 index 1758da25..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/P1.kt +++ /dev/null @@ -1,8 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces - -import com.google.gson.annotations.SerializedName - -class P1 { - @SerializedName("serviceName" ) var serviceName : String? = null - @SerializedName("tags" ) var tags : ArrayList = arrayListOf() -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Processes.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Processes.kt deleted file mode 100644 index 983914ba..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Processes.kt +++ /dev/null @@ -1,8 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces - -import com.google.gson.annotations.SerializedName -import gov.cdc.ocio.processingstatusapi.model.traces.P1 - -class Processes { - @SerializedName("p1" ) var p1 : P1? = P1() -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Reference.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Reference.kt deleted file mode 100644 index 3f762647..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Reference.kt +++ /dev/null @@ -1,9 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces - -import com.google.gson.annotations.SerializedName - -class Reference { - @SerializedName("refType" ) var refType : String? = null - @SerializedName("traceID" ) var traceID : String? = null - @SerializedName("spanID" ) var spanID : String? = null -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/SpanResult.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/SpanResult.kt deleted file mode 100644 index eba19a39..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/SpanResult.kt +++ /dev/null @@ -1,24 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces - -import com.google.gson.annotations.SerializedName -import java.util.* - -data class SpanResult( - - @SerializedName("stage_name") - var stageName: String? = null, - - @SerializedName("trace_id") - var traceId: String? = null, - - @SerializedName("span_id") - var spanId: String? = null, - - var timestamp: Date? = null, - var status: String? = null, - - @SerializedName("elapsed_millis") - var elapsedMillis: Long? = null, - - var metadata : List? = null -) \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Spans.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Spans.kt deleted file mode 100644 index f9a6c73e..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Spans.kt +++ /dev/null @@ -1,16 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces - -import com.google.gson.annotations.SerializedName - -class Spans { - - @SerializedName("traceID" ) var traceID : String? = null - @SerializedName("spanID" ) var spanID : String? = null - @SerializedName("operationName" ) var operationName : String? = null - @SerializedName("references" ) var references : ArrayList = arrayListOf() - @SerializedName("startTime" ) var startTime : Long? = null - @SerializedName("duration" ) var duration : Long? = null - @SerializedName("tags" ) var tags : ArrayList = arrayListOf() - @SerializedName("logs" ) var logs : ArrayList = arrayListOf() - @SerializedName("warnings" ) var warnings : ArrayList = arrayListOf() -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Tags.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Tags.kt deleted file mode 100644 index 1801003a..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/Tags.kt +++ /dev/null @@ -1,10 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces - -import com.google.gson.annotations.SerializedName - -class Tags { - - @SerializedName("key" ) var key : String? = null - //@SerializedName("type" ) var type : String? = null - @SerializedName("value" ) var value : String? = null -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/TraceDao.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/TraceDao.kt deleted file mode 100644 index 87c35dc6..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/TraceDao.kt +++ /dev/null @@ -1,31 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces - -import com.google.gson.annotations.SerializedName - -data class TraceDao( - - @SerializedName("trace_id") - var traceId: String? = null, - - @SerializedName("span_id") - var spanId: String? = null, - - var spans: List? = null -) { - companion object { - - /** - * Provide the trace DAO from the trace result. - * - * @param traceResult TraceResult - * @return TraceResult - */ - fun buildFromTraceResult(traceResult: TraceResult): TraceDao { - return TraceDao().apply { - this.traceId = traceResult.traceId - this.spanId = traceResult.spanId - this.spans = traceResult.spans - } - } - } -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/TraceResult.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/TraceResult.kt deleted file mode 100644 index 48763460..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/traces/TraceResult.kt +++ /dev/null @@ -1,142 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.model.traces - -import com.google.gson.annotations.SerializedName -import gov.cdc.ocio.processingstatusapi.functions.status.GetStatusFunction -import java.time.Instant -import java.util.* - -data class TraceResult( - - @SerializedName("trace_id") - var traceId: String? = null, - - @SerializedName("span_id") - var spanId: String? = null, - - @SerializedName("upload_id") - var uploadId: String? = null, - - @SerializedName("data_stream_id") - var dataStreamId: String? = null, - - @SerializedName("data_stream_route") - var dataStreamRoute: String? = null, - - var spans: List? = null -) { - companion object { - - /** - * Provide the trace result from the trace model. - * - * @param traceModel Base - * @return TraceResult - */ - fun buildFromTrace(traceModel: Data): TraceResult { - - // Parent span will always be the first element in the array - val uploadIdTag = traceModel.spans[0].tags.firstOrNull { it.key.equals("uploadId") } - val dataStreamIdTag = traceModel.spans[0].tags.firstOrNull { it.key.equals("dataStreamId") } - val dataStreamRouteTag = traceModel.spans[0].tags.firstOrNull { it.key.equals("dataStreamRoute") } - val parentSpanId = traceModel.spans[0].spanID - - // Remove the parent span since we'll be doing a foreach on all remaining spans - traceModel.spans.removeAt(0) - - // Iterate through all the remaining spans and map them by operationName aka stageName - val spanMap = mutableMapOf>() - val parentSpans = traceModel.spans.filter { span -> span.references.any { ref -> ref.spanID == parentSpanId } } - parentSpans.forEach { span -> - span.operationName?.let { stageName -> - var spansList = spanMap[stageName]?.toMutableList() - if (spansList == null) - spansList = mutableListOf() - val tag = Tags().apply { - key = "spanMark" - value = "start" - } - span.tags.add(tag) - spansList.add(span) - spanMap[stageName] = spansList - } - } - val childSpans = traceModel.spans.filter { span -> span.references.any { ref -> ref.refType == "CHILD_OF" } } - childSpans.forEach { span -> - // Find the parent span - val matchParentSpanId = span.references.firstOrNull { ref -> ref.refType == "CHILD_OF" }?.spanID - val match = spanMap.values.firstOrNull { spanList -> spanList.any { span -> span.spanID == matchParentSpanId } } - val stageName = match?.get(0)?.operationName - stageName?.let { - var stageSpansList = spanMap[stageName]?.toMutableList() - if (stageSpansList == null) - stageSpansList = mutableListOf() - val tag = Tags().apply { - key = "spanMark" - value = "stop" - } - span.tags.add(tag) - stageSpansList.add(span) - spanMap[stageName] = stageSpansList - } - } - - // Build the span results associated with this trace - val spanResults = mutableListOf() - spanMap.entries.forEach { entry -> - val stageName = entry.key - val spansList = entry.value - - // Determine whether the stage is running or completed. If the start mark is found, but not the stop mark - // then it is still running. - var startTimestamp: Long? = null - var stopTimestamp: Long? = null - var spanID: String? = null - val tags = mutableListOf() - spansList.forEach { span -> - val spanMarkTag = span.tags.firstOrNull { it.key.equals("spanMark") } - when (spanMarkTag?.value) { - "start" -> { - startTimestamp = span.startTime?.div(1000) // microseconds to milliseconds - spanID = span.spanID - } - "stop" -> { - stopTimestamp = span.startTime?.div(1000) // microseconds to milliseconds - } - } - tags.addAll(span.tags.filterNot { GetStatusFunction.excludedSpanTags.contains(it.key) }) - } - val isSpanComplete = (startTimestamp != null && stopTimestamp != null) - - val elapsedMillis = if (isSpanComplete) { - stopTimestamp!! - startTimestamp!! - } else if (startTimestamp != null) { - Instant.now().toEpochMilli() - startTimestamp!! - } else { - null - } - - val spanResult = SpanResult().apply { - this.stageName = stageName - this.traceId = traceModel.traceID - this.spanId = spanID - timestamp = startTimestamp?.let { Date(it) } - status = if (isSpanComplete) "complete" else "running" - this.elapsedMillis = elapsedMillis - metadata = tags - } - spanResults.add(spanResult) - } - - val result = TraceResult().apply { - this.traceId = traceModel.traceID - spanId = parentSpanId - uploadId = uploadIdTag?.value - dataStreamId = dataStreamIdTag?.value - dataStreamRoute = dataStreamRouteTag?.value - spans = spanResults - } - - return result - } - } -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/opentelemetry/OpenTelemetryConfig.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/opentelemetry/OpenTelemetryConfig.kt deleted file mode 100644 index e520a248..00000000 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/opentelemetry/OpenTelemetryConfig.kt +++ /dev/null @@ -1,72 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.opentelemetry - -import gov.cdc.ocio.processingstatusapi.functions.traces.TraceUtils -import io.opentelemetry.api.OpenTelemetry -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator -import io.opentelemetry.context.propagation.ContextPropagators -import io.opentelemetry.exporter.logging.LoggingSpanExporter -import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter -import io.opentelemetry.sdk.OpenTelemetrySdk -import io.opentelemetry.sdk.resources.Resource -import io.opentelemetry.sdk.trace.SdkTracerProvider -import io.opentelemetry.sdk.trace.export.BatchSpanProcessor -import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor -import io.opentelemetry.semconv.resource.attributes.ResourceAttributes -import java.util.concurrent.TimeUnit -import io.opentelemetry.api.common.Attributes - -/** - * All SDK management takes place here, away from the instrumentation code, which should only access - * the OpenTelemetry APIs. - */ -internal object OpenTelemetryConfig1 { - /** - * Initializes the OpenTelemetry SDK with a logging span exporter and the W3C Trace Context - * propagator. - * - * @return A ready-to-use [OpenTelemetry] instance. - */ - fun initOpenTelemetry(): OpenTelemetry { - val sdkTracerProvider: SdkTracerProvider = SdkTracerProvider.builder() - .addSpanProcessor(SimpleSpanProcessor.create(LoggingSpanExporter())) - .build() - val sdk: OpenTelemetrySdk = OpenTelemetrySdk.builder() - .setTracerProvider(sdkTracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build() - Runtime.getRuntime().addShutdownHook(Thread(sdkTracerProvider::close)) - return sdk - } -} - -internal object OpenTelemetryConfig { - /** - * Initialize an OpenTelemetry SDK with a [OtlpGrpcSpanExporter] and a [ ]. - * - * @param jaegerEndpoint The endpoint of your Jaeger instance. - * @return A ready-to-use [OpenTelemetry] instance. - */ - fun initOpenTelemetry(): OpenTelemetry? { - if (!TraceUtils.tracingEnabled) - return null - - // Export traces to Jaeger over OTLP - val jaegerOtlpExporter: OtlpGrpcSpanExporter = OtlpGrpcSpanExporter.builder() - .setEndpoint(System.getenv("JAEGER_OTEL_COLLECTOR_END_POINT")) - .setTimeout(30, TimeUnit.SECONDS) - .build() - val serviceNameResource: Resource = - Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "dex-processing-status")) - - // Set to process the spans by the Jaeger Exporter - val tracerProvider = SdkTracerProvider.builder() - .addSpanProcessor(BatchSpanProcessor.builder(jaegerOtlpExporter).build()) - .setResource(Resource.getDefault().merge(serviceNameResource)) - .build() - val openTelemetry = OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build() - - // it's always a good idea to shut down the SDK cleanly at JVM exit. - Runtime.getRuntime().addShutdownHook(Thread { tracerProvider.close() }) - return openTelemetry - } -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/test/kotlin/FunctionWrapperTest.kt b/processing-status-api-function-app/src/test/kotlin/FunctionWrapperTest.kt index 035d7d37..8c419bd4 100644 --- a/processing-status-api-function-app/src/test/kotlin/FunctionWrapperTest.kt +++ b/processing-status-api-function-app/src/test/kotlin/FunctionWrapperTest.kt @@ -2,13 +2,9 @@ import com.microsoft.azure.functions.ExecutionContext import com.microsoft.azure.functions.HttpRequestMessage import com.microsoft.azure.functions.HttpStatus import gov.cdc.ocio.processingstatusapi.FunctionJavaWrappers -import gov.cdc.ocio.processingstatusapi.functions.traces.CreateTraceFunction -import gov.cdc.ocio.processingstatusapi.opentelemetry.OpenTelemetryConfig import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject -import io.opentelemetry.api.OpenTelemetry -import io.opentelemetry.api.trace.Tracer import org.mockito.Mockito import org.mockito.Mockito.mock import org.testng.Assert @@ -23,8 +19,6 @@ class FunctionWrapperTest { private lateinit var request: HttpRequestMessage> private lateinit var context: ExecutionContext - private val mockOpenTelemetry = mockk() - private val mockTracer = mockk() private val testMessage = File("./src/test/kotlin/data/reports/createReport_badrequest.json").readText() @BeforeMethod @@ -33,9 +27,6 @@ class FunctionWrapperTest { request = mock(HttpRequestMessage::class.java) as HttpRequestMessage> context = mock(ExecutionContext::class.java) Mockito.`when`(request.body).thenReturn(Optional.of(testMessage)) - mockkObject(OpenTelemetryConfig) - every { OpenTelemetryConfig.initOpenTelemetry()} returns mockOpenTelemetry - every {mockOpenTelemetry.getTracer(CreateTraceFunction::class.java.name)} returns mockTracer // Setup method invocation interception when createResponseBuilder is called to avoid null pointer on real method call. Mockito.doAnswer { invocation -> @@ -50,60 +41,6 @@ class FunctionWrapperTest { assert(response.status == HttpStatus.INTERNAL_SERVER_ERROR) } - @Test - fun testCreateTrace() { - val response = FunctionJavaWrappers().createTrace(request) - } - - @Test - fun testTraceStartSpan() { - var exceptionThrown = false - try { - FunctionJavaWrappers().traceStartSpan(request, "4dad617cd7de019066a0f49cad309948", "1") - } catch(ex: Exception) { - exceptionThrown = true - } - Assert.assertTrue(exceptionThrown) - } - - @Test - fun testTraceStopSpan() { - var exceptionThrown = false - try { - FunctionJavaWrappers().traceStopSpan(request, "4dad617cd7de019066a0f49cad309948", "1") - } catch(ex: Exception) { - exceptionThrown = true - } - Assert.assertTrue(exceptionThrown) - } - - @Test - fun testGetTraceByTraceId() { - var exceptionThrown = false - try { - FunctionJavaWrappers().getTraceByTraceId(request, "4dad617cd7de019066a0f49cad309948") - } catch(ex: Exception) { - exceptionThrown = true - } - Assert.assertFalse(exceptionThrown) - } - - @Test - fun testGetTraceByUploadId() { - var exceptionThrown = false - try { - FunctionJavaWrappers().getTraceByUploadId(request, "4dad617cd7de019066a0f49cad309948") - } catch(ex: Exception) { - exceptionThrown = true - } - Assert.assertFalse(exceptionThrown) - } - - @Test - fun testGetTraceSpanByUploadIdStageName() { - val response = FunctionJavaWrappers().getTraceSpanByUploadIdStageName(request) - } - @Test fun tesCreateReportByUploadId() { val response = FunctionJavaWrappers().createReportByUploadId(request, "4dad617cd7de019066a0f49cad309948") diff --git a/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt b/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt index aeb2e522..e23ab837 100644 --- a/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt +++ b/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt @@ -10,13 +10,10 @@ import com.microsoft.azure.functions.HttpStatus import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager import gov.cdc.ocio.processingstatusapi.functions.status.GetStatusFunction import gov.cdc.ocio.processingstatusapi.model.reports.Report -import gov.cdc.ocio.processingstatusapi.opentelemetry.OpenTelemetryConfig import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic -import io.opentelemetry.api.OpenTelemetry -import io.opentelemetry.api.trace.Tracer import khttp.responses.Response import org.json.JSONObject import org.mockito.Mockito.doAnswer @@ -33,8 +30,7 @@ class GetStatusFunctionTests { private lateinit var request: HttpRequestMessage> private lateinit var context: ExecutionContext - private val mockOpenTelemetry = mockk() - private val mockTracer = mockk() + private val testBytes = File("./src/test/kotlin/data/trace/get_trace.json").readText() private val mockResponse = mockk() // Convert the string to a JSONObject @@ -56,9 +52,6 @@ class GetStatusFunctionTests { mockkStatic("khttp.KHttp") every {khttp.get(any())} returns mockResponse every {mockResponse.jsonObject} returns jsonObject - mockkObject(OpenTelemetryConfig) - every { OpenTelemetryConfig.initOpenTelemetry()} returns mockOpenTelemetry - every {mockOpenTelemetry.getTracer(GetStatusFunction::class.java.name)} returns mockTracer every { mockCosmosContainer.queryItems(any(), any(), Report::class.java) } returns items // Setup method invocation interception when createResponseBuilder is called to avoid null pointer on real method call. diff --git a/processing-status-api-function-app/src/test/kotlin/test/traces/AddSpanToTraceFunctionTests.kt b/processing-status-api-function-app/src/test/kotlin/test/traces/AddSpanToTraceFunctionTests.kt deleted file mode 100644 index 27c17705..00000000 --- a/processing-status-api-function-app/src/test/kotlin/test/traces/AddSpanToTraceFunctionTests.kt +++ /dev/null @@ -1,71 +0,0 @@ -package test.traces - -import com.microsoft.azure.functions.ExecutionContext -import com.microsoft.azure.functions.HttpRequestMessage -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.functions.traces.AddSpanToTraceFunction -import gov.cdc.ocio.processingstatusapi.opentelemetry.OpenTelemetryConfig -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.opentelemetry.api.OpenTelemetry -import io.opentelemetry.api.trace.Tracer -import khttp.responses.Response -import org.mockito.Mockito -import org.mockito.Mockito.doAnswer -import org.mockito.kotlin.any -import org.testng.Assert.assertEquals -import org.testng.annotations.BeforeMethod -import org.testng.annotations.Test -import utils.HttpResponseMessageMock -import java.io.File -import java.util.* - - -class AddSpanToTraceFunctionTests { - - private lateinit var context: ExecutionContext - private lateinit var request: HttpRequestMessage> - val testMessage = File("./src/test/kotlin/data/reports/createReport_badrequest.json").readText() - private val mockResponse = mockk() - private val mockOpenTelemetry = mockk() - private val mockTracer = mockk() - - @BeforeMethod - fun setUp() { - context = Mockito.mock(ExecutionContext::class.java) - request = Mockito.mock(HttpRequestMessage::class.java) as HttpRequestMessage> - mockkObject(OpenTelemetryConfig) - every { OpenTelemetryConfig.initOpenTelemetry()} returns mockOpenTelemetry - every {mockOpenTelemetry.getTracer(AddSpanToTraceFunction::class.java.name)} returns mockTracer - Mockito.`when`(request.body).thenReturn(Optional.of(testMessage)) - // Setup method invocation interception when createResponseBuilder is called to avoid null pointer on real method call. - doAnswer { invocation -> - val status = invocation.arguments[0] as HttpStatus - HttpResponseMessageMock.HttpResponseMessageBuilderMock().status(status) - }.`when`(request).createResponseBuilder(any()) - } - - @Test - fun testWithStartSpanOk() { - val addSpanToTraceFunction = AddSpanToTraceFunction(request) - every {mockResponse.statusCode} returns HttpStatus.OK.value() - - val result = addSpanToTraceFunction.startSpan("1", "1") - assertEquals(400, result.statusCode) - } - - //@Test - fun testWithStopSpan_ok() { - val addSpanToTraceFunction = AddSpanToTraceFunction(request) - every {mockResponse.statusCode} returns HttpStatus.OK.value() - - val result = addSpanToTraceFunction.stopSpan("1", "1") - assertEquals(200, result.statusCode) - } - - - - -} - diff --git a/processing-status-api-function-app/src/test/kotlin/test/traces/CreateTraceFunctionTests.kt b/processing-status-api-function-app/src/test/kotlin/test/traces/CreateTraceFunctionTests.kt deleted file mode 100644 index 482ca0da..00000000 --- a/processing-status-api-function-app/src/test/kotlin/test/traces/CreateTraceFunctionTests.kt +++ /dev/null @@ -1,102 +0,0 @@ -package test.traces - -import com.microsoft.azure.functions.ExecutionContext -import com.microsoft.azure.functions.HttpRequestMessage -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.functions.traces.CreateTraceFunction -import gov.cdc.ocio.processingstatusapi.opentelemetry.OpenTelemetryConfig -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.opentelemetry.api.OpenTelemetry -import io.opentelemetry.api.trace.Tracer -import org.mockito.Mockito -import org.mockito.Mockito.doAnswer -import org.mockito.Mockito.mock -import org.mockito.kotlin.any -import org.testng.annotations.BeforeMethod -import org.testng.annotations.Test -import utils.HttpResponseMessageMock -import java.io.File -import java.util.* - - -class CreateTraceFunctionTests { - - private lateinit var request: HttpRequestMessage> - private lateinit var context: ExecutionContext - private val mockOpenTelemetry = mockk() - private val mockTracer = mockk() - private val testMessage = File("./src/test/kotlin/data/reports/createReport_badrequest.json").readText() - - @BeforeMethod - fun setUp() { - context = mock(ExecutionContext::class.java) - request = mock(HttpRequestMessage::class.java) as HttpRequestMessage> - mockkObject(OpenTelemetryConfig) - every { OpenTelemetryConfig.initOpenTelemetry()} returns mockOpenTelemetry - every {mockOpenTelemetry.getTracer(CreateTraceFunction::class.java.name)} returns mockTracer - - // Setup method invocation interception when createResponseBuilder is called to avoid null pointer on real method call. - doAnswer { invocation -> - val status = invocation.arguments[0] as HttpStatus - HttpResponseMessageMock.HttpResponseMessageBuilderMock().status(status) - }.`when`(request).createResponseBuilder(any()) - } - - @Test - fun testCreateBadrequest() { - val response = CreateTraceFunction(request).create(); - assert(response.status == HttpStatus.BAD_REQUEST) - } - - @Test - fun testCreateUploadIdMissing() { - val queryParameters = mutableMapOf() - queryParameters["destinationId"] = "1" - queryParameters["eventType"] = "1" - Mockito.`when`(request.body).thenReturn(Optional.of(testMessage)) - Mockito.`when`(request.queryParameters).thenReturn(queryParameters) - val response = CreateTraceFunction(request).create(); - assert(response.status == HttpStatus.BAD_REQUEST) - } - - @Test - fun testCreateDestinationIdMissing() { - val queryParameters = mutableMapOf() - val testMessage = File("./src/test/kotlin/data/reports/createReport_badrequest.json").readText() - queryParameters["eventType"] = "1" - queryParameters["uploadId"] = "1" - Mockito.`when`(request.body).thenReturn(Optional.of(testMessage)) - Mockito.`when`(request.queryParameters).thenReturn(queryParameters) - val response = CreateTraceFunction(request).create(); - assert(response.status == HttpStatus.BAD_REQUEST) - } - - @Test - fun testCreateEventTypeMissing() { - val queryParameters = mutableMapOf() - val testMessage = File("./src/test/kotlin/data/reports/createReport_badrequest.json").readText() - queryParameters["destinationId"] = "1" - queryParameters["uploadId"] = "1" - Mockito.`when`(request.body).thenReturn(Optional.of(testMessage)) - Mockito.`when`(request.queryParameters).thenReturn(queryParameters) - val response = CreateTraceFunction(request).create(); - assert(response.status == HttpStatus.BAD_REQUEST) - } - - //@Test - fun testCreate_uploadId_good_request() { - val queryParameters = mutableMapOf() - val testMessage = File("./src/test/kotlin/data/reports/createReport_badrequest.json").readText() - queryParameters["uploadId"] = "1" - queryParameters["destinationId"] = "1" - queryParameters["eventType"] = "1" - Mockito.`when`(request.body).thenReturn(Optional.of(testMessage)) - Mockito.`when`(request.queryParameters).thenReturn(queryParameters) - val response = CreateTraceFunction(request).create(); - assert(response.status == HttpStatus.BAD_REQUEST) - } - -} - diff --git a/processing-status-api-function-app/src/test/kotlin/test/traces/GetSpanFunctionTests.kt b/processing-status-api-function-app/src/test/kotlin/test/traces/GetSpanFunctionTests.kt deleted file mode 100644 index b4a76cd4..00000000 --- a/processing-status-api-function-app/src/test/kotlin/test/traces/GetSpanFunctionTests.kt +++ /dev/null @@ -1,72 +0,0 @@ -package test.traces - -import com.azure.cosmos.CosmosClient -import com.azure.cosmos.CosmosContainer -import com.azure.cosmos.CosmosDatabase -import com.azure.cosmos.util.CosmosPagedIterable -import com.microsoft.azure.functions.ExecutionContext -import com.microsoft.azure.functions.HttpRequestMessage -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager -import gov.cdc.ocio.processingstatusapi.functions.traces.GetSpanFunction -import gov.cdc.ocio.processingstatusapi.model.reports.Report -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.mockkStatic -import khttp.responses.Response -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.Mockito.doAnswer -import org.mockito.kotlin.any -import org.testng.Assert.assertEquals -import org.testng.annotations.BeforeMethod -import org.testng.annotations.Test -import utils.HttpResponseMessageMock -import java.io.File -import java.util.* - - -class GetSpanFunctionTests { - - private lateinit var context: ExecutionContext - private lateinit var request: HttpRequestMessage> - private val testBytes = File("./src/test/kotlin/data/trace/get_trace.json").readText() - private val mockResponse = mockk() - // Convert the string to a JSONObject - private val jsonObject = JSONObject(testBytes) - private val items = mockk>() - - @BeforeMethod - fun setUp() { - context = Mockito.mock(ExecutionContext::class.java) - request = Mockito.mock(HttpRequestMessage::class.java) as HttpRequestMessage> - val mockCosmosClient = mockk() - val mockCosmosDb = mockk() - val mockCosmosContainer = mockk() - mockkObject(CosmosContainerManager) - every { CosmosContainerManager.initDatabaseContainer(any(), any()) } returns mockCosmosContainer - every { mockCosmosClient.getDatabase(any()) } returns mockCosmosDb - every { mockCosmosDb.getContainer(any()) } returns mockCosmosContainer - every { mockCosmosContainer.queryItems(any(), any(), Report::class.java) } returns items - - mockkStatic("khttp.KHttp") - every {khttp.get(any())} returns mockResponse - every {mockResponse.jsonObject} returns jsonObject - // Setup method invocation interception when createResponseBuilder is called to avoid null pointer on real method call. - doAnswer { invocation -> - val status = invocation.arguments[0] as HttpStatus - HttpResponseMessageMock.HttpResponseMessageBuilderMock().status(status) - }.`when`(request).createResponseBuilder(any()) - } - - @Test - fun testWithQueryParamsOk() { - val getSpanFunction = GetSpanFunction(request) - every {mockResponse.statusCode} returns HttpStatus.OK.value() - - val result = getSpanFunction.withQueryParams() - assertEquals(400, result.statusCode) - } -} - diff --git a/processing-status-api-function-app/src/test/kotlin/test/traces/GetTraceFunctionTests.kt b/processing-status-api-function-app/src/test/kotlin/test/traces/GetTraceFunctionTests.kt deleted file mode 100644 index 8ba853e9..00000000 --- a/processing-status-api-function-app/src/test/kotlin/test/traces/GetTraceFunctionTests.kt +++ /dev/null @@ -1,87 +0,0 @@ -package test.traces - -import com.microsoft.azure.functions.ExecutionContext -import com.microsoft.azure.functions.HttpRequestMessage -import com.microsoft.azure.functions.HttpStatus -import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager -import gov.cdc.ocio.processingstatusapi.functions.traces.GetTraceFunction -import gov.cdc.ocio.processingstatusapi.functions.traces.TraceUtils -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.mockkStatic -import khttp.responses.Response -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.Mockito.doAnswer -import org.mockito.kotlin.any -import org.testng.Assert.assertEquals -import org.testng.annotations.BeforeMethod -import org.testng.annotations.Test -import utils.HttpResponseMessageMock -import java.io.File -import java.util.* - - -class GetTraceFunctionTests { - - private lateinit var context: ExecutionContext - private lateinit var request: HttpRequestMessage> - private val testBytes = File("./src/test/kotlin/data/trace/get_trace.json").readText() - private val mockResponse = mockk() - // Convert the string to a JSONObject - private val jsonObject = JSONObject(testBytes) - - @BeforeMethod - fun setUp() { - context = Mockito.mock(ExecutionContext::class.java) - request = Mockito.mock(HttpRequestMessage::class.java) as HttpRequestMessage> - mockkStatic("khttp.KHttp") - mockkObject(TraceUtils) - every { TraceUtils.tracingEnabled } returns true - every {khttp.get(any())} returns mockResponse - every {mockResponse.jsonObject} returns jsonObject - // Setup method invocation interception when createResponseBuilder is called to avoid null pointer on real method call. - doAnswer { invocation -> - val status = invocation.arguments[0] as HttpStatus - HttpResponseMessageMock.HttpResponseMessageBuilderMock().status(status) - }.`when`(request).createResponseBuilder(any()) - } - - @Test - fun testWithTraceIdOk() { - val getTraceFunction = GetTraceFunction(request) - every {mockResponse.statusCode} returns HttpStatus.OK.value() - - val result = getTraceFunction.withTraceId("1") - assertEquals(200, result.statusCode) - } - - @Test - fun testWithTraceIdBadRequest() { - val getTraceFunction = GetTraceFunction(request) - every {mockResponse.statusCode} returns HttpStatus.BAD_REQUEST.value() - - val result = getTraceFunction.withTraceId("1") - assertEquals(400, result.statusCode) - } - - @Test - fun testWithUploadIdOk() { - val getTraceFunction = GetTraceFunction(request) - every {mockResponse.statusCode} returns HttpStatus.OK.value() - - val result = getTraceFunction.withUploadId("1") - assertEquals(200, result.statusCode) - } - - @Test - fun testWithUploadIdBadRequest() { - val getTraceFunction = GetTraceFunction(request) - every {mockResponse.statusCode} returns HttpStatus.BAD_REQUEST.value() - - val result = getTraceFunction.withUploadId("1") - assertEquals(400, result.statusCode) - } -} - From 5523580f4e902b4cecd3ac916fae1a6afd2e9347 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 19 Jun 2024 16:34:07 -0400 Subject: [PATCH 24/91] Initial commit - converting notifications fapp to ktor microservice --- pstatus-graphql-ktor/build.gradle | 9 +- .../.gradle/8.5/checksums/checksums.lock | Bin 0 -> 17 bytes .../dependencies-accessors.lock | Bin 0 -> 17 bytes .../8.5/dependencies-accessors/gc.properties | 0 .../.gradle/8.5/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .../.gradle/8.5/gc.properties | 0 .../.gradle/vcs-1/gc.properties | 0 pstatus-notifications-ktor/build.gradle | 106 ++++++++ pstatus-notifications-ktor/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + pstatus-notifications-ktor/gradlew | 249 ++++++++++++++++++ pstatus-notifications-ktor/gradlew.bat | 92 +++++++ .../src/main/kotlin/Application.kt | 29 ++ .../src/main/kotlin/Routes.kt | 41 +++ .../src/main/kotlin/cache/InMemoryCache.kt | 166 ++++++++++++ .../main/kotlin/cache/InMemoryCacheService.kt | 77 ++++++ .../main/kotlin/dispatcher/EmailDispatcher.kt | 86 ++++++ .../src/main/kotlin/dispatcher/EmailUtil.kt | 46 ++++ .../src/main/kotlin/dispatcher/IDispatcher.kt | 8 + .../email/SubscribeEmailNotifications.kt | 68 +++++ .../email/UnsubscribeEmailNotifications.kt | 49 ++++ .../kotlin/exception/BadRequestException.kt | 9 + .../kotlin/exception/BadStateException.kt | 9 + .../main/kotlin/exception/ContentException.kt | 8 + .../exception/InvalidSchemaDefException.kt | 8 + .../main/kotlin/model/SubscriptionResult.kt | 19 ++ .../model/cache/NotificationSubscription.kt | 8 + .../kotlin/model/cache/SubscriptionRule.kt | 33 +++ .../ReportNotificationServiceBusMessage.kt | 57 ++++ .../kotlin/model/message/SchemaDefinition.kt | 83 ++++++ .../src/main/kotlin/parser/ReportParser.kt | 114 ++++++++ .../rulesEngine/EmailNotificationRule.kt | 41 +++ .../src/main/kotlin/rulesEngine/Rule.kt | 10 + .../src/main/kotlin/rulesEngine/RuleEngine.kt | 14 + .../rulesEngine/WebSocketNotificationRule.kt | 35 +++ .../ReportsNotificationProcessor.kt | 85 ++++++ .../src/main/resources/application.conf | 28 ++ .../src/main/resources/logback.xml | 12 + 40 files changed, 1607 insertions(+), 3 deletions(-) create mode 100644 pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock create mode 100644 pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock create mode 100644 pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/gc.properties create mode 100644 pstatus-notifications-ktor/.gradle/8.5/fileChanges/last-build.bin create mode 100644 pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock create mode 100644 pstatus-notifications-ktor/.gradle/8.5/gc.properties create mode 100644 pstatus-notifications-ktor/.gradle/vcs-1/gc.properties create mode 100644 pstatus-notifications-ktor/build.gradle create mode 100644 pstatus-notifications-ktor/gradle.properties create mode 100644 pstatus-notifications-ktor/gradle/wrapper/gradle-wrapper.jar create mode 100644 pstatus-notifications-ktor/gradle/wrapper/gradle-wrapper.properties create mode 100644 pstatus-notifications-ktor/gradlew create mode 100644 pstatus-notifications-ktor/gradlew.bat create mode 100644 pstatus-notifications-ktor/src/main/kotlin/Application.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/Routes.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCache.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCacheService.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/dispatcher/EmailDispatcher.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/dispatcher/EmailUtil.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/dispatcher/IDispatcher.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/email/SubscribeEmailNotifications.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/email/UnsubscribeEmailNotifications.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/exception/BadRequestException.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/exception/BadStateException.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/exception/ContentException.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/exception/InvalidSchemaDefException.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/model/SubscriptionResult.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/model/cache/NotificationSubscription.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/model/cache/SubscriptionRule.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/model/message/ReportNotificationServiceBusMessage.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/model/message/SchemaDefinition.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/parser/ReportParser.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/rulesEngine/EmailNotificationRule.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/rulesEngine/Rule.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/rulesEngine/RuleEngine.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/rulesEngine/WebSocketNotificationRule.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/servicebus/ReportsNotificationProcessor.kt create mode 100644 pstatus-notifications-ktor/src/main/resources/application.conf create mode 100644 pstatus-notifications-ktor/src/main/resources/logback.xml diff --git a/pstatus-graphql-ktor/build.gradle b/pstatus-graphql-ktor/build.gradle index 0baa39e7..e82d35a3 100644 --- a/pstatus-graphql-ktor/build.gradle +++ b/pstatus-graphql-ktor/build.gradle @@ -33,6 +33,10 @@ configurations { agent } +repositories{ + mavenLocal() + mavenCentral() +} dependencies { implementation "io.ktor:ktor-server-core-jvm:$ktor_version" implementation "io.ktor:ktor-server-netty-jvm:$ktor_version" @@ -50,6 +54,7 @@ dependencies { 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 "gov.cdc.io:pstatus-notifications:0.0.1" testImplementation "io.ktor:ktor-server-tests-jvm:$ktor_version" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" } @@ -70,6 +75,4 @@ jib { } } -repositories { - mavenCentral() -} + diff --git a/pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock b/pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..4ea0e982f42c845eaae1b01ac6ff1aa82a6dea48 GIT binary patch literal 17 VcmZRUKRZwLDPzlT1~6be001&T1la%p literal 0 HcmV?d00001 diff --git a/pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock b/pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock new file mode 100644 index 0000000000000000000000000000000000000000..2ad8c8569a18c28c24792822b069a610b2ab8d8d GIT binary patch literal 17 TcmZQhHmWte_PhEv0~7!NGzSE3 literal 0 HcmV?d00001 diff --git a/pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/gc.properties b/pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/gc.properties new file mode 100644 index 00000000..e69de29b diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileChanges/last-build.bin b/pstatus-notifications-ktor/.gradle/8.5/fileChanges/last-build.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..10d10e4ce29f425b2afb6082ea460fcf1772bc13 GIT binary patch literal 17 VcmZR6)VyMuPwtv*1~8Ck2LL**1g!u7 literal 0 HcmV?d00001 diff --git a/pstatus-notifications-ktor/.gradle/8.5/gc.properties b/pstatus-notifications-ktor/.gradle/8.5/gc.properties new file mode 100644 index 00000000..e69de29b diff --git a/pstatus-notifications-ktor/.gradle/vcs-1/gc.properties b/pstatus-notifications-ktor/.gradle/vcs-1/gc.properties new file mode 100644 index 00000000..e69de29b diff --git a/pstatus-notifications-ktor/build.gradle b/pstatus-notifications-ktor/build.gradle new file mode 100644 index 00000000..9d6d0223 --- /dev/null +++ b/pstatus-notifications-ktor/build.gradle @@ -0,0 +1,106 @@ + +import org.gradle.api.tasks.bundling.Jar + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} +plugins { + id 'org.jetbrains.kotlin.jvm' version '1.9.24' + id 'io.ktor.plugin' version '2.3.11' + id("com.google.cloud.tools.jib") version "3.3.0" // Add Jib plugin + id "maven-publish" + id 'java-library' +} +apply plugin: "java" +apply plugin: "kotlin" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "17" + } + +} + +/*kotlin { + jvm { + withJava() + } +}*/ + +group "gov.cdc.io" +version "0.0.1" +mainClassName = "gov.cdc.ocio.processingstatusnotifications.ApplicationKt" +/*tasks.withType { + archiveBaseName.set("processingstatusnotifications") + archiveVersion.set("0.0.1") +}*/ + + +dependencies { + 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("com.azure:azure-messaging-servicebus:7.7.0") + 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.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' + testImplementation("io.ktor:ktor-server-tests:2.3.2") + testImplementation("io.ktor:ktor-server-tests:2.3.2") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.8.10") +} + +ktor { + docker { + localImageName.set("pstatus-notifications-ktor") + } +} + +jib { + to { + image = 'imagehub.cdc.gov:6989/dex/pstatus/notifications-service' + auth { + username = System.getenv("IMAGEHUB_USERNAME") ?: "" + password = System.getenv("IMAGEHUB_PASSWORD") ?: "" + } + } +} + +repositories{ + // mavenLocal() + mavenCentral() +} + +publishing { + publications { + maven(MavenPublication) { + groupId 'gov.cdc.io' + artifactId 'pstatus-notifications' + version '0.0.1' + from(components["java"]) + } + + + } + repositories { + mavenLocal() // Or specify another repository + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/gradle.properties b/pstatus-notifications-ktor/gradle.properties new file mode 100644 index 00000000..f107887b --- /dev/null +++ b/pstatus-notifications-ktor/gradle.properties @@ -0,0 +1,4 @@ +ktor_version=2.3.10 +kotlin_version=1.9.23 +logback_version=1.4.14 +kotlin.code.style=official diff --git a/pstatus-notifications-ktor/gradle/wrapper/gradle-wrapper.jar b/pstatus-notifications-ktor/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! literal 0 HcmV?d00001 diff --git a/pstatus-notifications-ktor/gradle/wrapper/gradle-wrapper.properties b/pstatus-notifications-ktor/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..1af9e093 --- /dev/null +++ b/pstatus-notifications-ktor/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/pstatus-notifications-ktor/gradlew b/pstatus-notifications-ktor/gradlew new file mode 100644 index 00000000..1aa94a42 --- /dev/null +++ b/pstatus-notifications-ktor/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + 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 + + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# 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-ktor/gradlew.bat b/pstatus-notifications-ktor/gradlew.bat new file mode 100644 index 00000000..93e3f59f --- /dev/null +++ b/pstatus-notifications-ktor/gradlew.bat @@ -0,0 +1,92 @@ +@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=. +@rem This is normally unused +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% equ 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% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pstatus-notifications-ktor/src/main/kotlin/Application.kt b/pstatus-notifications-ktor/src/main/kotlin/Application.kt new file mode 100644 index 00000000..8afbaa8a --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/Application.kt @@ -0,0 +1,29 @@ +package gov.cdc.ocio.processingstatusnotifications + + + +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.routing.* +/*fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinApplication { + +}*/ + +fun main(args: Array) { + embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true) +} + +fun Application.module() { + // graphQLModule() + install(ContentNegotiation) { + json() + } + routing { + subscribeEmailNotificationRoute() + unsubscribeEmailNotificationRoute() + } + +} diff --git a/pstatus-notifications-ktor/src/main/kotlin/Routes.kt b/pstatus-notifications-ktor/src/main/kotlin/Routes.kt new file mode 100644 index 00000000..0bba11fd --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/Routes.kt @@ -0,0 +1,41 @@ +package gov.cdc.ocio.processingstatusnotifications + +import gov.cdc.ocio.processingstatusnotifications.email.UnsubscribeEmailNotifications +import kotlinx.serialization.Serializable +import io.ktor.server.plugins.* +import io.ktor.serialization.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* + + +@Serializable +data class EmailSubscription(val dataStreamId:String, val dataStreamRoute:String,val email: String, val stageName: String, val statusType:String, val subscriptionId:String) + +fun Route.subscribeEmailNotificationRoute() { + post("subscribe/email/{dataStreamId}/{dataStreamRoute}") { + val subscription = call.receive() + val result = SubscribeEmailNotifications().run(subscription); + if (result.subscription_id!=null) { + call.respond("Subscription successful!") + } else { + call.respond("Failed to send subscription email.") + } + } +} + +fun Route.unsubscribeEmailNotificationRoute() { + post("unsubscribe/{subscriptionId}") { + val subscription = call.receive() + val result = UnsubscribeEmailNotifications().run(subscription.subscriptionId); + if (result.subscription_id!=null) { + call.respond("Unsubscription successful") + } else { + call.respond("Unsubscription unsuccessful") + } + } +} + diff --git a/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCache.kt b/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCache.kt new file mode 100644 index 00000000..21255cfc --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCache.kt @@ -0,0 +1,166 @@ +package gov.cdc.ocio.processingstatusnotifications.cache + +import gov.cdc.ocio.processingstatusnotifications.* +import gov.cdc.ocio.processingstatusnotifications.exception.* +import gov.cdc.ocio.processingstatusnotifications.model.cache.* +import mu.KotlinLogging +import java.util.* +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 logger = KotlinLogging.logger {} + private val readWriteLock = ReentrantReadWriteLock() + + /* + Cache to store "SubscriptionRule HashSet -> SubscriptionId" + subscriptionRuleCache = HashMap() + */ + private val subscriptionRuleCache = HashMap() + + /* + 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 + * + * @param subscriptionRule String - Hashcode of object with all the required fields + * @param subscriptionType SubscriptionType - Currently supports only 'Email' or 'Websocket' + * @param emailOrUrl String - Valid EmailId or Valid WebSocket Url + * @return String + */ + fun updateCacheForSubscription(subscriptionRule: String, + subscriptionType: SubscriptionType, + emailOrUrl: String): String { + if (subscriptionType == SubscriptionType.EMAIL || subscriptionType == SubscriptionType.WEBSOCKET) { + // If subscription type is EMAIL or WEBSOCKET then proceed else throw BadState Exception + val subscriptionId = updateSubscriptionRuleCache(subscriptionRule) + updateSubscriberCache(subscriptionId, NotificationSubscription(subscriptionId, emailOrUrl, subscriptionType)) + return subscriptionId + } else { + throw BadStateException("Not a valid SubscriptionType") + } + } + + /** + * This method adds a new entry Map[SubscriberRule, SubscriptionId] + * for new rule (if it doesn't exist) in SubscriptionCache + * + * @param subscriptionRule String + * @return String + */ + private fun updateSubscriptionRuleCache(subscriptionRule: String): String { + // Try to read from existing cache to see an existing subscription rule + var existingSubscriptionId: String? = null + readWriteLock.readLock().lock() + try { + existingSubscriptionId = subscriptionRuleCache.get(subscriptionRule) + } finally { + readWriteLock.readLock().unlock() + } + + // if subscription doesn't exist, it will add it else it will return the existing subscription id + return if (existingSubscriptionId != null) { + logger.debug("Subscription Rule exists") + existingSubscriptionId + } else { + // create unique subscription + val subscriptionId = generateUniqueSubscriptionId() + logger.debug("Subscription Id for this new rule has been generated $subscriptionId") + readWriteLock.writeLock().lock() + try { + subscriptionRuleCache.put(subscriptionRule, subscriptionId) + } finally { + readWriteLock.writeLock().unlock() + } + subscriptionId + } + } + + /** + * This method generates new unique susbscriptionId for caches + * @return String + */ + internal fun generateUniqueSubscriptionId(): String { + // TODO: This could be handled to background task to populate + // uniqueSubscriptionIds bucket of size 20 or 50, may be? + var subscriptionId = UUID.randomUUID().toString() + while(subscriberCache.contains(subscriptionId)) { + subscriptionId = UUID.randomUUID().toString() + } + return subscriptionId + } + + /** + * This method adds to the subscriber cache the new entry of subscriptionId to the NotificationSubscriber + * + * @param subscriptionId String + * @param notificationSubscriber NotificationSubscriber + */ + private fun updateSubscriberCache(subscriptionId: String, + notificationSubscriber: NotificationSubscription) { + logger.debug("Subscriber added in subscriber cache") + readWriteLock.writeLock().lock() + try { + subscriberCache.putIfAbsent(subscriptionId, mutableListOf()) + subscriberCache[subscriptionId]?.add(notificationSubscriber) + } 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 + */ + fun unsubscribeSubscriber(subscriptionId: String): Boolean { + if (subscriberCache.containsKey(subscriptionId)) { + val subscribers = subscriberCache[subscriptionId]?.filter { it.subscriptionId != subscriptionId }.orEmpty().toMutableList() + + readWriteLock.writeLock().lock() + try { + subscriberCache.replace(subscriptionId, subscribers) + } finally { + readWriteLock.writeLock().unlock() + } + return true + } else { + logger.info("Subscription $subscriptionId doesn't exist ") + throw BadStateException("Subscription doesn't exist") + } + } + + fun getSubscriptionId(ruleId: String) : String? { + if (subscriptionRuleCache.containsKey(ruleId)) { + return subscriptionRuleCache[ruleId] + } + return null + } + + fun getSubscriptionDetails(subscriptionId: String) : List? { + if (subscriberCache.containsKey(subscriptionId)) { + return subscriberCache[subscriptionId] + } + return emptyList() + } + + + +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCacheService.kt b/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCacheService.kt new file mode 100644 index 00000000..164ec402 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCacheService.kt @@ -0,0 +1,77 @@ +package gov.cdc.ocio.processingstatusnotifications.cache + +import gov.cdc.ocio.processingstatusnotifications.* +import gov.cdc.ocio.processingstatusnotifications.exception.* +import gov.cdc.ocio.processingstatusnotifications.model.cache.* + + +/** + * 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 + * + * @param dataStreamId String + * @param dataStreamRoute String + * @param stageName String + * @param statusType String + * @param emailOrUrl String + * @param subscriptionType SubscriptionType + * @return String + */ + fun updateNotificationsPreferences( + dataStreamId: String, + dataStreamRoute: String, + stageName: String, + statusType: String, + emailOrUrl: String, + subscriptionType: SubscriptionType + ): String { + try { + val subscriptionRule = SubscriptionRule(dataStreamId, dataStreamRoute, stageName, statusType) + val subscriptionId = + InMemoryCache.updateCacheForSubscription(subscriptionRule.getStringHash(), subscriptionType, emailOrUrl) + return subscriptionId + } catch (e: BadStateException) { + throw e + } + + } + + /** + * This method removes subscriber from subscription rule. + * If the rule doesn't exist then it throws BadStateException + * + * @param subscriptionId String + * @return Boolean + */ + fun unsubscribeNotifications(subscriptionId: String): Boolean { + try { + return InMemoryCache.unsubscribeSubscriber(subscriptionId) + } catch (e: BadStateException) { + throw e + } + } + + /** + * This methods checks for subscription rule and gets the subscriptionId. + * In turn uses the subscription Id to retrieve the NotificationSubscription details + * @param ruleId String + * @return Boolean + */ + fun getSubscription(ruleId: String): List { + try { + val subscriptionId = InMemoryCache.getSubscriptionId(ruleId) + if (subscriptionId != null) { + return InMemoryCache.getSubscriptionDetails(subscriptionId).orEmpty() + } + return emptyList() + } catch (e: BadStateException) { + throw e + } + } +} diff --git a/pstatus-notifications-ktor/src/main/kotlin/dispatcher/EmailDispatcher.kt b/pstatus-notifications-ktor/src/main/kotlin/dispatcher/EmailDispatcher.kt new file mode 100644 index 00000000..9b06bc04 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/dispatcher/EmailDispatcher.kt @@ -0,0 +1,86 @@ +package gov.cdc.ocio.processingstatusnotifications.dispatcher + + +import gov.cdc.ocio.processingstatusnotifications.model.cache.* + +import mu.KotlinLogging + +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.Socket +import javax.mail.MessagingException +import javax.mail.Session +import kotlin.math.log + +/** + * EMail dispatcher implements IDispatcher which will implement code to send out emails + * to the subscribers of teh rules (if the rule matches in rule engine evaluation phase) + * @property logger KLogger + */ +class EmailDispatcher(): IDispatcher { + private val logger = KotlinLogging.logger {} + override fun dispatchEvent(subscription: NotificationSubscription): String { + + // Call the function to check SMTP status + return if(checkSMTPStatusWithoutCredentials(subscription)) { + sendEmail(subscription) + logger.info { "Email sent successfully" } + "Email sent successfully" + } else { + logger.info { "Error occurred while sending email" } + "Email server not reachable" + } + + } + + /** + * Method to check teh status of the SMTP server + * @param subscription NotificationSubscription + * @return String + */ + private fun checkSMTPStatusWithoutCredentials(subscription: NotificationSubscription): Boolean { + // This is to get the status from curl statement to see if server is connected + try { + // TODO : Uncomment this later +// val smtpServer = System.getenv("SmtpHostServer") +// val port = System.getenv("SmtpHostPort").toInt() + + 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) { + e.printStackTrace() + } + return false + } + + /** + * Method to send email + * @param subscription NotificationSubscription + * @return String + */ + private fun sendEmail(subscription: NotificationSubscription): Unit { + + try{ + // TODO : Change this into properties + val toEmalId = subscription.subscriberAddressOrUrl; + val props = System.getProperties() + props["mail.smtp.host"] = "smtpgw.cdc.gov" + props["mail.smtp.port"] = 25 + val session = Session.getInstance(props, null) + EmailUtil.sendEmail(session, toEmalId, "${System.getenv("ReplyToName")} : Notification for report", "You have signed up for this notification subscription, please check the portal") + } catch(_: MessagingException) { + logger.info("Unable to send email") + } + + } + +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/dispatcher/EmailUtil.kt b/pstatus-notifications-ktor/src/main/kotlin/dispatcher/EmailUtil.kt new file mode 100644 index 00000000..6106b475 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/dispatcher/EmailUtil.kt @@ -0,0 +1,46 @@ +package gov.cdc.ocio.processingstatusnotifications.dispatcher + + +import java.util.* +import javax.mail.Message +import javax.mail.Session +import javax.mail.Transport +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeMessage + +object EmailUtil { + /** + * Utility method to send simple HTML email + * @param session + * @param toEmail + * @param subject + * @param body + */ + fun sendEmail(session: Session?, toEmail: String?, subject: String?, body: String?) { + try { + val msg = MimeMessage(session) + // TODO: Uncomment this later +// val replyToEmail = System.getenv("ReplyToEmail") +// val replyToName = System.getenv("ReplyToName") + + 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() + } + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/dispatcher/IDispatcher.kt b/pstatus-notifications-ktor/src/main/kotlin/dispatcher/IDispatcher.kt new file mode 100644 index 00000000..5a5b04b6 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/dispatcher/IDispatcher.kt @@ -0,0 +1,8 @@ +package gov.cdc.ocio.processingstatusnotifications.dispatcher + +import gov.cdc.ocio.processingstatusnotifications.model.cache.* + +interface IDispatcher { + + fun dispatchEvent(subscription: NotificationSubscription): String +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/email/SubscribeEmailNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/email/SubscribeEmailNotifications.kt new file mode 100644 index 00000000..4beb086a --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/email/SubscribeEmailNotifications.kt @@ -0,0 +1,68 @@ +package gov.cdc.ocio.processingstatusnotifications + +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService +import mu.KotlinLogging +import java.time.Instant + +class SubscribeEmailNotifications(){ + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + + fun run(subscription: EmailSubscription): + SubscriptionResult { + val dataStreamId = subscription.dataStreamId + val dataStreamRoute = subscription.dataStreamRoute + val email = subscription.email + val stageName = subscription.stageName + val statusType = subscription.statusType + + logger.debug("dataStreamId: $dataStreamId") + logger.debug("dataStreamRoute: $dataStreamRoute") + logger.debug("Subscription Email Id: $email") + logger.debug("StageName: $stageName") + logger.debug("StatusType: $statusType") + + var subscriptionResult = SubscriptionResult() + if (!(email == null || stageName == null || statusType == null)) { + subscriptionResult = subscribeForEmail(dataStreamId, dataStreamRoute, email, stageName, statusType) + if (subscriptionResult.subscription_id != null) { + return subscriptionResult + } + } + subscriptionResult.message = "Invalid Request" + subscriptionResult.status = false + + return subscriptionResult + } + + private fun subscribeForEmail( + dataStreamId: String, + dataStreamRoute: String, + email: String?, + stageName: String?, + statusType: String? + ): SubscriptionResult { + val result = SubscriptionResult() + if (dataStreamId.isBlank() + || dataStreamRoute.isBlank() + || email.isNullOrBlank() + || stageName.isNullOrBlank() + || statusType.isNullOrBlank()) { + result.status = false + result.message = "Required fields not sent in request" + } else if (!email.contains('@') || email.split(".").size > 2 || !email.matches(Regex("([a-zA-Z0-9_%-]+@[a-zA-Z0-9-]+\\.[a-zA-Z]{2,})\$"))) { + result.status = false + result.message = "Not valid email address" + } else if (!(statusType == "success" || statusType == "warning" || statusType == "error")) { + result.status = false + result.message = "Not valid email address" + } else { + result.subscription_id = cacheService.updateNotificationsPreferences(dataStreamId, dataStreamRoute, stageName, statusType, email, SubscriptionType.EMAIL) + result.timestamp = Instant.now().epochSecond + result.status = true + result.message = "Subscription for Email setup" + } + + return result + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/email/UnsubscribeEmailNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/email/UnsubscribeEmailNotifications.kt new file mode 100644 index 00000000..995b2d2c --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/email/UnsubscribeEmailNotifications.kt @@ -0,0 +1,49 @@ +package gov.cdc.ocio.processingstatusnotifications.email + + + +import java.util.* +import gov.cdc.ocio.processingstatusnotifications.SubscriptionResult +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService +import java.time.Instant +import mu.KotlinLogging +/** + * This method is used by HTTP endpoints to unsubscribe for any notifications + * by passing required parameter of subscriptionId + * + * @property request HttpRequestMessage> + * @property logger KLogger + * @property cacheService InMemoryCacheService + * @constructor + */ +class UnsubscribeEmailNotifications( + +) { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + + fun run(subscriptionId: String): SubscriptionResult { + logger.debug { "SubscriptionId $subscriptionId" } + + val result = SubscriptionResult() + val unsubscribeSuccessfull = unsubscribeNotifications(subscriptionId) + if (subscriptionId.isNotBlank() && unsubscribeSuccessfull) { + result.subscription_id = subscriptionId + result.timestamp = Instant.now().epochSecond + result.status = false + result.message = "Unsubscription successful" + + } else { + result.status = false + result.message = "Unsubscription unsuccessful" + + } + return result + } + + private fun unsubscribeNotifications( + subscriptionId: String, + ): Boolean { + return cacheService.unsubscribeNotifications(subscriptionId) + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/exception/BadRequestException.kt b/pstatus-notifications-ktor/src/main/kotlin/exception/BadRequestException.kt new file mode 100644 index 00000000..0a19251e --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/exception/BadRequestException.kt @@ -0,0 +1,9 @@ +package gov.cdc.ocio.processingstatusnotifications.exception + +/** + * Intended use of this exception is for bad requests, such as a record could not be located because an invalid + * identifier was provided. Or, if a required parameter is missing for a request. + * + * @constructor + */ +class BadRequestException(message: String): Exception(message) \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/exception/BadStateException.kt b/pstatus-notifications-ktor/src/main/kotlin/exception/BadStateException.kt new file mode 100644 index 00000000..8819d58e --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/exception/BadStateException.kt @@ -0,0 +1,9 @@ +package gov.cdc.ocio.processingstatusnotifications.exception + +/** + * Intended use of this exception is for internal server issues where we expect to be in a certain state or have + * internal state information that is missing or invalid. + * + * @constructor + */ +class BadStateException(message: String): Exception(message) \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/exception/ContentException.kt b/pstatus-notifications-ktor/src/main/kotlin/exception/ContentException.kt new file mode 100644 index 00000000..370172c7 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/exception/ContentException.kt @@ -0,0 +1,8 @@ +package gov.cdc.ocio.processingstatusnotifications.exception + +/** + * Intended use of this exception is for content issues. + * + * @constructor + */ +class ContentException(message: String): Exception(message) \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/exception/InvalidSchemaDefException.kt b/pstatus-notifications-ktor/src/main/kotlin/exception/InvalidSchemaDefException.kt new file mode 100644 index 00000000..8a88539d --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/exception/InvalidSchemaDefException.kt @@ -0,0 +1,8 @@ +package gov.cdc.ocio.processingstatusnotifications.exception + +/** + * Intended use of this exception is for invalid schema definitions. + * + * @constructor + */ +class InvalidSchemaDefException(message: String): Exception(message) \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/model/SubscriptionResult.kt b/pstatus-notifications-ktor/src/main/kotlin/model/SubscriptionResult.kt new file mode 100644 index 00000000..033db750 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/model/SubscriptionResult.kt @@ -0,0 +1,19 @@ +package gov.cdc.ocio.processingstatusnotifications + +class SubscriptionResult { + var subscription_id: String? = null + var timestamp: Long? = null + var status: Boolean? = false + var message: String? = "" +} + +enum class StatusType { + SUCCESS, + WARNING, + FAILURE +} + +enum class SubscriptionType { + EMAIL, + WEBSOCKET +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/model/cache/NotificationSubscription.kt b/pstatus-notifications-ktor/src/main/kotlin/model/cache/NotificationSubscription.kt new file mode 100644 index 00000000..cd5e21c1 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/model/cache/NotificationSubscription.kt @@ -0,0 +1,8 @@ +package gov.cdc.ocio.processingstatusnotifications.model.cache + +import gov.cdc.ocio.processingstatusnotifications.* + +class NotificationSubscription(val subscriptionId: String, + val subscriberAddressOrUrl: String, + val subscriberType: SubscriptionType +) \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/model/cache/SubscriptionRule.kt b/pstatus-notifications-ktor/src/main/kotlin/model/cache/SubscriptionRule.kt new file mode 100644 index 00000000..be3430b7 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/model/cache/SubscriptionRule.kt @@ -0,0 +1,33 @@ +package gov.cdc.ocio.processingstatusnotifications.model.cache + +class SubscriptionRule(val dataStreamId: String, + val dataStreamRoute: String, + val stageName: String, + val statusType: String) { + + override fun hashCode(): Int { + var result = dataStreamId.lowercase().hashCode() + result = 31 * result + dataStreamRoute.lowercase().hashCode() + result = 31 * result + stageName.lowercase().hashCode() + result = 31 * result + statusType.lowercase().hashCode() + return result + } + + fun getStringHash(): String { + return Integer.toHexString(hashCode()) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SubscriptionRule + + if (dataStreamId != other.dataStreamId) return false + if (dataStreamRoute != other.dataStreamRoute) return false + if (stageName != other.stageName) return false + if (statusType != other.statusType) return false + + return true + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/model/message/ReportNotificationServiceBusMessage.kt b/pstatus-notifications-ktor/src/main/kotlin/model/message/ReportNotificationServiceBusMessage.kt new file mode 100644 index 00000000..b06e72ab --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/model/message/ReportNotificationServiceBusMessage.kt @@ -0,0 +1,57 @@ +package gov.cdc.ocio.processingstatusnotifications.model.message + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import gov.cdc.ocio.processingstatusnotifications.exception.BadStateException +import java.lang.ClassCastException +import java.util.* + +/** + * Create a report service bus message. + * + * @property uploadId String? + * @property dataStreamId String? + * @property dataStreamRoute String? + * @property stageName String? + * @property contentType String? + * @property content String? + */ +class ReportNotificationServiceBusMessage { + + @SerializedName("upload_id") + val uploadId: String? = null + + @SerializedName("data_stream_id") + val dataStreamId: String? = null + + @SerializedName("data_stream_route") + val dataStreamRoute: String? = null + + @SerializedName("stage_name") + val stageName: String? = null + + @SerializedName("content_type") + val contentType: String? = null + + // content will vary depending on content_type so make it any. For example, if content_type is json then the + // content type will be a Map<*, *>. + val content: Any? = null + + val contentAsString: String? + get() { + if (content == null) return null + + return when (contentType?.lowercase(Locale.getDefault())) { + "json" -> { + val typeObject = object : TypeToken?>() {}.type + try { + Gson().toJson(content as Map<*, *>, typeObject) + } catch (e: ClassCastException) { + throw BadStateException("content_type indicates json, but the content is not in JSON format") + } + } + else -> content.toString() + } + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/model/message/SchemaDefinition.kt b/pstatus-notifications-ktor/src/main/kotlin/model/message/SchemaDefinition.kt new file mode 100644 index 00000000..80ed85c7 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/model/message/SchemaDefinition.kt @@ -0,0 +1,83 @@ +package gov.cdc.ocio.processingstatusnotifications.model.message + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import gov.cdc.ocio.processingstatusnotifications.exception.InvalidSchemaDefException + +/** + * Schema definition for all stages. Every stage must inherit this class. + * + * @property schemaName String? + * @property schemaVersion String? + * @constructor + */ +open class SchemaDefinition(@SerializedName("schema_name") var schemaName: String? = null, + @SerializedName("schema_version") var schemaVersion: String? = null, + @Transient private val priority: Int = 0) : + Comparable { + + /** + * Hash operator + * + * @return Int + */ + override fun hashCode(): Int { + var result = schemaName?.hashCode() ?: 0 + result = 31 * result + (schemaVersion?.hashCode() ?: 0) + return result + } + + /** + * Compare operator + * + * @param other SchemaDefinition + * @return Int + */ + override fun compareTo(other: SchemaDefinition): Int { + return compareValues(priority, other.priority) + } + + /** + * Equals operator + * + * @param other Any? + * @return Boolean + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SchemaDefinition + + if (schemaName != other.schemaName) return false + if (schemaVersion != other.schemaVersion) return false + + return true + } + + /** + * Return the schema definition as a human-readable string. + * + * @return String + */ + override fun toString(): String { + return "SchemaDefinition(schemaName=$schemaName, schemaVersion=$schemaVersion)" + } + + companion object { + + @Throws(InvalidSchemaDefException::class) + fun fromJsonString(jsonContent: String?): SchemaDefinition { + if (jsonContent == null) throw InvalidSchemaDefException("Missing schema definition") + + val schemaDefinition = Gson().fromJson(jsonContent, SchemaDefinition::class.java) + if (schemaDefinition?.schemaName.isNullOrEmpty()) + throw InvalidSchemaDefException("Invalid schema_name provided") + + if (schemaDefinition.schemaVersion.isNullOrEmpty()) + throw InvalidSchemaDefException("Invalid schema_version provided") + + return schemaDefinition + } + } +} diff --git a/pstatus-notifications-ktor/src/main/kotlin/parser/ReportParser.kt b/pstatus-notifications-ktor/src/main/kotlin/parser/ReportParser.kt new file mode 100644 index 00000000..ec5a2a1e --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/parser/ReportParser.kt @@ -0,0 +1,114 @@ +package gov.cdc.ocio.processingstatusnotifications.parser + +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import gov.cdc.ocio.processingstatusnotifications.exception.ContentException +import kotlin.collections.HashMap + +/** + * Class containing util methods to parse the content of the report + * + */ +class ReportParser { + + private val reportTypeToStatusFieldMapping: MutableMap = mutableMapOf( + "dex-hl7-validation" to "current_status", + "dex-file-copy" to "result", + "dex-metadata-verify" to "issues", + "upload" to "abc" // TODO : find this + ) + + /** + * Method which parses different types of notification reports for report status + * @param content String + * @return Unit + */ + fun parseReportForStatus(content: String, reportType: String?): String { + val reportMetricCollector = HashMap() + val factory = JsonFactory() + val mapper = ObjectMapper(factory) + val rootNode: JsonNode = mapper.readTree(content) + + recurseParseHelper(rootNode, reportMetricCollector, reportType!!) + return reportMetricCollector[reportType]!! + } + + @Throws(ContentException::class) + private fun recurseParseHelper(node: JsonNode, reportMetricMap: HashMap, reportType: String) { + if (node.isNull) { + return + } + + val statusFieldName = reportTypeToStatusFieldMapping[reportType] + val fieldsIterator: Iterator> = node.fields() + while (fieldsIterator.hasNext()) { + val field: Map.Entry = fieldsIterator.next() + if (field.value.isArray) { + // Specifically for Metadata Report type to figure out the status from issues fields + if (field.key.equals(statusFieldName, true) && statusFieldName == "issues") { + if (!field.value.isArray) { + throw ContentException("Invalid content in $reportType") + } else { + val status = if (field.value.size() > 0) "failure" else "success" + reportMetricMap[reportType] = status + return + } + } + + for (element in field.value) { + recurseParseHelper(element, reportMetricMap, reportType) + } + } else if (field.value.isObject) { + recurseParseHelper(field.value, reportMetricMap, reportType) + } else { + if (field.key.equals(statusFieldName, true)) { + processStatusValueInArray(field, reportMetricMap, reportType!!) + } + } + + } + } + + private fun processStatusValueInArray( + field: Map.Entry, + reportMetricMap: HashMap, + reportType: String + ) { + val validStatusValue = getValidStatusValue(field.value.textValue()); + if (!reportMetricMap.containsKey(reportType)) { + reportMetricMap[reportType] = validStatusValue + } else { + val existingStatus = getPrecedenceOfStatus(reportMetricMap.get(reportType)) + val newStatus = getPrecedenceOfStatus(field.value.textValue()) + if (existingStatus < newStatus) { + reportMetricMap[reportType] = validStatusValue + } + } + } + + private fun getPrecedenceOfStatus(status: String?): Int { + return when (status?.lowercase()) { + "success" -> 1 + "warning" -> 2 + "failure" -> 3 + else -> 0 + } + } + + /** + * Method to convert erroneous status types to "SUCCESS", "WARNING" or "FAILURE" + * @param status String + * @return String + */ + private fun getValidStatusValue(status: String): String { + var tempStatus = status.lowercase() + return if (tempStatus == "success" || (!tempStatus.contains("invalid") && tempStatus.contains("valid"))) { + "success" + } else if (tempStatus.contains("invalid") || tempStatus.contains("error") || tempStatus.contains("failure")) { + "failure" + } else { + "warning" + } + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/EmailNotificationRule.kt b/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/EmailNotificationRule.kt new file mode 100644 index 00000000..8bce1d1b --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/EmailNotificationRule.kt @@ -0,0 +1,41 @@ +package gov.cdc.ocio.processingstatusnotifications.rulesEngine + +import gov.cdc.ocio.processingstatusnotifications.SubscriptionType +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingstatusnotifications.dispatcher.EmailDispatcher +import gov.cdc.ocio.processingstatusnotifications.model.cache.NotificationSubscription +import mu.KotlinLogging + +/** + * Class to evaluate existing rules in Datastore for email notifications. + * If matching rule exist, we can send an email using EmailDispatcher + */ +class EmailNotificationRule(): Rule { + private val logger = KotlinLogging.logger {} + + /** + * Method to evaluate existing subscription for matching rule for Email notifications + * @param ruleId String + * @param cacheService InMemoryCacheService + * @return String + */ + override fun evaluateAndDispatch(ruleId: String, cacheService: InMemoryCacheService): String { + val subscribers: List = cacheService.getSubscription(ruleId) + for(subscriber in subscribers) { + if (subscriber.subscriberType == SubscriptionType.EMAIL) { + return dispatchEvent(subscriber) + } + } + return "" + } + + /** + * Method to dispatch email to the subscriber using the information saved in Datastore + * @param subscription NotificationSubscription + * @return String + */ + override fun dispatchEvent(subscription: NotificationSubscription): String { + val emailDispatcher = EmailDispatcher() + return emailDispatcher.dispatchEvent(subscription) + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/Rule.kt b/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/Rule.kt new file mode 100644 index 00000000..bbc3b215 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/Rule.kt @@ -0,0 +1,10 @@ +package gov.cdc.ocio.processingstatusnotifications.rulesEngine + + +import gov.cdc.ocio.processingstatusnotifications.model.cache.NotificationSubscription +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService + +interface Rule { + fun evaluateAndDispatch(ruleId: String, cacheService: InMemoryCacheService): String + fun dispatchEvent(subscription: NotificationSubscription): String +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/RuleEngine.kt b/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/RuleEngine.kt new file mode 100644 index 00000000..1c141710 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/RuleEngine.kt @@ -0,0 +1,14 @@ +package gov.cdc.ocio.processingstatusnotifications.rulesEngine + +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService + +object RuleEngine { + private val rules = listOf(EmailNotificationRule(), WebsocketNotificationRule()) + private val cacheService = InMemoryCacheService() + + fun evaluateAllRules(ruleId: String): List { + val dispatchMsgsForTesting = mutableListOf() + for (rule in rules) dispatchMsgsForTesting += rule.evaluateAndDispatch(ruleId, cacheService) + return dispatchMsgsForTesting + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/WebSocketNotificationRule.kt b/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/WebSocketNotificationRule.kt new file mode 100644 index 00000000..0a8bcb4f --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/rulesEngine/WebSocketNotificationRule.kt @@ -0,0 +1,35 @@ +package gov.cdc.ocio.processingstatusnotifications.rulesEngine + +import gov.cdc.ocio.processingstatusnotifications.SubscriptionType +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingstatusnotifications.model.cache.NotificationSubscription +import mu.KotlinLogging + +class WebsocketNotificationRule(): Rule { + private val logger = KotlinLogging.logger {} + /** + * Method to evaluate existing subscription for matching rule for Websocket notifications + * @param ruleId String + * @param cacheService InMemoryCacheService + * @return String + */ + override fun evaluateAndDispatch(ruleId: String, cacheService: InMemoryCacheService): String { + val subscribers: List = cacheService.getSubscription(ruleId) + for(subscriber in subscribers) { + if (subscriber.subscriberType == SubscriptionType.WEBSOCKET) { + return dispatchEvent(subscriber) + } + } + return "" + } + + /** + * Method to dispatch websocket event to the subscriber using the information saved in Datastore + * @param subscription NotificationSubscription + * @return String + */ + override fun dispatchEvent(subscription: NotificationSubscription): String { + logger.info("Websocket event dispatched for ${subscription.subscriberAddressOrUrl}") + return "Websocket Event dispatched for ${subscription.subscriberAddressOrUrl}" + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/servicebus/ReportsNotificationProcessor.kt b/pstatus-notifications-ktor/src/main/kotlin/servicebus/ReportsNotificationProcessor.kt new file mode 100644 index 00000000..800c3979 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/servicebus/ReportsNotificationProcessor.kt @@ -0,0 +1,85 @@ +package gov.cdc.ocio.processingstatusnotifications.servicebus + +import com.google.gson.GsonBuilder +import com.google.gson.JsonSyntaxException +import com.google.gson.ToNumberPolicy +import gov.cdc.ocio.processingstatusnotifications.exception.BadRequestException +import gov.cdc.ocio.processingstatusnotifications.exception.BadStateException +import gov.cdc.ocio.processingstatusnotifications.exception.ContentException +import gov.cdc.ocio.processingstatusnotifications.exception.InvalidSchemaDefException +import gov.cdc.ocio.processingstatusnotifications.model.cache.SubscriptionRule +import gov.cdc.ocio.processingstatusnotifications.model.message.ReportNotificationServiceBusMessage +import gov.cdc.ocio.processingstatusnotifications.model.message.SchemaDefinition +import gov.cdc.ocio.processingstatusnotifications.parser.ReportParser +import gov.cdc.ocio.processingstatusnotifications.rulesEngine.RuleEngine + +class ReportsNotificationProcessor { + + private val logger = mu.KotlinLogging.logger {} + + // Use the LONG_OR_DOUBLE number policy, which will prevent Longs from being made into Doubles + private val gson = GsonBuilder() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create() + + /** + * Process a service bus message with the provided message. + * + * @param message String + * @throws BadStateException + */ + @Throws(BadRequestException::class, InvalidSchemaDefException::class) + fun withMessage(message: String): String { + try { + return sendNotificationForReportStatus(gson.fromJson(message, ReportNotificationServiceBusMessage::class.java)) + } catch (e: JsonSyntaxException) { + logger.error("Failed to parse CreateReportSBMessage: ${e.localizedMessage}") + throw BadRequestException("Unable to interpret the create report message") + } + } + + /** + * Subscribe for notifications from the provided service bus message. + * + * @param reportNotification ReportNotificationMessage + * @throws BadRequestException + */ + @Throws(BadRequestException::class,InvalidSchemaDefException::class) + private fun sendNotificationForReportStatus(reportNotification: ReportNotificationServiceBusMessage): String { + + val dataStreamId = reportNotification.dataStreamId + ?: throw BadRequestException("Missing required field data_stream_id") + + val dataStreamRoute = reportNotification.dataStreamRoute + ?: throw BadRequestException("Missing required field data_stream_route") + + val stageName = reportNotification.stageName + ?: throw BadRequestException("Missing required field stage_name") + + val contentType = reportNotification.contentType + ?: throw BadRequestException("Missing required field content_type") + + val content: String + val status: String + try { + content = reportNotification.contentAsString + ?: throw BadRequestException("Missing required field content") + val schemaDef = SchemaDefinition.fromJsonString(content) + + status = ReportParser().parseReportForStatus(content, schemaDef.schemaName) + logger.debug("Report parsed for status $status") + RuleEngine.evaluateAllRules(SubscriptionRule(dataStreamId, dataStreamRoute, stageName, status).getStringHash()) + return status.lowercase() + } catch (ex: BadStateException) { + // assume a bad request + throw BadRequestException(ex.localizedMessage) + } catch(ex: InvalidSchemaDefException) { + // assume an invalid request + throw InvalidSchemaDefException(ex.localizedMessage) + } catch(ex: ContentException) { + // assume an invalid request + throw ContentException(ex.localizedMessage) + } + } + +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/resources/application.conf b/pstatus-notifications-ktor/src/main/resources/application.conf new file mode 100644 index 00000000..7f53bc35 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/resources/application.conf @@ -0,0 +1,28 @@ +ktor { + deployment { + port = 8080 + host = 0.0.0.0 + } + + application { + modules = [ gov.cdc.ocio.notificationsstatusapi.ApplicationKt.module ] + } + } + +azure { + service_bus { + connection_string = ${SERVICE_BUS_CONNECTION_STRING} + azure_servicebus_namespace=${AZURE_SERVICE_BUS_NAMESPACE} + queue_name = ${SERVICE_BUS_REPORT_QUEUE_NAME} + topic_name = ${SERVICE_BUS_REPORT_TOPIC_NAME} + subscription_name = ${SERVICE_BUS_REPORT_TOPIC_SUBSCRIPTION_NAME} + } + cosmos_db { + client { + endpoint = ${COSMOS_DB_CLIENT_ENDPOINT} + key = ${COSMOS_DB_CLIENT_KEY} + } + database_name = "ProcessingStatus" + container_name = "Reports" + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/resources/logback.xml b/pstatus-notifications-ktor/src/main/resources/logback.xml new file mode 100644 index 00000000..3e11d781 --- /dev/null +++ b/pstatus-notifications-ktor/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + \ No newline at end of file From 44aa445c0bfdc770e7bdcc7f61b5ed87360e4af7 Mon Sep 17 00:00:00 2001 From: Madhavi Tammineni Date: Thu, 20 Jun 2024 02:25:22 -0400 Subject: [PATCH 25/91] Remove Jaeger health checks --- .../functions/HealthCheckFunction.kt | 23 +------------------ .../processingstatusapi/model/HealthCheck.kt | 4 ---- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/HealthCheckFunction.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/HealthCheckFunction.kt index 090effc8..b1899707 100644 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/HealthCheckFunction.kt +++ b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/HealthCheckFunction.kt @@ -11,7 +11,6 @@ import com.microsoft.azure.servicebus.primitives.ServiceBusException import gov.cdc.ocio.processingstatusapi.cosmos.CosmosClientManager import gov.cdc.ocio.processingstatusapi.model.CosmosDb import gov.cdc.ocio.processingstatusapi.model.HealthCheck -import gov.cdc.ocio.processingstatusapi.model.Jaeger import gov.cdc.ocio.processingstatusapi.model.ServiceBus import gov.cdc.ocio.processingstatusapi.model.reports.Report import mu.KotlinLogging @@ -33,10 +32,8 @@ class HealthCheckFunction( fun run(): HttpResponseMessage { var cosmosDBHealthy = false var serviceBusHealthy = false - var jaegerHealthy = false val cosmosDBHealth = CosmosDb() val serviceBusHealth = ServiceBus() - val jaegerHealth = Jaeger() val time = measureTimeMillis { try { cosmosDBHealthy = isCosmosDBHealthy() @@ -54,23 +51,13 @@ class HealthCheckFunction( logger.error("Azure Service Bus is not healthy: ${ex.message}") } - try { - jaegerHealthy = isJaegerHealthy() - if(jaegerHealthy){ - jaegerHealth.status = "UP" - } - } catch (ex: Exception) { - jaegerHealth.healthIssues = ex.message - logger.error("Jaeger is not healthy: ${ex.message}") - } } val result = HealthCheck().apply { - status = if (cosmosDBHealthy && serviceBusHealthy && jaegerHealthy) "UP" else "DOWN" + status = if (cosmosDBHealthy && serviceBusHealthy) "UP" else "DOWN" totalChecksDuration = formatMillisToHMS(time) dependencyHealthChecks.add(cosmosDBHealth) dependencyHealthChecks.add(serviceBusHealth) - dependencyHealthChecks.add(jaegerHealth) } if(result.status == "DOWN"){ @@ -122,14 +109,6 @@ class HealthCheckFunction( return true } - private fun isJaegerHealthy(): Boolean { - val healthEndPoint = System.getenv("JAEGER_HEALTH_END_POINT") - logger.info("healthEndPoint: $healthEndPoint") - val response = khttp.get(healthEndPoint) - logger.info("response.statusCode:" + response.statusCode) - return response.statusCode == HttpStatus.OK.value(); - } - /** * Format the time in milliseconds to 00:00:00.000 format. * diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/HealthCheck.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/HealthCheck.kt index d635fae4..2d4672dd 100644 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/HealthCheck.kt +++ b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/model/HealthCheck.kt @@ -19,10 +19,6 @@ class ServiceBus: HealthCheckSystem() { var service: String = "Azure Service Bus" } -class Jaeger: HealthCheckSystem() { - var service: String = "Jaeger" -} - class HealthCheck { var status : String? = "DOWN" From 88f42f03aacd74bdc925118f1bf15a1124b52aa2 Mon Sep 17 00:00:00 2001 From: Madhavi Tammineni Date: Thu, 20 Jun 2024 14:16:30 -0400 Subject: [PATCH 26/91] Remove tracing from postman --- .../test/status/GetStatusFunctionTests.kt | 4 +- ...cessing Status API.postman_collection.json | 599 ++---------------- 2 files changed, 58 insertions(+), 545 deletions(-) diff --git a/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt b/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt index e23ab837..bede1904 100644 --- a/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt +++ b/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt @@ -62,12 +62,12 @@ class GetStatusFunctionTests { } @Test - fun testCreateSuccess() { + fun testWithUploadIdFailure() { every { items.count() > 0} returns false every {mockResponse.statusCode} returns HttpStatus.OK.value() val response = GetStatusFunction(request).withUploadId("1"); - assert(response.status == HttpStatus.OK) + assert(response.status == HttpStatus.BAD_REQUEST) } diff --git a/processing-status-api-function-app/test/postman/Processing Status API.postman_collection.json b/processing-status-api-function-app/test/postman/Processing Status API.postman_collection.json index 55ae2a11..53d57281 100644 --- a/processing-status-api-function-app/test/postman/Processing Status API.postman_collection.json +++ b/processing-status-api-function-app/test/postman/Processing Status API.postman_collection.json @@ -1,392 +1,10 @@ { "info": { - "_postman_id": "111429b9-2820-43fe-be73-f0e0991d7684", + "_postman_id": "b5f1bd40-742a-4988-bd3b-f455dbacf8be", "name": "Processing Status API", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "608910" + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ - { - "name": "Trace", - "item": [ - { - "name": "Create \"dex-testing\" trace", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "pm.environment.set(\"traceId\", jsonData.trace_id);", - "pm.environment.set(\"parentSpanId\", jsonData.span_id);" - ], - "type": "text/javascript" - } - }, - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "var uploadId = uuid.v4();", - "console.log(uploadId);", - "", - "pm.environment.set(\"uploadId\", uploadId);" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/trace?uploadId={{uploadId}}&destinationId=dex-testing&eventType=test-event1", - "host": [ - "{{PROCESSING_STATUS_BASE_URL}}" - ], - "path": [ - "api", - "trace" - ], - "query": [ - { - "key": "uploadId", - "value": "{{uploadId}}" - }, - { - "key": "destinationId", - "value": "dex-testing" - }, - { - "key": "eventType", - "value": "test-event1" - } - ] - } - }, - "response": [] - }, - { - "name": "Start upload span", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "pm.environment.set(\"spanId\", jsonData.span_id);" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "[\n {\n \"key\": \"extra_field1\",\n \"value\": \"value1\"\n },\n {\n \"key\": \"extra_field2\",\n \"value\": \"value2\"\n }\n]", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/trace/startSpan/{{traceId}}/{{parentSpanId}}?stageName=dex-upload", - "host": [ - "{{PROCESSING_STATUS_BASE_URL}}" - ], - "path": [ - "api", - "trace", - "startSpan", - "{{traceId}}", - "{{parentSpanId}}" - ], - "query": [ - { - "key": "stageName", - "value": "dex-upload" - } - ] - } - }, - "response": [] - }, - { - "name": "Stop upload span", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [], - "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/trace/stopSpan/{{traceId}}/{{spanId}}", - "host": [ - "{{PROCESSING_STATUS_BASE_URL}}" - ], - "path": [ - "api", - "trace", - "stopSpan", - "{{traceId}}", - "{{spanId}}" - ] - } - }, - "response": [] - }, - { - "name": "Start routing span", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "var jsonData = JSON.parse(responseBody);", - "pm.environment.set(\"spanId\", jsonData.span_id);" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [], - "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/trace/startSpan/{{traceId}}/{{parentSpanId}}?stageName=dex-routing", - "host": [ - "{{PROCESSING_STATUS_BASE_URL}}" - ], - "path": [ - "api", - "trace", - "startSpan", - "{{traceId}}", - "{{parentSpanId}}" - ], - "query": [ - { - "key": "stageName", - "value": "dex-routing" - } - ] - } - }, - "response": [] - }, - { - "name": "Stop routing span", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [], - "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/trace/stopSpan/{{traceId}}/{{spanId}}", - "host": [ - "{{PROCESSING_STATUS_BASE_URL}}" - ], - "path": [ - "api", - "trace", - "stopSpan", - "{{traceId}}", - "{{spanId}}" - ] - } - }, - "response": [] - }, - { - "name": "Get trace (ref = traceId)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/trace/traceId/{{traceId}}", - "host": [ - "{{PROCESSING_STATUS_BASE_URL}}" - ], - "path": [ - "api", - "trace", - "traceId", - "{{traceId}}" - ] - } - }, - "response": [] - }, - { - "name": "Get trace (ref = uploadId)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/trace/uploadId/{{uploadId}}", - "host": [ - "{{PROCESSING_STATUS_BASE_URL}}" - ], - "path": [ - "api", - "trace", - "uploadId", - "{{uploadId}}" - ] - } - }, - "response": [] - }, - { - "name": "Get span (ref = uploadId, stageName)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/trace/span?uploadId={{uploadId}}&stageName=dex-upload", - "host": [ - "{{PROCESSING_STATUS_BASE_URL}}" - ], - "path": [ - "api", - "trace", - "span" - ], - "query": [ - { - "key": "uploadId", - "value": "{{uploadId}}" - }, - { - "key": "stageName", - "value": "dex-upload" - } - ] - } - }, - "response": [] - }, - { - "name": "Get trace directly", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{JAEGER_TRACE_END_POINT}}/api/traces/{{traceId}}", - "host": [ - "{{JAEGER_TRACE_END_POINT}}" - ], - "path": [ - "api", - "traces", - "{{traceId}}" - ] - } - }, - "response": [] - }, - { - "name": "Get list of services", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{JAEGER_TRACE_END_POINT}}/api/services", - "host": [ - "{{JAEGER_TRACE_END_POINT}}" - ], - "path": [ - "api", - "services" - ] - } - }, - "response": [] - }, - { - "name": "Get all \"dex-processing\" traces", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{JAEGER_TRACE_END_POINT}}/api/traces?limit=20000&service=dex-processing-status", - "host": [ - "{{JAEGER_TRACE_END_POINT}}" - ], - "path": [ - "api", - "traces" - ], - "query": [ - { - "key": "limit", - "value": "20000" - }, - { - "key": "service", - "value": "dex-processing-status" - } - ] - } - }, - "response": [] - } - ] - }, { "name": "Report", "item": [ @@ -425,12 +43,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_version\":\"0.0.1\",\n \"schema_name\":\"dex-metadata-verify\",\n \"filename\":\"some_upload1.csv\",\n \"metadata\":{\n \"meta_field2\":\"value3\"\n },\n \"issues\":[\n \"Missing required metadata field, 'meta_field1'.\",\n \"Metadata field, 'meta_field2' is set to 'value3' and does not contain one of the allowed values: [ 'value1', value2']\"\n ]\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_version\":\"0.0.1\",\n \"schema_name\":\"dex-metadata-verify\",\n \"filename\":\"some_upload1.csv\",\n \"metadata\":{\n \"meta_field2\":\"value3\"\n },\n \"issues\":[\n \"Missing required metadata field, 'meta_field1'.\",\n \"Metadata field, 'meta_field2' is set to 'value3' and does not contain one of the allowed values: [ 'value1', value2']\"\n ]\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-metadata-verify&destinationId=dex-testing&eventType=test-event1", @@ -494,12 +107,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"dex-metadata-verify\",\n \"filename\": \"some_upload1.csv\",\n \"metadata\":{\n \"meta_field1\":\"value1\"\n },\n \"issues\": null\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"dex-metadata-verify\",\n \"filename\": \"some_upload1.csv\",\n \"metadata\":{\n \"meta_field1\":\"value1\"\n },\n \"issues\": null\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-metadata-verify&destinationId=dex-testing&eventType=test-event1", @@ -559,12 +167,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\",\n \"tguid\": \"{{uploadId}}\",\n \"offset\": 0,\n \"size\": 27472691,\n \"filename\": \"some_upload1.csv\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"end_time_epoch_millis\": 1700009141546,\n \"start_time_epoch_millis\": 1700009137234,\n \"metadata\": {\n \"filename\": \"10MB-test-file\",\n \"filetype\": \"text/plain\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"meta_ext_source\": \"IZGW\",\n \"meta_ext_sourceversion\": \"V2022-12-31\",\n \"meta_ext_entity\": \"DD2\",\n \"meta_username\": \"ygj6@cdc.gov\",\n \"meta_ext_objectkey\": \"2b18d70c-8559-11ee-b9d1-0242ac120002\",\n \"meta_ext_filename\": \"10MB-test-file\",\n \"meta_ext_submissionperiod\": \"1\"\n }\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\",\n \"tguid\": \"{{uploadId}}\",\n \"offset\": 0,\n \"size\": 27472691,\n \"filename\": \"some_upload1.csv\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"end_time_epoch_millis\": 1700009141546,\n \"start_time_epoch_millis\": 1700009137234,\n \"metadata\": {\n \"filename\": \"10MB-test-file\",\n \"filetype\": \"text/plain\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"meta_ext_source\": \"IZGW\",\n \"meta_ext_sourceversion\": \"V2022-12-31\",\n \"meta_ext_entity\": \"DD2\",\n \"meta_username\": \"ygj6@cdc.gov\",\n \"meta_ext_objectkey\": \"2b18d70c-8559-11ee-b9d1-0242ac120002\",\n \"meta_ext_filename\": \"10MB-test-file\",\n \"meta_ext_submissionperiod\": \"1\"\n }\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", @@ -615,12 +218,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\",\n \"tguid\": \"{{uploadId}}\",\n \"offset\": 27472691,\n \"size\": 27472691,\n \"filename\": \"some_upload1.csv\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"end_time_epoch_millis\": 1700009141546,\n \"start_time_epoch_millis\": 1700009137234,\n \"metadata\": {\n \"filename\": \"10MB-test-file\",\n \"filetype\": \"text/plain\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"meta_ext_source\": \"IZGW\",\n \"meta_ext_sourceversion\": \"V2022-12-31\",\n \"meta_ext_entity\": \"DD2\",\n \"meta_username\": \"ygj6@cdc.gov\",\n \"meta_ext_objectkey\": \"2b18d70c-8559-11ee-b9d1-0242ac120002\",\n \"meta_ext_filename\": \"10MB-test-file\",\n \"meta_ext_submissionperiod\": \"1\"\n }\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\",\n \"tguid\": \"{{uploadId}}\",\n \"offset\": 27472691,\n \"size\": 27472691,\n \"filename\": \"some_upload1.csv\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"end_time_epoch_millis\": 1700009141546,\n \"start_time_epoch_millis\": 1700009137234,\n \"metadata\": {\n \"filename\": \"10MB-test-file\",\n \"filetype\": \"text/plain\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"meta_ext_source\": \"IZGW\",\n \"meta_ext_sourceversion\": \"V2022-12-31\",\n \"meta_ext_entity\": \"DD2\",\n \"meta_username\": \"ygj6@cdc.gov\",\n \"meta_ext_objectkey\": \"2b18d70c-8559-11ee-b9d1-0242ac120002\",\n \"meta_ext_filename\": \"10MB-test-file\",\n \"meta_ext_submissionperiod\": \"1\"\n }\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", @@ -671,12 +269,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"dex-file-copy\",\n \"schema_version\": \"1.0\",\n \"file_source_blob_url\": \"../csv_file1.csv\",\n \"file_destination_blob_url\": \".../edav/csv_file1_87847487844.csv\",\n \"timestamp\": \"\",\n \"result\": \"success|failed\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"dex-file-copy\",\n \"schema_version\": \"1.0\",\n \"file_source_blob_url\": \"../csv_file1.csv\",\n \"file_destination_blob_url\": \".../edav/csv_file1_87847487844.csv\",\n \"timestamp\": \"\",\n \"result\": \"success|failed\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-routing&destinationId=dex-testing&eventType=test-event1", @@ -727,12 +320,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"file_name\": \"postman-dtx022-19fcf009-3c83-48d0-be62-d33c2c68c45e.txt\",\n \"file_uuid\": \"{{$randomUUID}}\",\n \"routing_metadata\": {\n \"upload_id\": \"{{uploadId}}\"\n },\n \"message_batch\": \"SINGLE\",\n \"number_of_messages\": 100,\n \"number_of_messages_not_propagated\": 0,\n \"error_messages\": [],\n \"metadata\": {\n \"processes\": [\n {\n \"process_name\": \"RECEIVER\"\n }\n ]\n },\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"hl7_debatch_report\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"file_name\": \"postman-dtx022-19fcf009-3c83-48d0-be62-d33c2c68c45e.txt\",\n \"file_uuid\": \"{{$randomUUID}}\",\n \"routing_metadata\": {\n \"upload_id\": \"{{uploadId}}\"\n },\n \"message_batch\": \"SINGLE\",\n \"number_of_messages\": 100,\n \"number_of_messages_not_propagated\": 0,\n \"error_messages\": [],\n \"metadata\": {\n \"processes\": [\n {\n \"process_name\": \"RECEIVER\"\n }\n ]\n },\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"hl7_debatch_report\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=receiver&destinationId=dex-testing&eventType=test-event1", @@ -783,12 +371,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"id\": \"c5d9a1dd-f8e5-44fe-be7c-30cda29f6833\",\n \"message_uuid\": \"c5d9a1dd-f8e5-44fe-be7c-30cda29f6833\",\n \"message_info\": {\n \"event_code\": \"10110\",\n \"route\": \"hepatitis\",\n \"reporting_jurisdiction\": \"01\",\n \"type\": \"CASE\",\n \"local_record_id\": \"CAS13728088AL01\"\n },\n \"routing_metadata\": {\n \"upload_id\": \"UNKNOWN\"\n },\n \"metadata\": {\n \"provenance\": {\n \"event_id\": \"876b0c8b-601e-006d-38c5-5d346d06d59d\",\n \"event_timestamp\": \"2024-02-12T15:06:05.3884264Z\",\n \"file_uuid\": \"c933b793-65c5-4a5b-b5a1-ae15a8c02eba\",\n \"file_path\": \"https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/postman-dtx022-e4a79d2c-6654-420b-8d79-e4f2045fae0a.txt\",\n \"file_timestamp\": \"2024-02-12T15:06:05+00:00\",\n \"file_size\": 5513,\n \"single_or_batch\": \"SINGLE\",\n \"message_hash\": \"7e080a0ef4a2b0b7a1240304602b6197\",\n \"ext_system_provider\": \"POSTMAN\",\n \"ext_original_file_name\": \"postman-dtx022-e4a79d2c-6654-420b-8d79-e4f2045fae0a.txt\",\n \"message_index\": 1,\n \"ext_original_file_timestamp\": \"dtx0-test\",\n \"source_metadata\": {\n \"orginal_file_name\": \"test-da2e4021-4b56-48d0-add1-d3ad9f3d1c53.txt\"\n }\n },\n \"processes\": [\n {\n \"status\": \"SUCCESS\",\n \"process_name\": \"RECEIVER\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:06.579\",\n \"eventhub_offset\": 240518168576,\n \"eventhub_sequence_number\": 115413,\n \"configs\": [],\n \"start_processing_time\": \"2024-02-12T15:06:07.435+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:07.53+00:00\"\n },\n {\n \"status\": \"SUCCESS\",\n \"report\": {\n \"entries\": [],\n \"status\": \"SUCCESS\"\n },\n \"process_name\": \"REDACTOR\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:08.595\",\n \"eventhub_offset\": 124554051584,\n \"eventhub_sequence_number\": 5658,\n \"configs\": [\n \"case_config.txt\"\n ],\n \"start_processing_time\": \"2024-02-12T15:06:09.46+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:09.479+00:00\"\n },\n {\n \"status\": \"SUCCESS\",\n \"report\": {\n \"entries\": {\n \"structure\": [],\n \"content\": [],\n \"value-set\": []\n },\n \"error-count\": {\n \"structure\": 0,\n \"value-set\": 0,\n \"content\": 0\n },\n \"warning-count\": {\n \"structure\": 0,\n \"value-set\": 0,\n \"content\": 0\n },\n \"status\": \"VALID_MESSAGE\"\n },\n \"process_name\": \"STRUCTURE-VALIDATOR\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:10.47\",\n \"eventhub_offset\": 485331304448,\n \"eventhub_sequence_number\": 188792,\n \"configs\": [\n \"NOTF_ORU_V3.0\"\n ],\n \"start_processing_time\": \"2024-02-12T15:06:11.454+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:11.475+00:00\"\n }\n ]\n },\n \"summary\": {\n \"current_status\": \"VALID_MESSAGE\"\n },\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"DEX HL7v2\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"id\": \"c5d9a1dd-f8e5-44fe-be7c-30cda29f6833\",\n \"message_uuid\": \"c5d9a1dd-f8e5-44fe-be7c-30cda29f6833\",\n \"message_info\": {\n \"event_code\": \"10110\",\n \"route\": \"hepatitis\",\n \"reporting_jurisdiction\": \"01\",\n \"type\": \"CASE\",\n \"local_record_id\": \"CAS13728088AL01\"\n },\n \"routing_metadata\": {\n \"upload_id\": \"UNKNOWN\"\n },\n \"metadata\": {\n \"provenance\": {\n \"event_id\": \"876b0c8b-601e-006d-38c5-5d346d06d59d\",\n \"event_timestamp\": \"2024-02-12T15:06:05.3884264Z\",\n \"file_uuid\": \"c933b793-65c5-4a5b-b5a1-ae15a8c02eba\",\n \"file_path\": \"https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/postman-dtx022-e4a79d2c-6654-420b-8d79-e4f2045fae0a.txt\",\n \"file_timestamp\": \"2024-02-12T15:06:05+00:00\",\n \"file_size\": 5513,\n \"single_or_batch\": \"SINGLE\",\n \"message_hash\": \"7e080a0ef4a2b0b7a1240304602b6197\",\n \"ext_system_provider\": \"POSTMAN\",\n \"ext_original_file_name\": \"postman-dtx022-e4a79d2c-6654-420b-8d79-e4f2045fae0a.txt\",\n \"message_index\": 1,\n \"ext_original_file_timestamp\": \"dtx0-test\",\n \"source_metadata\": {\n \"orginal_file_name\": \"test-da2e4021-4b56-48d0-add1-d3ad9f3d1c53.txt\"\n }\n },\n \"processes\": [\n {\n \"status\": \"SUCCESS\",\n \"process_name\": \"RECEIVER\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:06.579\",\n \"eventhub_offset\": 240518168576,\n \"eventhub_sequence_number\": 115413,\n \"configs\": [],\n \"start_processing_time\": \"2024-02-12T15:06:07.435+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:07.53+00:00\"\n },\n {\n \"status\": \"SUCCESS\",\n \"report\": {\n \"entries\": [],\n \"status\": \"SUCCESS\"\n },\n \"process_name\": \"REDACTOR\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:08.595\",\n \"eventhub_offset\": 124554051584,\n \"eventhub_sequence_number\": 5658,\n \"configs\": [\n \"case_config.txt\"\n ],\n \"start_processing_time\": \"2024-02-12T15:06:09.46+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:09.479+00:00\"\n },\n {\n \"status\": \"SUCCESS\",\n \"report\": {\n \"entries\": {\n \"structure\": [],\n \"content\": [],\n \"value-set\": []\n },\n \"error-count\": {\n \"structure\": 0,\n \"value-set\": 0,\n \"content\": 0\n },\n \"warning-count\": {\n \"structure\": 0,\n \"value-set\": 0,\n \"content\": 0\n },\n \"status\": \"VALID_MESSAGE\"\n },\n \"process_name\": \"STRUCTURE-VALIDATOR\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:10.47\",\n \"eventhub_offset\": 485331304448,\n \"eventhub_sequence_number\": 188792,\n \"configs\": [\n \"NOTF_ORU_V3.0\"\n ],\n \"start_processing_time\": \"2024-02-12T15:06:11.454+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:11.475+00:00\"\n }\n ]\n },\n \"summary\": {\n \"current_status\": \"VALID_MESSAGE\"\n },\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"DEX HL7v2\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-hl7-structure&destinationId=dex-testing&eventType=test-event1", @@ -830,12 +413,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/uploadId/{{uploadId}}", @@ -862,12 +440,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/reportId/{{reportId}}", @@ -894,12 +467,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/uploadId/49f78a80-bf57-4858-bbfb-5b6c7f27ae22", @@ -926,15 +494,10 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/dex-upload", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/dex-upload?", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -965,12 +528,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/stage2?eventType=test-event1", @@ -1003,12 +561,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/dex-hl7-validation?eventType=test-event1", @@ -1031,7 +584,8 @@ }, "response": [] } - ] + ], + "_postman_isSubFolder": true }, { "name": "Error Paths", @@ -1043,12 +597,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_version\": \"1.0\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_version\": \"1.0\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", @@ -1087,12 +636,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"upload\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"upload\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", @@ -1131,12 +675,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&eventType=test-event1", @@ -1171,12 +710,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing", @@ -1211,12 +745,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_version\": \"1.0\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_version\": \"1.0\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", @@ -1255,12 +784,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"upload\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"upload\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", @@ -1299,12 +823,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&eventType=test-event1", @@ -1339,12 +858,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}", - "options": { - "raw": { - "language": "json" - } - } + "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing", @@ -1372,7 +886,8 @@ }, "response": [] } - ] + ], + "_postman_isSubFolder": true } ] }, @@ -1400,12 +915,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/status/{{uploadId}}", @@ -1442,12 +952,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/counts/{{uploadId}}", @@ -1485,12 +990,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/counts/9de71b83-54ed-4231-a426-ff5f208d75ff", @@ -1528,12 +1028,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/counts?destination_id=dex-testing&ext_event=test-event1&date_start=20240207T190000Z", @@ -1584,12 +1079,7 @@ "header": [], "body": { "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } + "raw": "" }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/status/9de71b83-54ed-4231-a426-ff5f208d75ff", @@ -1610,6 +1100,10 @@ "request": { "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/upload/dex-testing?page_number=1&page_size=20&sort_by=date&sort_order=descending&ext_event=test-event1&date_start=20240207T190000Z", "host": [ @@ -1657,6 +1151,10 @@ "request": { "method": "GET", "header": [], + "body": { + "mode": "raw", + "raw": "" + }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/health", "host": [ @@ -1669,6 +1167,21 @@ } }, "response": [] + }, + { + "name": "Processing Status API", + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "" + } + }, + "response": [] } ] } \ No newline at end of file From 570ec1df411691da32254d5d61f9cdbe1cfabb12 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Fri, 21 Jun 2024 16:03:57 -0500 Subject: [PATCH 27/91] DASB-474 - Add health rest endpoints to graphql and report-sink services (#118) * DASB-474 - Added /health REST endpoint to graphql service * Deleted unused file * Removed unused serializable annotations * Removed unnecessary KoinComponent inheritance from some classes * Added health endpoint to the report-sink, which checks connection to cosmos and the report topic * Added missing class and function headers * Removed nullable value for overall health status since can never be null * Added missing function header to health check * Added missing routing function header, format fix to getHealth function header --------- Co-authored-by: Matt B Krystof --- pstatus-graphql-ktor/build.gradle | 2 + .../ocio/processingstatusapi/Application.kt | 7 + .../CustomSchemaGeneratorHooks.kt | 35 ---- .../cosmos/CosmosRepository.kt | 6 +- .../processingstatusapi/plugins/Routing.kt | 14 ++ .../queries/HealthQueryService.kt | 2 +- pstatus-report-sink-ktor/build.gradle | 2 + .../ocio/processingstatusapi/Application.kt | 13 +- .../ocio/processingstatusapi/HealthCheck.kt | 170 ++++++++++++++++++ .../cosmos/CosmosRepository.kt | 7 +- .../processingstatusapi/plugins/Routing.kt | 10 +- .../processingstatusapi/plugins/ServiceBus.kt | 19 +- .../src/main/resources/application.conf | 1 - 13 files changed, 228 insertions(+), 60 deletions(-) delete mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/CustomSchemaGeneratorHooks.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt diff --git a/pstatus-graphql-ktor/build.gradle b/pstatus-graphql-ktor/build.gradle index 0baa39e7..edcf71e1 100644 --- a/pstatus-graphql-ktor/build.gradle +++ b/pstatus-graphql-ktor/build.gradle @@ -40,6 +40,8 @@ dependencies { implementation "io.ktor:ktor-server-status-pages:$ktor_version" implementation "io.ktor:ktor-server-auth:$ktor_version" implementation "io.ktor:ktor-server-auth-jwt:$ktor_version" + implementation "io.ktor:ktor-server-content-negotiation:$ktor_version" + implementation "io.ktor:ktor-serialization-jackson:$ktor_version" implementation "ch.qos.logback:logback-classic:$logback_version" implementation 'com.expediagroup:graphql-kotlin-ktor-server:7.1.1' implementation 'com.azure:azure-cosmos:4.55.0' 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 caa1941f..3648f23a 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 @@ -2,12 +2,15 @@ package gov.cdc.ocio.processingstatusapi import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository +import gov.cdc.ocio.processingstatusapi.plugins.configureRouting import gov.cdc.ocio.processingstatusapi.plugins.graphQLModule import graphql.scalars.ExtendedScalars import graphql.schema.idl.RuntimeWiring +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 org.koin.core.KoinApplication import org.koin.dsl.module import org.koin.ktor.plugin.Koin @@ -29,9 +32,13 @@ fun main(args: Array) { fun Application.module() { graphQLModule() + configureRouting() install(Koin) { loadKoinModules(environment) } + 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/CustomSchemaGeneratorHooks.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/CustomSchemaGeneratorHooks.kt deleted file mode 100644 index f0aba826..00000000 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/CustomSchemaGeneratorHooks.kt +++ /dev/null @@ -1,35 +0,0 @@ -package gov.cdc.ocio.processingstatusapi - -import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks -import graphql.language.StringValue -import graphql.schema.Coercing -import graphql.schema.GraphQLScalarType -import graphql.schema.GraphQLType -import java.util.* -import kotlin.reflect.KClass -import kotlin.reflect.KType - -//class CustomSchemaGeneratorHooks : SchemaGeneratorHooks { -// -// override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) { -// Date::class -> graphqlDateType -// else -> null -// } -//} -// -//val graphqlDateType = GraphQLScalarType.newScalar() -// .name("Date") -// .description("A type representing a formatted java.util.UUID") -// .coercing(DateCoercing) -// .build() -// -//object DateCoercing : Coercing { -// override fun parseValue(input: Any?): Date = Date.fromString(serialize(input)) -// -// override fun parseLiteral(input: Any?): UUID? { -// val dateString = (input as? StringValue)?.value -// return Date.fromString(dateString) -// } -// -// override fun serialize(dataFetcherResult: Any?): String = dataFetcherResult.toString() -//} diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt index 14f5dfe5..42f63e70 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt @@ -1,14 +1,12 @@ package gov.cdc.ocio.processingstatusapi.cosmos -import org.koin.core.component.KoinComponent - -class CosmosRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { +class CosmosRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String) { val reportsContainer = CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey) } -class CosmosDeadLetterRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { +class CosmosDeadLetterRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String) { val reportsDeadLetterContainer = CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt new file mode 100644 index 00000000..c3e5a9b6 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt @@ -0,0 +1,14 @@ +package gov.cdc.ocio.processingstatusapi.plugins + +import gov.cdc.ocio.processingstatusapi.queries.HealthQueryService +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Application.configureRouting() { + routing { + get("/health") { + call.respond(HealthQueryService().getHealth()) + } + } +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt index f219f002..783bc733 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt @@ -31,7 +31,7 @@ class HealthCheck { var totalChecksDuration : String? = null @GraphQLDescription("Status of the service dependencies") - var dependencyHealthChecks = arrayListOf() + var dependencyHealthChecks = mutableListOf() } class HealthQueryService : Query { diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index 1e98b9ba..417ccd99 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -36,6 +36,8 @@ configurations { dependencies { implementation("io.ktor:ktor-server-core-jvm") implementation("io.ktor:ktor-server-netty-jvm") + implementation "io.ktor:ktor-server-content-negotiation:$ktor_version" + implementation "io.ktor:ktor-serialization-jackson:$ktor_version" implementation("ch.qos.logback:logback-classic:$logback_version") implementation("com.azure:azure-messaging-servicebus:7.13.3") implementation("com.azure:azure-cosmos:4.55.0") 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 62493c2e..6f2e0640 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 @@ -2,11 +2,14 @@ package gov.cdc.ocio.processingstatusapi import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository +import gov.cdc.ocio.processingstatusapi.plugins.AzureServiceBusConfiguration import gov.cdc.ocio.processingstatusapi.plugins.configureRouting import gov.cdc.ocio.processingstatusapi.plugins.serviceBusModule +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 org.koin.core.KoinApplication import org.koin.dsl.module import org.koin.ktor.plugin.Koin @@ -24,8 +27,12 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp single(createdAtStart = true) { CosmosRepository(uri, authKey, "Reports", "/uploadId") } single(createdAtStart = true) { CosmosDeadLetterRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } } + val asbConfigModule = module { + // Create an azure service bus config that can be dependency injected (for health checks) + single(createdAtStart = true) { AzureServiceBusConfiguration(environment.config, configurationPath = "azure.service_bus") } + } - return modules(listOf(cosmosModule)) + return modules(listOf(cosmosModule, asbConfigModule)) } /** @@ -45,5 +52,7 @@ fun Application.module() { install(Koin) { loadKoinModules(environment) } - + install(ContentNegotiation) { + jackson() + } } 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 new file mode 100644 index 00000000..34131d4b --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/HealthCheck.kt @@ -0,0 +1,170 @@ +package gov.cdc.ocio.processingstatusapi + +import com.azure.cosmos.models.CosmosQueryRequestOptions +import com.azure.messaging.servicebus.ServiceBusClientBuilder +import com.azure.messaging.servicebus.models.ServiceBusReceiveMode +import com.microsoft.azure.servicebus.primitives.ServiceBusException +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository +import gov.cdc.ocio.processingstatusapi.models.Report +import gov.cdc.ocio.processingstatusapi.plugins.AzureServiceBusConfiguration +import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +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 cosmosdb service health check. + * + * @property service String + */ +class HealthCheckCosmosDb: HealthCheckSystem() { + override val service = "Cosmos DB" +} + +/** + * Concrete implementation of the Azure Service Bus health check. + * + * @property service String + */ +class HealthCheckServiceBus: HealthCheckSystem() { + override val service: String = "Azure Service Bus" +} + +/** + * 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 report-sink service and its dependencies. + * + * @property logger KLogger + * @property cosmosRepository CosmosRepository + * @property azureServiceBusConfiguration AzureServiceBusConfiguration + */ +class HealthQueryService: KoinComponent { + + private val logger = KotlinLogging.logger {} + + private val cosmosRepository by inject() + + private val azureServiceBusConfiguration by inject() + + /** + * Returns a HealthCheck object with the overall health of the report-sink service and its dependencies. + * + * @return HealthCheck + */ + fun getHealth(): HealthCheck { + var cosmosDBHealthy = false + var serviceBusHealthy = false + val cosmosDBHealth = HealthCheckCosmosDb() + val serviceBusHealth = HealthCheckServiceBus() + val time = measureTimeMillis { + try { + cosmosDBHealthy = isCosmosDBHealthy() + cosmosDBHealth.status = "UP" + } catch (ex: Exception) { + cosmosDBHealth.healthIssues = ex.message + logger.error("CosmosDB is not healthy: ${ex.message}") + } + + 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}") + } + } + + return HealthCheck().apply { + status = if (cosmosDBHealthy && serviceBusHealthy) "UP" else "DOWN" + totalChecksDuration = formatMillisToHMS(time) + dependencyHealthChecks.add(cosmosDBHealth) + dependencyHealthChecks.add(serviceBusHealth) + } + } + + /** + * Check whether CosmosDB is healthy. + * + * @return Boolean + */ + private fun isCosmosDBHealthy(): Boolean { + val sqlQuery = "select * t offset 0 limit 1" + cosmosRepository.reportsContainer?.queryItems( + sqlQuery, CosmosQueryRequestOptions(), + Report::class.java + ) + return true + } + + /** + * Check whether service bus is healthy. + * + * @return Boolean + */ + @Throws(InterruptedException::class, ServiceBusException::class) + private fun isServiceBusHealthy(config: AzureServiceBusConfiguration): Boolean { + + val receiverClient = ServiceBusClientBuilder() + .connectionString(config.connectionString) + .receiver() + .topicName(config.topicName) + .subscriptionName(config.subscriptionName) + .receiveMode(ServiceBusReceiveMode.PEEK_LOCK) // PEEK_LOCK mode to avoid consuming messages + .buildClient() + + // Attempt to open the connection + receiverClient.peekMessage() + + // Close the receiver client + receiverClient.close() + + return true + } + + /** + * 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-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt index f29c6c3e..79028294 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt @@ -1,7 +1,5 @@ package gov.cdc.ocio.processingstatusapi.cosmos -import org.koin.core.component.KoinComponent - /** * The class which initializes and creates an instance of a cosmos db reports container * @param uri :String @@ -10,10 +8,11 @@ import org.koin.core.component.KoinComponent * @param partitionKey:String * */ -class CosmosRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { +class CosmosRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String) { val reportsContainer = CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey) } + /** * The class which initializes and creates an instance of a cosmos db reports deadletter container * @param uri :String @@ -22,7 +21,7 @@ class CosmosRepository(uri: String, authKey: String, reportsContainerName: Strin * @param partitionKey:String * */ -class CosmosDeadLetterRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String): KoinComponent { +class CosmosDeadLetterRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String) { val reportsDeadLetterContainer = CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey) } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt index d93c52d7..c288e99e 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt @@ -1,13 +1,19 @@ package gov.cdc.ocio.processingstatusapi.plugins +import gov.cdc.ocio.processingstatusapi.HealthQueryService import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* +/** + * Configures the REST routing endpoints for the application. + * + * @receiver Application + */ fun Application.configureRouting() { routing { - get("/") { - call.respondText("Hello World!") + get("/health") { + call.respond(HealthQueryService().getHealth()) } } } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index 1f82ff54..1c8a1492 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -19,12 +19,12 @@ internal val LOGGER = KtorSimpleLogger("pstatus-report-sink") * @param config ApplicationConfig * */ -class AzureServiceBusConfiguration(config: ApplicationConfig) { - var connectionString: String = config.tryGetString("connection_string") ?: "" - var serviceBusNamespace: String = config.tryGetString("azure_servicebus_namespace") ?: "" - var queueName: String = config.tryGetString("queue_name") ?: "" - var topicName: String = config.tryGetString("topic_name") ?: "" - var subscriptionName: String = config.tryGetString("subscription_name") ?: "" +class AzureServiceBusConfiguration(config: ApplicationConfig, configurationPath: String? = null) { + private val configPath = if (configurationPath != null) "$configurationPath." else "" + val connectionString = config.tryGetString("${configPath}connection_string") ?: "" + val queueName = config.tryGetString("${configPath}queue_name") ?: "" + val topicName = config.tryGetString("${configPath}topic_name") ?: "" + val subscriptionName = config.tryGetString("${configPath}subscription_name") ?: "" } val AzureServiceBus = createApplicationPlugin( @@ -33,16 +33,14 @@ val AzureServiceBus = createApplicationPlugin( createConfiguration = ::AzureServiceBusConfiguration) { val connectionString = pluginConfig.connectionString - val serviceBusNamespace= pluginConfig.serviceBusNamespace val queueName = pluginConfig.queueName val topicName = pluginConfig.topicName val subscriptionName = pluginConfig.subscriptionName -// Initialize Service Bus client for queue + // Initialize Service Bus client for queue val processorQueueClient by lazy { ServiceBusClientBuilder() .connectionString(connectionString) - .fullyQualifiedNamespace(serviceBusNamespace) .transportType(AmqpTransportType.AMQP_WEB_SOCKETS) .processor() .queueName(queueName) @@ -51,11 +49,10 @@ val AzureServiceBus = createApplicationPlugin( .buildProcessorClient() } -// Initialize Service Bus client for topics + // Initialize Service Bus client for topics val processorTopicClient by lazy { ServiceBusClientBuilder() .connectionString(connectionString) - .fullyQualifiedNamespace(serviceBusNamespace) .transportType(AmqpTransportType.AMQP_WEB_SOCKETS) .processor() .topicName(topicName) diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index acfac84d..9f1db1db 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -12,7 +12,6 @@ ktor { azure { service_bus { connection_string = ${SERVICE_BUS_CONNECTION_STRING} - azure_servicebus_namespace=${AZURE_SERVICE_BUS_NAMESPACE} queue_name = ${SERVICE_BUS_REPORT_QUEUE_NAME} topic_name = ${SERVICE_BUS_REPORT_TOPIC_NAME} subscription_name = ${SERVICE_BUS_REPORT_TOPIC_SUBSCRIPTION_NAME} From 670804a7f194ea3e8fa36f00aa9b147ade969577 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 21 Jun 2024 17:28:15 -0400 Subject: [PATCH 28/91] converted fapp to notifications ktor microservice --- pstatus-graphql-ktor/build.gradle | 15 ++- .../mutations/NotificationsMutationService.kt | 99 ++++++++++++++++++ .../processingstatusapi/plugins/GraphQL.kt | 5 + .../queries/NotificationsQueryService.kt | 23 ++++ .../.gradle/8.5/checksums/checksums.lock | Bin 17 -> 17 bytes .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes pstatus-notifications-ktor/build.gradle | 42 +++----- .../src/main/kotlin/Application.kt | 4 +- .../src/main/kotlin/Routes.kt | 97 +++++++++++++---- .../main/kotlin/model/SubscriptionResult.kt | 13 --- .../SubscribeEmailNotifications.kt | 9 +- .../SubscribeWebhookNotifications.kt | 94 +++++++++++++++++ .../UnsubscribeNotifications.kt} | 19 ++-- .../src/main/resources/application.conf | 21 +--- 14 files changed, 341 insertions(+), 100 deletions(-) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/NotificationsQueryService.kt rename pstatus-notifications-ktor/src/main/kotlin/{email => notifications}/SubscribeEmailNotifications.kt (88%) create mode 100644 pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt rename pstatus-notifications-ktor/src/main/kotlin/{email/UnsubscribeEmailNotifications.kt => notifications/UnsubscribeNotifications.kt} (75%) diff --git a/pstatus-graphql-ktor/build.gradle b/pstatus-graphql-ktor/build.gradle index e82d35a3..c232f7e3 100644 --- a/pstatus-graphql-ktor/build.gradle +++ b/pstatus-graphql-ktor/build.gradle @@ -9,6 +9,8 @@ buildscript { plugins { id 'org.jetbrains.kotlin.jvm' version '1.9.24' id 'io.ktor.plugin' version '2.3.11' + id "org.jetbrains.kotlin.plugin.serialization" version "1.8.20" +// id ("kotlinx-serialization") } apply plugin: "java" apply plugin: "kotlin" @@ -45,6 +47,12 @@ dependencies { implementation "io.ktor:ktor-server-auth:$ktor_version" implementation "io.ktor:ktor-server-auth-jwt:$ktor_version" implementation "ch.qos.logback:logback-classic:$logback_version" + implementation("io.ktor:ktor-server-content-negotiation:2.1.0") + implementation("io.ktor:ktor-serialization-kotlinx-json:2.1.0") + implementation("io.ktor:ktor-client-core:2.1.0") + implementation("io.ktor:ktor-client-cio:2.1.0") + implementation("io.ktor:ktor-client-content-negotiation:2.1.0") + implementation("io.ktor:ktor-client-serialization:2.1.0") implementation 'com.expediagroup:graphql-kotlin-ktor-server:7.1.1' implementation 'com.azure:azure-cosmos:4.55.0' implementation 'io.github.microutils:kotlin-logging-jvm:3.0.5' @@ -54,7 +62,12 @@ dependencies { 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 "gov.cdc.io:pstatus-notifications:0.0.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" + implementation "io.ktor:ktor-client-logging:2.1.0" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0" testImplementation "io.ktor:ktor-server-tests-jvm:$ktor_version" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt new file mode 100644 index 00000000..22a9e811 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt @@ -0,0 +1,99 @@ +@file:Suppress("PLUGIN_IS_NOT_ENABLED") + +package gov.cdc.ocio.processingstatusapi.mutations + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import com.expediagroup.graphql.server.operations.Mutation +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable + + + +@Serializable +data class EmailSubscription(val dataStreamId:String, + val dataStreamRoute:String, + val email: String, + val stageName: String, + val statusType:String) + +@Serializable +data class UnSubscription(val subscriptionId:String) + + +@Serializable +data class SubscriptionResult( + var subscription_id: String? = null, + var timestamp: Long? = null, + var status: Boolean? = false, + var message: String? = "" +) + +/** + * The graphQL mutation class for notifications + */ + +class NotificationsMutationService : Mutation { + + private val client = HttpClient { + install(ContentNegotiation) { + json() + } + + } + + @GraphQLDescription("Subscribe Email Notifications") + @Suppress("unused") + fun subscribeEmail(dataStreamId:String, dataStreamRoute:String,email: String, stageName: String, statusType:String):SubscriptionResult { + return runBlocking { + client.post("http://localhost:8081/subscribe/email") { + contentType(io.ktor.http.ContentType.Application.Json) + setBody(EmailSubscription(dataStreamId, dataStreamRoute,email,stageName,statusType)) + }.body() + + } + + } + + @GraphQLDescription("Unsubscribe Email Notifications") + @Suppress("unused") + fun unsubscribeEmail(subscriptionId:String):SubscriptionResult { + return runBlocking { + client.post("http://localhost:8081/unsubscribe/email") { + contentType(ContentType.Application.Json) + setBody(UnSubscription(subscriptionId)) + }.body() + } + + } + + @GraphQLDescription("Subscribe Webhook Notifications") + @Suppress("unused") + fun subscribeWebhook(dataStreamId:String, dataStreamRoute:String,email: String, stageName: String, statusType:String):SubscriptionResult { + return runBlocking { + client.post("http://localhost:8081/subscribe/webhook") { + contentType(io.ktor.http.ContentType.Application.Json) + setBody(EmailSubscription(dataStreamId, dataStreamRoute,email,stageName,statusType)) + }.body() + + } + + } + + @GraphQLDescription("Unsubscribe Webhook Notifications") + @Suppress("unused") + fun unsubscribeWebhook(subscriptionId:String):SubscriptionResult { + return runBlocking { + client.post("http://localhost:8081/unsubscribe/webhook") { + contentType(ContentType.Application.Json) + setBody(UnSubscription(subscriptionId)) + }.body() + } + + } +} \ No newline at end of file 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 84a2f579..0d492c70 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.queries.* +import gov.cdc.ocio.processingstatusapi.mutations.* import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.server.application.* @@ -66,6 +67,10 @@ fun Application.graphQLModule() { ReportCountsQueryService(), ReportDeadLetterQueryService(), UploadQueryService() + + ) + mutations= listOf( + NotificationsMutationService() ) // subscriptions = listOf( // ErrorSubscriptionService() diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/NotificationsQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/NotificationsQueryService.kt new file mode 100644 index 00000000..38b2993b --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/NotificationsQueryService.kt @@ -0,0 +1,23 @@ +package gov.cdc.ocio.processingstatusapi.queries + + + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import com.expediagroup.graphql.server.operations.Query +import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDeadLetterDataLoader +import gov.cdc.ocio.processingstatusapi.loaders.ReportDeadLetterLoader +import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter +import graphql.schema.DataFetchingEnvironment +import java.util.concurrent.CompletableFuture + +class NotificationsQueryService : Query { + + @GraphQLDescription("Return all the subscriptions") + @Suppress("unused") + fun getSubscriptionsByRuleId(ruleId: String) = ReportDeadLetterLoader().getByUploadId(ruleId) + + @GraphQLDescription("Return all the subscriptions") + @Suppress("unused") + fun getSubscriptionById(subscriptionId: String) = ReportDeadLetterLoader().getByUploadId(subscriptionId) +} + diff --git a/pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock b/pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock index 4ea0e982f42c845eaae1b01ac6ff1aa82a6dea48..7416098c90ce783b48f9ec4e5bf9613cfe057106 100644 GIT binary patch literal 17 VcmZRUKRZwLDPzlT1~6d!001&@1qlEE literal 17 VcmZRUKRZwLDPzlT1~6be001&T1la%p diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock index 10d10e4ce29f425b2afb6082ea460fcf1772bc13..200cbe99c561c6cb70249f8effc5c1d82f4bae19 100644 GIT binary patch literal 17 VcmZR6)VyMuPwtv*1~AZ{3;;V?1xWw^ literal 17 VcmZR6)VyMuPwtv*1~8Ck2LL**1g!u7 diff --git a/pstatus-notifications-ktor/build.gradle b/pstatus-notifications-ktor/build.gradle index 9d6d0223..7fed7cbd 100644 --- a/pstatus-notifications-ktor/build.gradle +++ b/pstatus-notifications-ktor/build.gradle @@ -1,5 +1,5 @@ -import org.gradle.api.tasks.bundling.Jar +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile buildscript { repositories { @@ -15,6 +15,7 @@ plugins { id("com.google.cloud.tools.jib") version "3.3.0" // Add Jib plugin id "maven-publish" id 'java-library' + id "org.jetbrains.kotlin.plugin.serialization" version "1.8.20" } apply plugin: "java" apply plugin: "kotlin" @@ -24,26 +25,19 @@ java { targetCompatibility = JavaVersion.VERSION_17 } -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { +tasks.withType(KotlinCompile).all { kotlinOptions { jvmTarget = "17" } } -/*kotlin { - jvm { - withJava() - } -}*/ -group "gov.cdc.io" + +group "gov.cdc.ocio" version "0.0.1" mainClassName = "gov.cdc.ocio.processingstatusnotifications.ApplicationKt" -/*tasks.withType { - archiveBaseName.set("processingstatusnotifications") - archiveVersion.set("0.0.1") -}*/ + dependencies { @@ -51,7 +45,6 @@ dependencies { 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("com.azure:azure-messaging-servicebus:7.7.0") 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") @@ -63,6 +56,11 @@ dependencies { 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("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + 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("io.ktor:ktor-server-tests:2.3.2") testImplementation("io.ktor:ktor-server-tests:2.3.2") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.8.10") @@ -85,22 +83,6 @@ jib { } repositories{ - // mavenLocal() - mavenCentral() + mavenCentral() } -publishing { - publications { - maven(MavenPublication) { - groupId 'gov.cdc.io' - artifactId 'pstatus-notifications' - version '0.0.1' - from(components["java"]) - } - - - } - repositories { - mavenLocal() // Or specify another repository - } -} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/Application.kt b/pstatus-notifications-ktor/src/main/kotlin/Application.kt index 8afbaa8a..e15ccbf8 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/Application.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/Application.kt @@ -17,13 +17,15 @@ fun main(args: Array) { } fun Application.module() { - // graphQLModule() + install(ContentNegotiation) { json() } routing { subscribeEmailNotificationRoute() unsubscribeEmailNotificationRoute() + subscribeWebhookRoute() + unsubscribeWebhookRoute() } } diff --git a/pstatus-notifications-ktor/src/main/kotlin/Routes.kt b/pstatus-notifications-ktor/src/main/kotlin/Routes.kt index 0bba11fd..8020a5f5 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/Routes.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/Routes.kt @@ -1,41 +1,94 @@ +@file:Suppress("PLUGIN_IS_NOT_ENABLED") + package gov.cdc.ocio.processingstatusnotifications -import gov.cdc.ocio.processingstatusnotifications.email.UnsubscribeEmailNotifications +import gov.cdc.ocio.processingstatusnotifications.notifications.UnSubscribeNotifications +import gov.cdc.ocio.processingstatusnotifications.notifications.SubscribeEmailNotifications +import gov.cdc.ocio.processingstatusnotifications.notifications.SubscribeWebhookNotifications import kotlinx.serialization.Serializable -import io.ktor.server.plugins.* -import io.ktor.serialization.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import io.ktor.server.engine.* -import io.ktor.server.netty.* +/** + * Email Subscription data class which is serialized back and forth + */ +@Serializable +data class EmailSubscription(val dataStreamId:String, + val dataStreamRoute:String, + val email: String, + val stageName: String, + val statusType:String) +/** + * Webhook Subscription data class which is serialized back and forth + */ +@Serializable +data class WebhookSubscription(val dataStreamId:String, + val dataStreamRoute:String, + val url: String, + val stageName: String, + val statusType:String) + +/** + * UnSubscription data class which is serialized back and forth when we need to unsubscribe by the subscriptionId + */ +@Serializable +data class UnSubscription(val subscriptionId:String) + +/** + * The resultant class for subscription of email/webhooks + */ @Serializable -data class EmailSubscription(val dataStreamId:String, val dataStreamRoute:String,val email: String, val stageName: String, val statusType:String, val subscriptionId:String) +data class SubscriptionResult( + var subscription_id: String? = null, + var timestamp: Long? = null, + var status: Boolean? = false, + var message: String? = "" +) +/** + * Route to subscribe for email notifications + */ fun Route.subscribeEmailNotificationRoute() { - post("subscribe/email/{dataStreamId}/{dataStreamRoute}") { + post("/subscribe/email") { val subscription = call.receive() - val result = SubscribeEmailNotifications().run(subscription); - if (result.subscription_id!=null) { - call.respond("Subscription successful!") - } else { - call.respond("Failed to send subscription email.") - } + val emailSubscription =EmailSubscription(subscription.dataStreamId, subscription.dataStreamRoute, subscription.email, subscription.stageName, subscription.statusType) + val result = SubscribeEmailNotifications().run(emailSubscription); + call.respond(result) + } } - +/** + * Route to Unsubscribe for email notifications + */ fun Route.unsubscribeEmailNotificationRoute() { - post("unsubscribe/{subscriptionId}") { - val subscription = call.receive() - val result = UnsubscribeEmailNotifications().run(subscription.subscriptionId); - if (result.subscription_id!=null) { - call.respond("Unsubscription successful") - } else { - call.respond("Unsubscription unsuccessful") - } + post("/unsubscribe/email") { + val subscription = call.receive() + val result = UnSubscribeNotifications().run(subscription.subscriptionId); + call.respond(result) } } +/** + * Route to subscribe for webhook notifications + */ +fun Route.subscribeWebhookRoute() { + post("/subscribe/webhook") { + val subscription = call.receive() + val webhookSubscription = WebhookSubscription(subscription.dataStreamId, subscription.dataStreamRoute, subscription.url, subscription.stageName, subscription.statusType) + val result = SubscribeWebhookNotifications().run(webhookSubscription); + call.respond(result) + } +} +/** + * Route to unsubscribe for webhook notifications + */ +fun Route.unsubscribeWebhookRoute() { + post("/unsubscribe/webhook") { + val subscription = call.receive() + val result = UnSubscribeNotifications().run(subscription.subscriptionId); + call.respond(result) + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/model/SubscriptionResult.kt b/pstatus-notifications-ktor/src/main/kotlin/model/SubscriptionResult.kt index 033db750..ec3ceac6 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/model/SubscriptionResult.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/model/SubscriptionResult.kt @@ -1,18 +1,5 @@ package gov.cdc.ocio.processingstatusnotifications -class SubscriptionResult { - var subscription_id: String? = null - var timestamp: Long? = null - var status: Boolean? = false - var message: String? = "" -} - -enum class StatusType { - SUCCESS, - WARNING, - FAILURE -} - enum class SubscriptionType { EMAIL, WEBSOCKET diff --git a/pstatus-notifications-ktor/src/main/kotlin/email/SubscribeEmailNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt similarity index 88% rename from pstatus-notifications-ktor/src/main/kotlin/email/SubscribeEmailNotifications.kt rename to pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt index 4beb086a..3ebe676d 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/email/SubscribeEmailNotifications.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt @@ -1,5 +1,8 @@ -package gov.cdc.ocio.processingstatusnotifications +package gov.cdc.ocio.processingstatusnotifications.notifications +import gov.cdc.ocio.processingstatusnotifications.EmailSubscription +import gov.cdc.ocio.processingstatusnotifications.SubscriptionResult +import gov.cdc.ocio.processingstatusnotifications.SubscriptionType import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService import mu.KotlinLogging import java.time.Instant @@ -57,7 +60,9 @@ class SubscribeEmailNotifications(){ result.status = false result.message = "Not valid email address" } else { - result.subscription_id = cacheService.updateNotificationsPreferences(dataStreamId, dataStreamRoute, stageName, statusType, email, SubscriptionType.EMAIL) + result.subscription_id = cacheService.updateNotificationsPreferences(dataStreamId, dataStreamRoute, stageName, statusType, email, + SubscriptionType.EMAIL + ) result.timestamp = Instant.now().epochSecond result.status = true result.message = "Subscription for Email setup" diff --git a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt new file mode 100644 index 00000000..bb67c70d --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt @@ -0,0 +1,94 @@ +package gov.cdc.ocio.processingstatusnotifications.notifications + +import gov.cdc.ocio.processingstatusnotifications.SubscriptionResult +import gov.cdc.ocio.processingstatusnotifications.SubscriptionType +import gov.cdc.ocio.processingstatusnotifications.WebhookSubscription +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService +import mu.KotlinLogging +import java.time.Instant + +/** +* This method is used by graphL endpoints to subscribe for Webhook notifications + * based on rules sent in required parameters/arguments + * dataStreamId + * dataStreamRoute + * stageName + * statusType ("warning", "success", "error") + * url (websocket url) + * + * + * @property request HttpRequestMessage> + * @property logger KLogger + * @property cacheService InMemoryCacheService + * @constructor + */ +class SubscribeWebhookNotifications() + { + private val logger = KotlinLogging.logger {} + private val cacheService: InMemoryCacheService = InMemoryCacheService() + + /** + * + */ + + fun run(subscription: WebhookSubscription): + SubscriptionResult { + + val dataStreamId = subscription.dataStreamId + val dataStreamRoute = subscription.dataStreamRoute + val url = subscription.url + val stageName = subscription.stageName + val statusType = subscription.statusType + + logger.debug("dataStreamId: $dataStreamId") + logger.debug("dataStreamRoute: $dataStreamRoute") + logger.debug("Subscription Url: $url") + logger.debug("StageName: $stageName") + logger.debug("StatusType: $statusType") + + val subscriptionResult = subscribeForWebhook(dataStreamId, dataStreamRoute, url, stageName, statusType) + if (subscriptionResult.subscription_id != null) { + subscriptionResult.message = "Subscription successful" + return subscriptionResult + } + subscriptionResult.message = "Invalid Request" + subscriptionResult.status = false + + return subscriptionResult + } + + /** + * + */ + private fun subscribeForWebhook( + dataStreamId: String, + dataStreamRoute: String, + url: String?, + stageName: String?, + statusType: String? + ): SubscriptionResult { + + val result = SubscriptionResult() + if (dataStreamId.isBlank() + || dataStreamRoute.isBlank() + || url.isNullOrBlank() + || stageName.isNullOrBlank() + || statusType.isNullOrBlank()) { + result.status = false + result.message = "Required fields not sent in request" + } else if (!url.lowercase().startsWith("ws")) { + result.status = false + result.message = "Not valid url address" + } else if (!(statusType.equals("success", true) || statusType.equals("warning", true) || statusType.equals("failure", true))) { + result.status = false + result.message = "Not valid status" + } else { + result.subscription_id = cacheService.updateNotificationsPreferences(dataStreamId, dataStreamRoute, stageName, statusType, url, SubscriptionType.WEBSOCKET) + result.timestamp = Instant.now().epochSecond + result.status = true + result.message = "Subscription for Webhook setup" + } + + return result + } +} \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/email/UnsubscribeEmailNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/UnsubscribeNotifications.kt similarity index 75% rename from pstatus-notifications-ktor/src/main/kotlin/email/UnsubscribeEmailNotifications.kt rename to pstatus-notifications-ktor/src/main/kotlin/notifications/UnsubscribeNotifications.kt index 995b2d2c..fbd369ed 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/email/UnsubscribeEmailNotifications.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/UnsubscribeNotifications.kt @@ -1,8 +1,5 @@ -package gov.cdc.ocio.processingstatusnotifications.email +package gov.cdc.ocio.processingstatusnotifications.notifications - - -import java.util.* import gov.cdc.ocio.processingstatusnotifications.SubscriptionResult import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService import java.time.Instant @@ -11,14 +8,12 @@ import mu.KotlinLogging * This method is used by HTTP endpoints to unsubscribe for any notifications * by passing required parameter of subscriptionId * - * @property request HttpRequestMessage> * @property logger KLogger * @property cacheService InMemoryCacheService * @constructor */ -class UnsubscribeEmailNotifications( - -) { +class UnSubscribeNotifications() + { private val logger = KotlinLogging.logger {} private val cacheService: InMemoryCacheService = InMemoryCacheService() @@ -26,16 +21,16 @@ class UnsubscribeEmailNotifications( logger.debug { "SubscriptionId $subscriptionId" } val result = SubscriptionResult() - val unsubscribeSuccessfull = unsubscribeNotifications(subscriptionId) - if (subscriptionId.isNotBlank() && unsubscribeSuccessfull) { + val unsubscribeSuccessful = unsubscribeNotifications(subscriptionId) + if (subscriptionId.isNotBlank() && unsubscribeSuccessful) { result.subscription_id = subscriptionId result.timestamp = Instant.now().epochSecond result.status = false - result.message = "Unsubscription successful" + result.message = "UnSubscription successful" } else { result.status = false - result.message = "Unsubscription unsuccessful" + result.message = "UnSubscription unsuccessful" } return result diff --git a/pstatus-notifications-ktor/src/main/resources/application.conf b/pstatus-notifications-ktor/src/main/resources/application.conf index 7f53bc35..fefe723e 100644 --- a/pstatus-notifications-ktor/src/main/resources/application.conf +++ b/pstatus-notifications-ktor/src/main/resources/application.conf @@ -1,28 +1,11 @@ ktor { deployment { - port = 8080 + port = 8081 host = 0.0.0.0 } application { - modules = [ gov.cdc.ocio.notificationsstatusapi.ApplicationKt.module ] + modules = [ gov.cdc.ocio.processingstatusnotifications.ApplicationKt.module ] } } -azure { - service_bus { - connection_string = ${SERVICE_BUS_CONNECTION_STRING} - azure_servicebus_namespace=${AZURE_SERVICE_BUS_NAMESPACE} - queue_name = ${SERVICE_BUS_REPORT_QUEUE_NAME} - topic_name = ${SERVICE_BUS_REPORT_TOPIC_NAME} - subscription_name = ${SERVICE_BUS_REPORT_TOPIC_SUBSCRIPTION_NAME} - } - cosmos_db { - client { - endpoint = ${COSMOS_DB_CLIENT_ENDPOINT} - key = ${COSMOS_DB_CLIENT_KEY} - } - database_name = "ProcessingStatus" - container_name = "Reports" - } -} \ No newline at end of file From 7f1dc23590e88827602f01c265e32afacdea048e Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 21 Jun 2024 17:32:18 -0400 Subject: [PATCH 29/91] Code refactored --- .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes .../src/main/kotlin/Routes.kt | 8 ++++---- .../notifications/SubscribeEmailNotifications.kt | 13 ++++--------- .../SubscribeWebhookNotifications.kt | 2 +- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock index 200cbe99c561c6cb70249f8effc5c1d82f4bae19..3cd14982288097ecb6169e74bb25ca3170c5172e 100644 GIT binary patch literal 17 VcmZR6)VyMuPwtv*1~4$F1^_!m1u_5t literal 17 VcmZR6)VyMuPwtv*1~AZ{3;;V?1xWw^ diff --git a/pstatus-notifications-ktor/src/main/kotlin/Routes.kt b/pstatus-notifications-ktor/src/main/kotlin/Routes.kt index 8020a5f5..16e70fec 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/Routes.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/Routes.kt @@ -55,7 +55,7 @@ fun Route.subscribeEmailNotificationRoute() { post("/subscribe/email") { val subscription = call.receive() val emailSubscription =EmailSubscription(subscription.dataStreamId, subscription.dataStreamRoute, subscription.email, subscription.stageName, subscription.statusType) - val result = SubscribeEmailNotifications().run(emailSubscription); + val result = SubscribeEmailNotifications().run(emailSubscription) call.respond(result) } @@ -66,7 +66,7 @@ fun Route.subscribeEmailNotificationRoute() { fun Route.unsubscribeEmailNotificationRoute() { post("/unsubscribe/email") { val subscription = call.receive() - val result = UnSubscribeNotifications().run(subscription.subscriptionId); + val result = UnSubscribeNotifications().run(subscription.subscriptionId) call.respond(result) } } @@ -77,7 +77,7 @@ fun Route.subscribeWebhookRoute() { post("/subscribe/webhook") { val subscription = call.receive() val webhookSubscription = WebhookSubscription(subscription.dataStreamId, subscription.dataStreamRoute, subscription.url, subscription.stageName, subscription.statusType) - val result = SubscribeWebhookNotifications().run(webhookSubscription); + val result = SubscribeWebhookNotifications().run(webhookSubscription) call.respond(result) } @@ -88,7 +88,7 @@ fun Route.subscribeWebhookRoute() { fun Route.unsubscribeWebhookRoute() { post("/unsubscribe/webhook") { val subscription = call.receive() - val result = UnSubscribeNotifications().run(subscription.subscriptionId); + val result = UnSubscribeNotifications().run(subscription.subscriptionId) call.respond(result) } } \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt index 3ebe676d..a027b66c 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt @@ -25,16 +25,11 @@ class SubscribeEmailNotifications(){ logger.debug("StageName: $stageName") logger.debug("StatusType: $statusType") - var subscriptionResult = SubscriptionResult() - if (!(email == null || stageName == null || statusType == null)) { - subscriptionResult = subscribeForEmail(dataStreamId, dataStreamRoute, email, stageName, statusType) - if (subscriptionResult.subscription_id != null) { - return subscriptionResult + val subscriptionResult = subscribeForEmail(dataStreamId, dataStreamRoute, email, stageName, statusType) + if (subscriptionResult.subscription_id == null) { + subscriptionResult.message = "Invalid Request" + subscriptionResult.status = false } - } - subscriptionResult.message = "Invalid Request" - subscriptionResult.status = false - return subscriptionResult } diff --git a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt index bb67c70d..5cbd3ee9 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt @@ -17,7 +17,7 @@ import java.time.Instant * url (websocket url) * * - * @property request HttpRequestMessage> + * @property logger KLogger * @property cacheService InMemoryCacheService * @constructor From 6cca4d973922e022450707f2298d40855ac09a7f Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 21 Jun 2024 17:34:29 -0400 Subject: [PATCH 30/91] Code refactored --- pstatus-notifications-ktor/build.gradle | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pstatus-notifications-ktor/build.gradle b/pstatus-notifications-ktor/build.gradle index 7fed7cbd..5e1711df 100644 --- a/pstatus-notifications-ktor/build.gradle +++ b/pstatus-notifications-ktor/build.gradle @@ -25,20 +25,15 @@ java { targetCompatibility = JavaVersion.VERSION_17 } -tasks.withType(KotlinCompile).all { +tasks.withType(KotlinCompile).configureEach { kotlinOptions { jvmTarget = "17" } } - - group "gov.cdc.ocio" version "0.0.1" -mainClassName = "gov.cdc.ocio.processingstatusnotifications.ApplicationKt" - - dependencies { implementation("io.ktor:ktor-server-core:2.3.2") From b71a66d383522ca135763a0d935428ade3621560 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 21 Jun 2024 17:37:06 -0400 Subject: [PATCH 31/91] Removed unused file --- .../queries/NotificationsQueryService.kt | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/NotificationsQueryService.kt diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/NotificationsQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/NotificationsQueryService.kt deleted file mode 100644 index 38b2993b..00000000 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/NotificationsQueryService.kt +++ /dev/null @@ -1,23 +0,0 @@ -package gov.cdc.ocio.processingstatusapi.queries - - - -import com.expediagroup.graphql.generator.annotations.GraphQLDescription -import com.expediagroup.graphql.server.operations.Query -import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDeadLetterDataLoader -import gov.cdc.ocio.processingstatusapi.loaders.ReportDeadLetterLoader -import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter -import graphql.schema.DataFetchingEnvironment -import java.util.concurrent.CompletableFuture - -class NotificationsQueryService : Query { - - @GraphQLDescription("Return all the subscriptions") - @Suppress("unused") - fun getSubscriptionsByRuleId(ruleId: String) = ReportDeadLetterLoader().getByUploadId(ruleId) - - @GraphQLDescription("Return all the subscriptions") - @Suppress("unused") - fun getSubscriptionById(subscriptionId: String) = ReportDeadLetterLoader().getByUploadId(subscriptionId) -} - From 1d2f303361c71cb4f595556f326c035c01b74d42 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 21 Jun 2024 17:49:06 -0400 Subject: [PATCH 32/91] Code refactored --- .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes .../SubscribeEmailNotifications.kt | 23 +++++++++++++++++- .../SubscribeWebhookNotifications.kt | 20 +++++++++------ .../notifications/UnsubscribeNotifications.kt | 16 +++++++++--- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock index 3cd14982288097ecb6169e74bb25ca3170c5172e..570d4c5f2af761c263c22ff7e5c489bc0a87f596 100644 GIT binary patch literal 17 VcmZR6)VyMuPwtv*1~4!b0suPQ1j+ya literal 17 VcmZR6)VyMuPwtv*1~4$F1^_!m1u_5t diff --git a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt index a027b66c..ea0fbad1 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt @@ -7,10 +7,23 @@ import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService import mu.KotlinLogging import java.time.Instant -class SubscribeEmailNotifications(){ +/** + * Class for subscribing for email notifications + * dataStreamId + * dataStreamRoute + * email + * stageName + * statusType ("warning", "success", "error") + * + */ +class SubscribeEmailNotifications{ private val logger = KotlinLogging.logger {} private val cacheService: InMemoryCacheService = InMemoryCacheService() + /** + * The function which validates and subscribes for email notifications + * @param subscription EmailSubscription + */ fun run(subscription: EmailSubscription): SubscriptionResult { val dataStreamId = subscription.dataStreamId @@ -33,6 +46,14 @@ class SubscribeEmailNotifications(){ return subscriptionResult } + /** + * This function validates and updates the notification preferences of the cacheService + * @param dataStreamId String + * @param dataStreamRoute String + * @param email String + * @param stageName String + * @param statusType String + */ private fun subscribeForEmail( dataStreamId: String, dataStreamRoute: String, diff --git a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt index 5cbd3ee9..002680e8 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeWebhookNotifications.kt @@ -22,14 +22,15 @@ import java.time.Instant * @property cacheService InMemoryCacheService * @constructor */ -class SubscribeWebhookNotifications() +class SubscribeWebhookNotifications { private val logger = KotlinLogging.logger {} private val cacheService: InMemoryCacheService = InMemoryCacheService() - /** - * - */ + /** + * The function which validates and subscribes for webhook notifications + * @param subscription WebhookSubscription + */ fun run(subscription: WebhookSubscription): SubscriptionResult { @@ -57,9 +58,14 @@ class SubscribeWebhookNotifications() return subscriptionResult } - /** - * - */ + /** + * This function validates and updates the notification preferences of the cacheService + * @param dataStreamId String + * @param dataStreamRoute String + * @param url String + * @param stageName String + * @param statusType String + */ private fun subscribeForWebhook( dataStreamId: String, dataStreamRoute: String, diff --git a/pstatus-notifications-ktor/src/main/kotlin/notifications/UnsubscribeNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/UnsubscribeNotifications.kt index fbd369ed..9cbffa8d 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/notifications/UnsubscribeNotifications.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/UnsubscribeNotifications.kt @@ -4,19 +4,23 @@ import gov.cdc.ocio.processingstatusnotifications.SubscriptionResult import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService import java.time.Instant import mu.KotlinLogging + + /** - * This method is used by HTTP endpoints to unsubscribe for any notifications - * by passing required parameter of subscriptionId - * + * @property logger KLogger * @property cacheService InMemoryCacheService * @constructor */ -class UnSubscribeNotifications() +class UnSubscribeNotifications { private val logger = KotlinLogging.logger {} private val cacheService: InMemoryCacheService = InMemoryCacheService() + /** + * The function which validates and Unsubscribes for webhook notifications + * @param subscriptionId String + */ fun run(subscriptionId: String): SubscriptionResult { logger.debug { "SubscriptionId $subscriptionId" } @@ -36,6 +40,10 @@ class UnSubscribeNotifications() return result } + /** + * Function which unsubscribes based on subscription id from the cache service + * @param subscriptionId String + */ private fun unsubscribeNotifications( subscriptionId: String, ): Boolean { From b32f79a34fd364530d295bb18fd20fe46be4c5cc Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 21 Jun 2024 17:51:56 -0400 Subject: [PATCH 33/91] Code refactored --- .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes .../notifications/SubscribeEmailNotifications.kt | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock index 570d4c5f2af761c263c22ff7e5c489bc0a87f596..3b4d64312de9d084e229451e2d3fa3c77e36a88f 100644 GIT binary patch literal 17 VcmZR6)VyMuPwtv*1~4!T2LL-F1rGoK literal 17 VcmZR6)VyMuPwtv*1~4!b0suPQ1j+ya diff --git a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt index ea0fbad1..e7bb0904 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/notifications/SubscribeEmailNotifications.kt @@ -8,7 +8,8 @@ import mu.KotlinLogging import java.time.Instant /** - * Class for subscribing for email notifications + * This method is used by graphL endpoints to subscribe for Webhook notifications + * * based on rules sent in required parameters/arguments * dataStreamId * dataStreamRoute * email From 1e3d99425a37195515bc1be0c73221c946da72cf Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Mon, 24 Jun 2024 12:48:47 -0400 Subject: [PATCH 34/91] Added unit tests and introduced the en variable for notification base url endpoint --- .../mutations/NotificationsMutationService.kt | 16 ++-- .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes pstatus-notifications-ktor/build.gradle | 1 + .../kotlin/cache/InMemoryCacheServiceTest.kt | 73 ++++++++++++++++++ .../test/kotlin/cache/InMemoryCacheTest.kt | 58 ++++++++++++++ 5 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt create mode 100644 pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheTest.kt diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt index 22a9e811..a91ccdee 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt @@ -13,8 +13,6 @@ import io.ktor.http.* import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable - - @Serializable data class EmailSubscription(val dataStreamId:String, val dataStreamRoute:String, @@ -39,7 +37,7 @@ data class SubscriptionResult( */ class NotificationsMutationService : Mutation { - + val notificationsRouteBaseUrl =System.getenv("PSTATUS_NOTIFICATIONS_BASE_URL") private val client = HttpClient { install(ContentNegotiation) { json() @@ -50,8 +48,9 @@ class NotificationsMutationService : Mutation { @GraphQLDescription("Subscribe Email Notifications") @Suppress("unused") fun subscribeEmail(dataStreamId:String, dataStreamRoute:String,email: String, stageName: String, statusType:String):SubscriptionResult { + val url = "$notificationsRouteBaseUrl/subscribe/email" return runBlocking { - client.post("http://localhost:8081/subscribe/email") { + client.post(url) { contentType(io.ktor.http.ContentType.Application.Json) setBody(EmailSubscription(dataStreamId, dataStreamRoute,email,stageName,statusType)) }.body() @@ -63,8 +62,9 @@ class NotificationsMutationService : Mutation { @GraphQLDescription("Unsubscribe Email Notifications") @Suppress("unused") fun unsubscribeEmail(subscriptionId:String):SubscriptionResult { + val url = "$notificationsRouteBaseUrl/unsubscribe/email" return runBlocking { - client.post("http://localhost:8081/unsubscribe/email") { + client.post(url) { contentType(ContentType.Application.Json) setBody(UnSubscription(subscriptionId)) }.body() @@ -75,8 +75,9 @@ class NotificationsMutationService : Mutation { @GraphQLDescription("Subscribe Webhook Notifications") @Suppress("unused") fun subscribeWebhook(dataStreamId:String, dataStreamRoute:String,email: String, stageName: String, statusType:String):SubscriptionResult { + val url = "$notificationsRouteBaseUrl/subscribe/webhook" return runBlocking { - client.post("http://localhost:8081/subscribe/webhook") { + client.post(url) { contentType(io.ktor.http.ContentType.Application.Json) setBody(EmailSubscription(dataStreamId, dataStreamRoute,email,stageName,statusType)) }.body() @@ -88,8 +89,9 @@ class NotificationsMutationService : Mutation { @GraphQLDescription("Unsubscribe Webhook Notifications") @Suppress("unused") fun unsubscribeWebhook(subscriptionId:String):SubscriptionResult { + val url = "$notificationsRouteBaseUrl/unsubscribe/webhook" return runBlocking { - client.post("http://localhost:8081/unsubscribe/webhook") { + client.post(url) { contentType(ContentType.Application.Json) setBody(UnSubscription(subscriptionId)) }.body() diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock index 3b4d64312de9d084e229451e2d3fa3c77e36a88f..b1dcdf3bcc5f0459c3bcce0bebdb0a34a5355e13 100644 GIT binary patch literal 17 VcmZR6)VyMuPwtv*1~4!R0suQ71q%QG literal 17 VcmZR6)VyMuPwtv*1~4!T2LL-F1rGoK diff --git a/pstatus-notifications-ktor/build.gradle b/pstatus-notifications-ktor/build.gradle index 5e1711df..66e3323c 100644 --- a/pstatus-notifications-ktor/build.gradle +++ b/pstatus-notifications-ktor/build.gradle @@ -58,6 +58,7 @@ dependencies { implementation "io.ktor:ktor-client-content-negotiation:2.1.0" testImplementation("io.ktor:ktor-server-tests:2.3.2") testImplementation("io.ktor:ktor-server-tests:2.3.2") + testImplementation "org.testng:testng:7.4.0" testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.8.10") } diff --git a/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt new file mode 100644 index 00000000..af4890c0 --- /dev/null +++ b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt @@ -0,0 +1,73 @@ +package cache + + + +import gov.cdc.ocio.processingstatusnotifications.SubscriptionType +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService +import gov.cdc.ocio.processingstatusnotifications.exception.BadStateException +import org.testng.Assert.* +import org.testng.annotations.Test + +class InMemoryCacheServiceTest { + + private var inMemoryCacheService: InMemoryCacheService = InMemoryCacheService() + + @Test(description = "This test asserts true for generating two similar subscriptionIds for same set of rules for two different users") + fun testAddingSameNotificationPreferencesSuccess() { + val subscriptionId1 = inMemoryCacheService.updateNotificationsPreferences( + "destination1","dataStreamRoute1", + "stageName1","warning", + "abc@trh.com", SubscriptionType.EMAIL + ) + val subscriptionId2 = inMemoryCacheService.updateNotificationsPreferences( + "destination1","dataStreamRoute1", + "stageName1","warning", + "rty@trh.com", SubscriptionType.EMAIL + ) + assertEquals(subscriptionId1, subscriptionId2) + } + + @Test(description = "This test asserts true for generating two unique subscriptionIds for different set of rules for same user") + fun testAddingDifferentNotificationPreferencesSuccess() { + val subscriptionId1 = inMemoryCacheService.updateNotificationsPreferences( + "destination1","dataStreamRoute1", + "stageName1","warning", + "abc@trh.com", SubscriptionType.EMAIL + ) + val subscriptionId2 = inMemoryCacheService.updateNotificationsPreferences( + "destination1","dataStreamRoute1", + "stageName1","success", + "abc@trh.com", SubscriptionType.EMAIL + ) + assertNotEquals(subscriptionId1, subscriptionId2) + } + + @Test(description = "This test asserts true for unsubscribing existing susbcription") + fun testUnsubscribingSubscriptionSuccess() { + val subscriptionId1 = inMemoryCacheService.updateNotificationsPreferences( + "destination1","dataStreamRoute1", + "stageName1","warning", + "abc@trh.com", SubscriptionType.EMAIL + ) + + assertTrue(inMemoryCacheService.unsubscribeNotifications(subscriptionId1)) + } + + @Test(description = "This test throws exception for unsubscribing susbcriptionId that doesn't exist") + fun testUnsubscribingSubscriptionException() { + val subscriptionId1 = inMemoryCacheService.updateNotificationsPreferences( + "destination1","dataStreamRoute1", + "stageName1","warning", + "abc@trh.com", SubscriptionType.EMAIL + ) + // Remove subscription first + inMemoryCacheService.unsubscribeNotifications(subscriptionId1) + + try { + inMemoryCacheService.unsubscribeNotifications(subscriptionId1) + } catch (e: BadStateException) { + assertEquals(e.message, "Subscription doesn't exist") + } + } +} + diff --git a/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheTest.kt b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheTest.kt new file mode 100644 index 00000000..24da476c --- /dev/null +++ b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheTest.kt @@ -0,0 +1,58 @@ +package cache + + +import gov.cdc.ocio.processingstatusnotifications.SubscriptionType +import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCache +import org.testng.Assert.* +import org.testng.annotations.Test + +class InMemoryCacheTest { + + private var inMemoryCache: InMemoryCache = InMemoryCache + + @Test(description = "This test asserts true for generating two unique subscriptionId") + fun testTwoUniqueSubscriptionIdGenerated() { + val subscriptionId1 = inMemoryCache.generateUniqueSubscriptionId() + val subscriptionId2 = inMemoryCache.generateUniqueSubscriptionId() + assertNotEquals(subscriptionId1, subscriptionId2) + } + + @Test(description = "This test assert true for generating two unique subscriptionId for two different rules.") + fun testSuccessTwoNewRules() { + val subscriptionRule1 = "subscriptionRuleUnique1" + val subscriptionRule2 = "subscriptionRuleUnique2" + + val subscriptionId1 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.EMAIL, "trr@ddf.ccc") + val subscriptionId2 = inMemoryCache.updateCacheForSubscription(subscriptionRule2, SubscriptionType.EMAIL, "trr@ddf.ccc") + assertNotEquals(subscriptionId1, subscriptionId2) + } + + @Test(description = "This test assert true for generating one unique subscriptionId for single different rules for two users") + fun testSuccessTwoSubscribersSingleRules() { + val subscriptionRule1 = "subscriptionRuleUnique1" + + val subscriptionId1 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.EMAIL, "trr@ddf.ccc") + val subscriptionId2 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.WEBSOCKET, "tre@ddf.ccc") + assertEquals(subscriptionId1, subscriptionId2) + } + + @Test(description = "This test assert true for unsubscribing existing subscription which exist") + fun testSuccessUnsubscribeExistingSubscription() { + val subscriptionRule1 = "subscriptionRuleUnique1" + val subscriptionId1 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.EMAIL, "trr@ddf.ccc") + + assertTrue(inMemoryCache.unsubscribeSubscriber(subscriptionId1)) + } + + @Test(description = "This test assert false for unsubscribing subscription which doesn't exist") + fun testFailureUnsubscribeSubscription() { + val subscriptionRule1 = "subscriptionRuleUnique1" + val subscriptionId1 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.EMAIL, "trr@ddf.ccc") + + // Delete once so the subscription for this user doesn't exist + inMemoryCache.unsubscribeSubscriber(subscriptionId1) + + // Deleting second time for unexisting subcriber on this subscribing rule + assertFalse(inMemoryCache.unsubscribeSubscriber(subscriptionId1)) + } +} \ No newline at end of file From 18864940ef6affe34a729030239c915cf30902a2 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Mon, 24 Jun 2024 14:55:32 -0400 Subject: [PATCH 35/91] Updated unit tests --- .../.gradle/8.5/checksums/checksums.lock | Bin 17 -> 17 bytes .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes pstatus-notifications-ktor/build.gradle | 25 +++++++++++++++--- .../kotlin/cache/InMemoryCacheServiceTest.kt | 12 ++++----- .../test/kotlin/cache/InMemoryCacheTest.kt | 21 +++++++++------ 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock b/pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock index 7416098c90ce783b48f9ec4e5bf9613cfe057106..dc41b8b14344f8c5a9f10df803a78ac3e6e70c7a 100644 GIT binary patch literal 17 VcmZRUKRZwLDPzlT1~6cf2LLgl1UUcz literal 17 VcmZRUKRZwLDPzlT1~6d!001&@1qlEE diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock index b1dcdf3bcc5f0459c3bcce0bebdb0a34a5355e13..f9265861462c4ff2d08a4bd71b9b37f56ead629e 100644 GIT binary patch literal 17 VcmZR6)VyMuPwtv*1~9NN1^_z@1o8j? literal 17 VcmZR6)VyMuPwtv*1~4!R0suQ71q%QG diff --git a/pstatus-notifications-ktor/build.gradle b/pstatus-notifications-ktor/build.gradle index 66e3323c..5bddc99c 100644 --- a/pstatus-notifications-ktor/build.gradle +++ b/pstatus-notifications-ktor/build.gradle @@ -56,10 +56,15 @@ dependencies { 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("io.ktor:ktor-server-tests:2.3.2") - testImplementation("io.ktor:ktor-server-tests:2.3.2") - testImplementation "org.testng:testng:7.4.0" - testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.8.10") + + testImplementation("org.mockito.kotlin:mockito-kotlin:4.0.0") + testImplementation 'org.testng:testng:7.7.0' + + testImplementation "org.mockito:mockito-inline:3.11.2" + testImplementation "io.mockk:mockk:1.13.9" + testImplementation("io.ktor:ktor-server-tests-jvm") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") + } ktor { @@ -78,6 +83,18 @@ jib { } } +test { + useTestNG() + testLogging { + events "passed", "skipped", "failed" + } + //Change this to "true" if we want to execute unit tests + systemProperty("isTestEnvironment", "false") + + // Set the test classpath, if required +} + + repositories{ mavenCentral() } diff --git a/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt index af4890c0..f02e6ba4 100644 --- a/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt +++ b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt @@ -5,14 +5,14 @@ package cache import gov.cdc.ocio.processingstatusnotifications.SubscriptionType import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCacheService import gov.cdc.ocio.processingstatusnotifications.exception.BadStateException -import org.testng.Assert.* +import org.testng.Assert import org.testng.annotations.Test class InMemoryCacheServiceTest { private var inMemoryCacheService: InMemoryCacheService = InMemoryCacheService() - @Test(description = "This test asserts true for generating two similar subscriptionIds for same set of rules for two different users") + @Test fun testAddingSameNotificationPreferencesSuccess() { val subscriptionId1 = inMemoryCacheService.updateNotificationsPreferences( "destination1","dataStreamRoute1", @@ -24,7 +24,7 @@ class InMemoryCacheServiceTest { "stageName1","warning", "rty@trh.com", SubscriptionType.EMAIL ) - assertEquals(subscriptionId1, subscriptionId2) + Assert.assertEquals(subscriptionId1, subscriptionId2) } @Test(description = "This test asserts true for generating two unique subscriptionIds for different set of rules for same user") @@ -39,7 +39,7 @@ class InMemoryCacheServiceTest { "stageName1","success", "abc@trh.com", SubscriptionType.EMAIL ) - assertNotEquals(subscriptionId1, subscriptionId2) + Assert.assertNotEquals(subscriptionId1, subscriptionId2) } @Test(description = "This test asserts true for unsubscribing existing susbcription") @@ -50,7 +50,7 @@ class InMemoryCacheServiceTest { "abc@trh.com", SubscriptionType.EMAIL ) - assertTrue(inMemoryCacheService.unsubscribeNotifications(subscriptionId1)) + Assert.assertTrue(inMemoryCacheService.unsubscribeNotifications(subscriptionId1)) } @Test(description = "This test throws exception for unsubscribing susbcriptionId that doesn't exist") @@ -66,7 +66,7 @@ class InMemoryCacheServiceTest { try { inMemoryCacheService.unsubscribeNotifications(subscriptionId1) } catch (e: BadStateException) { - assertEquals(e.message, "Subscription doesn't exist") + Assert.assertEquals(e.message, "Subscription doesn't exist") } } } diff --git a/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheTest.kt b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheTest.kt index 24da476c..6112057c 100644 --- a/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheTest.kt +++ b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheTest.kt @@ -3,7 +3,8 @@ package cache import gov.cdc.ocio.processingstatusnotifications.SubscriptionType import gov.cdc.ocio.processingstatusnotifications.cache.InMemoryCache -import org.testng.Assert.* +import gov.cdc.ocio.processingstatusnotifications.exception.BadStateException +import org.testng.Assert import org.testng.annotations.Test class InMemoryCacheTest { @@ -14,7 +15,7 @@ class InMemoryCacheTest { fun testTwoUniqueSubscriptionIdGenerated() { val subscriptionId1 = inMemoryCache.generateUniqueSubscriptionId() val subscriptionId2 = inMemoryCache.generateUniqueSubscriptionId() - assertNotEquals(subscriptionId1, subscriptionId2) + Assert.assertNotEquals(subscriptionId1, subscriptionId2) } @Test(description = "This test assert true for generating two unique subscriptionId for two different rules.") @@ -24,7 +25,7 @@ class InMemoryCacheTest { val subscriptionId1 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.EMAIL, "trr@ddf.ccc") val subscriptionId2 = inMemoryCache.updateCacheForSubscription(subscriptionRule2, SubscriptionType.EMAIL, "trr@ddf.ccc") - assertNotEquals(subscriptionId1, subscriptionId2) + Assert.assertNotEquals(subscriptionId1, subscriptionId2) } @Test(description = "This test assert true for generating one unique subscriptionId for single different rules for two users") @@ -33,7 +34,7 @@ class InMemoryCacheTest { val subscriptionId1 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.EMAIL, "trr@ddf.ccc") val subscriptionId2 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.WEBSOCKET, "tre@ddf.ccc") - assertEquals(subscriptionId1, subscriptionId2) + Assert.assertEquals(subscriptionId1, subscriptionId2) } @Test(description = "This test assert true for unsubscribing existing subscription which exist") @@ -41,18 +42,22 @@ class InMemoryCacheTest { val subscriptionRule1 = "subscriptionRuleUnique1" val subscriptionId1 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.EMAIL, "trr@ddf.ccc") - assertTrue(inMemoryCache.unsubscribeSubscriber(subscriptionId1)) + Assert.assertTrue(inMemoryCache.unsubscribeSubscriber(subscriptionId1)) } @Test(description = "This test assert false for unsubscribing subscription which doesn't exist") fun testFailureUnsubscribeSubscription() { val subscriptionRule1 = "subscriptionRuleUnique1" val subscriptionId1 = inMemoryCache.updateCacheForSubscription(subscriptionRule1, SubscriptionType.EMAIL, "trr@ddf.ccc") - + var exceptionThrown = false // Delete once so the subscription for this user doesn't exist inMemoryCache.unsubscribeSubscriber(subscriptionId1) + try { + inMemoryCache.unsubscribeSubscriber(subscriptionId1) + } catch(ex: BadStateException) { + exceptionThrown = true + } + Assert.assertTrue(exceptionThrown) - // Deleting second time for unexisting subcriber on this subscribing rule - assertFalse(inMemoryCache.unsubscribeSubscriber(subscriptionId1)) } } \ No newline at end of file From 9a6324cb6de813f9ec606585d703ee16aff55704 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Mon, 24 Jun 2024 14:56:24 -0400 Subject: [PATCH 36/91] Changed from Replace to Remove. Since replace was still keeping the unsubscribed subscriptionIds --- .../src/main/kotlin/cache/InMemoryCache.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCache.kt b/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCache.kt index 21255cfc..6b15b504 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCache.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/cache/InMemoryCache.kt @@ -132,11 +132,11 @@ object InMemoryCache { */ fun unsubscribeSubscriber(subscriptionId: String): Boolean { if (subscriberCache.containsKey(subscriptionId)) { - val subscribers = subscriberCache[subscriptionId]?.filter { it.subscriptionId != subscriptionId }.orEmpty().toMutableList() + val subscribers = subscriberCache[subscriptionId]?.filter { it.subscriptionId == subscriptionId }.orEmpty().toMutableList() readWriteLock.writeLock().lock() try { - subscriberCache.replace(subscriptionId, subscribers) + subscriberCache.remove(subscriptionId, subscribers) } finally { readWriteLock.writeLock().unlock() } From 289ea5a1699186b508c1328f094beaa2aac8fbc8 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Mon, 24 Jun 2024 14:59:50 -0400 Subject: [PATCH 37/91] Updated the code --- .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes pstatus-notifications-ktor/build.gradle | 2 -- .../test/kotlin/cache/InMemoryCacheServiceTest.kt | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock index f9265861462c4ff2d08a4bd71b9b37f56ead629e..346088e05d2708857a3675a5c04a2a12fa3e1c27 100644 GIT binary patch literal 17 VcmZR6)VyMuPwtv*1~9NF1^_!u1u_5t literal 17 VcmZR6)VyMuPwtv*1~9NN1^_z@1o8j? diff --git a/pstatus-notifications-ktor/build.gradle b/pstatus-notifications-ktor/build.gradle index 5bddc99c..ede9b42a 100644 --- a/pstatus-notifications-ktor/build.gradle +++ b/pstatus-notifications-ktor/build.gradle @@ -56,10 +56,8 @@ dependencies { 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("org.mockito.kotlin:mockito-kotlin:4.0.0") testImplementation 'org.testng:testng:7.7.0' - testImplementation "org.mockito:mockito-inline:3.11.2" testImplementation "io.mockk:mockk:1.13.9" testImplementation("io.ktor:ktor-server-tests-jvm") diff --git a/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt index f02e6ba4..f1e01185 100644 --- a/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt +++ b/pstatus-notifications-ktor/src/test/kotlin/cache/InMemoryCacheServiceTest.kt @@ -12,7 +12,7 @@ class InMemoryCacheServiceTest { private var inMemoryCacheService: InMemoryCacheService = InMemoryCacheService() - @Test + @Test(description = "This test asserts true for generating two unique subscriptionIds for same user") fun testAddingSameNotificationPreferencesSuccess() { val subscriptionId1 = inMemoryCacheService.updateNotificationsPreferences( "destination1","dataStreamRoute1", From 6c652a18479897e1c821537a24b0f52dcc2731b8 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Mon, 24 Jun 2024 16:01:09 -0400 Subject: [PATCH 38/91] Added function and class headers as per PR review comments --- .../mutations/NotificationsMutationService.kt | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt index a91ccdee..a537c534 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt @@ -13,6 +13,14 @@ import io.ktor.http.* import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable +/** + * Email Subscription data class which is serialized back and forth which is in turn subscribed in to the MemCache + * @param dataStreamId String + * @param dataStreamRoute String + * @param email String + * @param stageName String + * @param statusType String + */ @Serializable data class EmailSubscription(val dataStreamId:String, val dataStreamRoute:String, @@ -20,10 +28,20 @@ data class EmailSubscription(val dataStreamId:String, val stageName: String, val statusType:String) +/** + * UnSubscription data class which is serialized back and forth which is in turn used for unsubscribing from the cache for emails and webhooks using the given subscriberId + * @param subscriptionId + */ @Serializable data class UnSubscription(val subscriptionId:String) - +/** + * SubscriptionResult is the response class which is serialized back and forth which is in turn used for getting the response which contains the subscriberId , message and the status of subscribe/unsubscribe operations + * @param subscription_id + * @param timestamp + * @param status + * @param message + */ @Serializable data class SubscriptionResult( var subscription_id: String? = null, @@ -45,6 +63,15 @@ class NotificationsMutationService : Mutation { } + /** + * SubscribeEmail function which inturn uses the http client to invoke the notifications ktor microservice route to subscribe + * @param dataStreamId String + * @param dataStreamRoute String + * @param email String + * @param stageName String + * @param statusType String + */ + @GraphQLDescription("Subscribe Email Notifications") @Suppress("unused") fun subscribeEmail(dataStreamId:String, dataStreamRoute:String,email: String, stageName: String, statusType:String):SubscriptionResult { @@ -59,6 +86,11 @@ class NotificationsMutationService : Mutation { } + /** + * UnSubscribeEmail function which inturn uses the http client to invoke the notifications ktor microservice route to unsubscribe email notifications using the subscriberId + * @param subscriptionId String + */ + @GraphQLDescription("Unsubscribe Email Notifications") @Suppress("unused") fun unsubscribeEmail(subscriptionId:String):SubscriptionResult { @@ -72,6 +104,15 @@ class NotificationsMutationService : Mutation { } + /** + * SubscribeWebhook function which inturn uses the http client to invoke the notifications ktor microservice route to subscribe webhook notifications + * @param dataStreamId String + * @param dataStreamRoute String + * @param email String + * @param stageName String + * @param statusType String + */ + @GraphQLDescription("Subscribe Webhook Notifications") @Suppress("unused") fun subscribeWebhook(dataStreamId:String, dataStreamRoute:String,email: String, stageName: String, statusType:String):SubscriptionResult { @@ -86,6 +127,11 @@ class NotificationsMutationService : Mutation { } + /** + * UnSubscribeWebhook function which inturn uses the http client to invoke the notifications ktor microservice route to unsubscribe webhook notifications + * @param subscriptionId String + */ + @GraphQLDescription("Unsubscribe Webhook Notifications") @Suppress("unused") fun unsubscribeWebhook(subscriptionId:String):SubscriptionResult { From 5333c6b0ed8daf1e4a1ffb75a8844100552aba5b Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Wed, 26 Jun 2024 08:47:50 -0500 Subject: [PATCH 39/91] DASB-512 - Added environment variable for graphql path (#120) * DASB-512 - Added graphql path environment variable * Added version endpoint to routing * Updates to graphql endpoint paths * Removed redundant path from graphql endpoints * Set version to 0.0.3 --------- Co-authored-by: Matt B Krystof --- .../gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt | 5 ++++- .../gov/cdc/ocio/processingstatusapi/plugins/Routing.kt | 4 ++++ pstatus-graphql-ktor/src/main/resources/application.conf | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) 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 0d492c70..039353fe 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 @@ -30,6 +30,7 @@ fun Application.graphQLModule() { val issuer = environment.config.property("jwt.issuer").getString() val audience = environment.config.property("jwt.audience").getString() val myRealm = environment.config.property("jwt.realm").getString() + val graphQLPath = environment.config.property("graphql.path").getString() install(Authentication) { jwt { jwt("auth-jwt") { @@ -96,6 +97,8 @@ fun Application.graphQLModule() { graphQLPostRoute() graphQLSubscriptionsRoute() graphQLSDLRoute() - graphiQLRoute() // Go to http://localhost:8080/graphiql for the GraphQL playground + graphiQLRoute( + graphQLEndpoint = "$graphQLPath/graphql", + subscriptionsEndpoint = "$graphQLPath/subscriptions") // Go to http://localhost:8080/graphiql for the GraphQL playground } } \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt index c3e5a9b6..1b4a8d57 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt @@ -6,9 +6,13 @@ import io.ktor.server.response.* import io.ktor.server.routing.* fun Application.configureRouting() { + val version = environment.config.propertyOrNull("ktor.version")?.getString() ?: "unknown" routing { get("/health") { call.respond(HealthQueryService().getHealth()) } + get("/version") { + call.respondText(version) + } } } \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/resources/application.conf b/pstatus-graphql-ktor/src/main/resources/application.conf index 65934b11..47c77d70 100644 --- a/pstatus-graphql-ktor/src/main/resources/application.conf +++ b/pstatus-graphql-ktor/src/main/resources/application.conf @@ -7,6 +7,12 @@ ktor { application { modules = [ gov.cdc.ocio.processingstatusapi.ApplicationKt.module ] } + + version = "0.0.3" +} + +graphql { + path = ${GRAPHQL_PATH} } jwt { From 1b26ba4c8919227e01211ee4d35c58531007a4fa Mon Sep 17 00:00:00 2001 From: Madhavi Tammineni Date: Mon, 1 Jul 2024 07:04:00 -0400 Subject: [PATCH 40/91] Remove tracing - cleanup of files --- processing-status-api-function-app/README.md | 3 - .../functions/reports/ReportManager.kt | 1 - .../functions/status/GetStatusFunction.kt | 5 -- .../src/test/kotlin/data/trace/get_trace.json | 87 ------------------- .../test/status/GetStatusFunctionTests.kt | 9 +- .../test/resources/hl7RedactedReportv2.json | 2 - .../src/test/resources/hl7Validationv2.json | 2 - .../scripts/load-testing/pstatus_load_test.py | 32 ------- .../pstatus_span_race_condition_test.py | 68 --------------- .../pstatus_trace_race_condition_test.py | 76 ---------------- 10 files changed, 1 insertion(+), 284 deletions(-) delete mode 100644 processing-status-api-function-app/src/test/kotlin/data/trace/get_trace.json delete mode 100644 processing-status-api-function-app/test/scripts/race-conditions/pstatus_span_race_condition_test.py delete mode 100644 processing-status-api-function-app/test/scripts/race-conditions/pstatus_trace_race_condition_test.py diff --git a/processing-status-api-function-app/README.md b/processing-status-api-function-app/README.md index bb4cf73e..3b5a1918 100644 --- a/processing-status-api-function-app/README.md +++ b/processing-status-api-function-app/README.md @@ -11,9 +11,6 @@ The following is needed in order to build and deploy this function app: ## Required Application Settings In addition to the standard required application settings, the processing status API function app requires the following. -- `JAEGER_OTEL_COLLECTOR_END_POINT` - URL of the Jaeger trace collector, listening on port 4317 -- `JAEGER_TRACE_ENDPOINT` - URL of the Jaeger web UI, listening on port 16686 -- `JAEGER_HEALTH_END_POINT` - URL of the Jaeger health end point, listening on port 14269 - `CosmosDbEndpoint` - URL of the cosmos database - `CosmosDbKey` - shared key used to connect to the cosmos database - `ServiceBusQueueName` - service bus queue name, which should always be `processing-status-cosmos-db-queue` diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/reports/ReportManager.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/reports/ReportManager.kt index e3494c5d..a96d8711 100644 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/reports/ReportManager.kt +++ b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/reports/ReportManager.kt @@ -213,7 +213,6 @@ class ReportManager { } HttpStatus.TOO_MANY_REQUESTS.value() -> { - // See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/performance-tips?tabs=trace-net-core#429 // https://learn.microsoft.com/en-us/rest/api/cosmos-db/common-cosmosdb-rest-response-headers // https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large?tabs=resource-specific val recommendedDuration = response.responseHeaders["x-ms-retry-after-ms"] diff --git a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetStatusFunction.kt b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetStatusFunction.kt index a9c501ec..02432d15 100644 --- a/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetStatusFunction.kt +++ b/processing-status-api-function-app/src/main/kotlin/gov/cdc/ocio/processingstatusapi/functions/status/GetStatusFunction.kt @@ -75,11 +75,6 @@ class GetStatusFunction( .build() } - companion object { - val excludedSpanTags = listOf("spanMark", "span.kind", "internal.span.format", "otel.library.name") - } - - private fun getReport(uploadId: String): ReportDao? { // Get the reports diff --git a/processing-status-api-function-app/src/test/kotlin/data/trace/get_trace.json b/processing-status-api-function-app/src/test/kotlin/data/trace/get_trace.json deleted file mode 100644 index 8274e439..00000000 --- a/processing-status-api-function-app/src/test/kotlin/data/trace/get_trace.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "data": [ - { - "traceID": "89356b6ce9a5fea5cab099a7b88a144e", - "spans": [ - { - "traceID": "89356b6ce9a5fea5cab099a7b88a144e", - "spanID": "ba14b27a972a03ee", - "operationName": "PARENT_SPAN", - "references": [ - { - "refType": "CHILD_OF", - "traceID": "89356b6ce9a5fea5cab099a7b88a144e", - "spanID": "9375e3177815242e" - } - ], - "startTime": 1706206691876878, - "duration": 9, - "tags": [ - { - "key": "otel.library.name", - "type": "string", - "value": "gov.cdc.ocio.processingstatusapi.functions.traces.CreateTraceFunction" - }, - { - "key": "dataStreamRoute", - "type": "string", - "value": "upload" - }, - { - "key": "dataStreamId", - "type": "string", - "value": "1" - }, - { - "key": "uploadId", - "type": "string", - "value": "1" - }, - { - "key": "span.kind", - "type": "string", - "value": "internal" - }, - { - "key": "internal.span.format", - "type": "string", - "value": "proto" - } - ], - "logs": [], - "processID": "p1", - "warnings": [ - "invalid parent span IDs=9375e3177815242e; skipping clock skew adjustment" - ] - } - ], - "processes": { - "p1": { - "serviceName": "dex-processing-status", - "tags": [ - { - "key": "telemetry.sdk.language", - "type": "string", - "value": "java" - }, - { - "key": "telemetry.sdk.name", - "type": "string", - "value": "opentelemetry" - }, - { - "key": "telemetry.sdk.version", - "type": "string", - "value": "1.29.0" - } - ] - } - }, - "warnings": null - } - ], - "total": 0, - "limit": 0, - "offset": 0, - "errors": null -} \ No newline at end of file diff --git a/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt b/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt index bede1904..58d5662a 100644 --- a/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt +++ b/processing-status-api-function-app/src/test/kotlin/test/status/GetStatusFunctionTests.kt @@ -15,14 +15,12 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic import khttp.responses.Response -import org.json.JSONObject import org.mockito.Mockito.doAnswer import org.mockito.Mockito.mock import org.mockito.kotlin.any import org.testng.annotations.BeforeMethod import org.testng.annotations.Test import utils.HttpResponseMessageMock -import java.io.File import java.util.* @@ -31,10 +29,7 @@ class GetStatusFunctionTests { private lateinit var request: HttpRequestMessage> private lateinit var context: ExecutionContext - private val testBytes = File("./src/test/kotlin/data/trace/get_trace.json").readText() private val mockResponse = mockk() - // Convert the string to a JSONObject - private val jsonObject = JSONObject(testBytes) private val items = mockk>() @BeforeMethod @@ -51,7 +46,6 @@ class GetStatusFunctionTests { request = mock(HttpRequestMessage::class.java) as HttpRequestMessage> mockkStatic("khttp.KHttp") every {khttp.get(any())} returns mockResponse - every {mockResponse.jsonObject} returns jsonObject every { mockCosmosContainer.queryItems(any(), any(), Report::class.java) } returns items // Setup method invocation interception when createResponseBuilder is called to avoid null pointer on real method call. @@ -66,10 +60,9 @@ class GetStatusFunctionTests { every { items.count() > 0} returns false every {mockResponse.statusCode} returns HttpStatus.OK.value() - val response = GetStatusFunction(request).withUploadId("1"); + val response = GetStatusFunction(request).withUploadId("1") assert(response.status == HttpStatus.BAD_REQUEST) } - } diff --git a/processing-status-api-function-app/test/IntegrationTest/src/test/resources/hl7RedactedReportv2.json b/processing-status-api-function-app/test/IntegrationTest/src/test/resources/hl7RedactedReportv2.json index 9bf3aa57..b45538e7 100644 --- a/processing-status-api-function-app/test/IntegrationTest/src/test/resources/hl7RedactedReportv2.json +++ b/processing-status-api-function-app/test/IntegrationTest/src/test/resources/hl7RedactedReportv2.json @@ -30,8 +30,6 @@ "upload_id": "mcq1-20240216-01-d849c4b7-a447-44c3-a3b1-a0d50acec6ee", "data_stream_id": "celr", "data_stream_route": "hl7", - "trace_id": "UNKNOWN", - "span_id": "UNKNOWN", "supporting_metadata": { "message_type": "CASE" } diff --git a/processing-status-api-function-app/test/IntegrationTest/src/test/resources/hl7Validationv2.json b/processing-status-api-function-app/test/IntegrationTest/src/test/resources/hl7Validationv2.json index 37530317..b220c546 100644 --- a/processing-status-api-function-app/test/IntegrationTest/src/test/resources/hl7Validationv2.json +++ b/processing-status-api-function-app/test/IntegrationTest/src/test/resources/hl7Validationv2.json @@ -30,8 +30,6 @@ "upload_id": "$uploadId", "data_stream_id": "daart", "data_stream_route": "hl7", - "trace_id": "UNKNOWN", - "span_id": "UNKNOWN", "supporting_metadata": { "route": "daart", "system_provider": "DEX-ROUTING", diff --git a/processing-status-api-function-app/test/scripts/load-testing/pstatus_load_test.py b/processing-status-api-function-app/test/scripts/load-testing/pstatus_load_test.py index 470aebaa..4f810db0 100644 --- a/processing-status-api-function-app/test/scripts/load-testing/pstatus_load_test.py +++ b/processing-status-api-function-app/test/scripts/load-testing/pstatus_load_test.py @@ -32,16 +32,6 @@ async def send_single_message(sender, message): message = ServiceBusMessage(message) await sender.send_messages(message) -def send_start_trace(trace_id, parent_span_id, stage_name): - url = f'{pstatus_base_url}/api/trace/startSpan/{trace_id}/{parent_span_id}?stageName={stage_name}' - r = requests.put(url) - json_response = r.json() - return json_response['span_id'] - -def send_stop_trace(trace_id, span_id): - url = f'{pstatus_base_url}/api/trace/stopSpan/{trace_id}/{span_id}' - requests.put(url) - async def run(): # Generate a unqiue upload ID upload_id = str(uuid.uuid4()) @@ -56,16 +46,6 @@ async def run(): # Get a Queue Sender object to send messages to the queue sender = servicebus_client.get_queue_sender(queue_name=QUEUE_NAME) async with sender: - # Create the trace for this upload - url = f'{pstatus_base_url}/api/trace?uploadId={upload_id}&destinationId=dex-testing&eventType=test-event1' - response = requests.post(url) - response_json = response.json() - trace_id = response_json["trace_id"] - parent_span_id = response_json["span_id"] - - # Send the start trace - span_id = send_start_trace(trace_id, parent_span_id, "dex-upload") - # Send upload messages print("Sending simulated UPLOAD reports...") num_chunks = 1 @@ -76,22 +56,13 @@ async def run(): #print(f"Sending: {message}") await send_single_message(sender, message) time.sleep(1) - # Send the stop trace - send_stop_trace(trace_id, span_id) - - # Send the start trace - span_id = send_start_trace(trace_id, parent_span_id, "dex-routing") # Send routing message print("Sending simulated ROUTING report...") message = reports.create_routing(upload_id) #print(f"Sending: {message}") await send_single_message(sender, message) - # Send the stop trace - send_stop_trace(trace_id, span_id) - # Send the start trace - span_id = send_start_trace(trace_id, parent_span_id, "dex-hl7-validation") # Send hl7 validation messages print("Sending simulated HL7-VALIDATION reports...") batch_message = await sender.create_message_batch() @@ -101,9 +72,6 @@ async def run(): message = reports.create_hl7_validation(upload_id, line) #print(f"Sending: {message}") batch_message.add_message(ServiceBusMessage(message)) - # Send the stop trace - await sender.send_messages(batch_message) - send_stop_trace(trace_id, span_id) asyncio.run(run()) print("Done sending messages") \ No newline at end of file diff --git a/processing-status-api-function-app/test/scripts/race-conditions/pstatus_span_race_condition_test.py b/processing-status-api-function-app/test/scripts/race-conditions/pstatus_span_race_condition_test.py deleted file mode 100644 index 9505b3f5..00000000 --- a/processing-status-api-function-app/test/scripts/race-conditions/pstatus_span_race_condition_test.py +++ /dev/null @@ -1,68 +0,0 @@ -import asyncio -import uuid -import requests -import time -import json - -############################################################################################ -# -- INSTRUCTIONS -- -# 1. Open the file named ".env" in the same folder as this Python script. -# 2. In the .env file, set the variables according to your environment and desired settings. -############################################################################################ - -env = {} -with open("../.env") as envfile: - for line in envfile: - name, var = line.partition("=")[::2] - var = var.strip() - if var.startswith('"') and var.endswith('"'): - var = var[1:-1] - env[name.strip()] = var - -pstatus_base_url=env["pstatus_api_base_url"] - -def send_start_trace(trace_id, parent_span_id, stage_name): - url = f'{pstatus_base_url}/api/trace/startSpan/{trace_id}/{parent_span_id}?stageName={stage_name}' - r = requests.put(url) - json_response = r.json() - return json_response['span_id'] - -def send_stop_trace(trace_id, span_id): - url = f'{pstatus_base_url}/api/trace/stopSpan/{trace_id}/{span_id}' - requests.put(url) - -async def run(): - # Generate a unqiue upload ID - upload_id = str(uuid.uuid4()) - print("Upload ID = " + upload_id) - - # Create the trace for this upload - url = f'{pstatus_base_url}/api/trace?uploadId={upload_id}&destinationId=dex-testing&eventType=test-event1' - response = requests.post(url) - response_json = response.json() - trace_id = response_json["trace_id"] - parent_span_id = response_json["span_id"] - - # Send the start trace - span_id = send_start_trace(trace_id, parent_span_id, "dex-upload") - - # Immediately try to get the span we just created - print('Attempting to get the span just created') - start_time = time.time() - - url = f'{pstatus_base_url}/api/trace/span?uploadId={upload_id}&stageName=dex-upload' - r = requests.get(url) - print(f'status code = {r.status_code}') - if r.status_code != 200: - print(f'bad response = {r.content}') - exit(1) - json_object = json.loads(r.content) - json_formatted_str = json.dumps(json_object, indent=2) - print(f'JSON response: {json_formatted_str}') - print('Waited %s seconds' % (time.time() - start_time)) - - # Send the stop trace - send_stop_trace(trace_id, span_id) - -asyncio.run(run()) -print("Done sending messages") \ No newline at end of file diff --git a/processing-status-api-function-app/test/scripts/race-conditions/pstatus_trace_race_condition_test.py b/processing-status-api-function-app/test/scripts/race-conditions/pstatus_trace_race_condition_test.py deleted file mode 100644 index 90d78e78..00000000 --- a/processing-status-api-function-app/test/scripts/race-conditions/pstatus_trace_race_condition_test.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -import uuid -import requests -import time -import json - -############################################################################################ -# -- INSTRUCTIONS -- -# 1. Open the file named ".env" in the same folder as this Python script. -# 2. In the .env file, set the variables according to your environment and desired settings. -############################################################################################ - -env = {} -with open("../.env") as envfile: - for line in envfile: - name, var = line.partition("=")[::2] - var = var.strip() - if var.startswith('"') and var.endswith('"'): - var = var[1:-1] - env[name.strip()] = var - -pstatus_base_url=env["pstatus_api_base_url"] - -async def run(): - ### Test 1 - print('TEST 1: Attempting to a trace just created using trace_id') - - # Generate a unqiue upload ID - upload_id = str(uuid.uuid4()) - print("Upload ID = " + upload_id) - - # Create the trace for this upload - url = f'{pstatus_base_url}/api/trace?uploadId={upload_id}&destinationId=dex-testing&eventType=test-event1' - response = requests.post(url) - response_json = response.json() - trace_id = response_json["trace_id"] - print(f'trace_id = {trace_id}') - - # Immediately try to get the trace we just created - start_time = time.time() - url = f'{pstatus_base_url}/api/trace/traceId/{trace_id}' - r = requests.get(url) - print(f'status code = {r.status_code}') - if r.status_code != 200: - print(f'FAILED: bad response = {r.content}') - else: - json_object = json.loads(r.content) - json_formatted_str = json.dumps(json_object, indent=2) - print(f'SUCCESS: JSON response: {json_formatted_str}') - print('Waited %s seconds' % (time.time() - start_time)) - - ### Test 2 - print('TEST 2: Attempting to get a trace just created using upload_id') - - # Generate a unqiue upload ID - upload_id = str(uuid.uuid4()) - print("Upload ID = " + upload_id) - - # Create the trace for this upload - url = f'{pstatus_base_url}/api/trace?uploadId={upload_id}&destinationId=dex-testing&eventType=test-event1' - requests.post(url) - - # Immediately try to get the trace we just created - start_time = time.time() - url = f'{pstatus_base_url}/api/trace/uploadId/{upload_id}' - r = requests.get(url) - print(f'status code = {r.status_code}') - if r.status_code != 200: - print(f'FAILED: bad response = {r.content}') - else: - json_object = json.loads(r.content) - json_formatted_str = json.dumps(json_object, indent=2) - print(f'SUCCESS: JSON response: {json_formatted_str}') - print('Waited %s seconds' % (time.time() - start_time)) - -asyncio.run(run()) \ No newline at end of file From 5e6e3fd2ad6d6e64c9ee61c869209125b0b30662 Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Mon, 1 Jul 2024 15:21:50 -0400 Subject: [PATCH 41/91] CI pipeline for GraphQL service --- .github/deploy-rc.yml | 15 +++++++++ .github/graphql-service-ci.yml | 31 ++++++++++++++++++ .github/graphql-service.yml | 23 +++++++++++++ .github/remote-cd-trigger-template.yml | 45 ++++++++++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 .github/deploy-rc.yml create mode 100644 .github/graphql-service-ci.yml create mode 100644 .github/graphql-service.yml create mode 100644 .github/remote-cd-trigger-template.yml diff --git a/.github/deploy-rc.yml b/.github/deploy-rc.yml new file mode 100644 index 00000000..4be179c2 --- /dev/null +++ b/.github/deploy-rc.yml @@ -0,0 +1,15 @@ +name: "TST/STG - Deploy release candidate" + +on: + workflow_dispatch: + push: + tags: + - '*' + +jobs: + staging: + uses: ./.github/workflows/remote-cd-trigger-template.yml + with: + WORKFLOW: build-and-deploy-rc.yml + REF: '${{ github.ref_name }}' # Resolves to the tag that is pushed + secrets: inherit \ No newline at end of file diff --git a/.github/graphql-service-ci.yml b/.github/graphql-service-ci.yml new file mode 100644 index 00000000..b00d4d2b --- /dev/null +++ b/.github/graphql-service-ci.yml @@ -0,0 +1,31 @@ +name: "CI - GraphQL Service" + +on: + workflow_dispatch: + pull_request: + paths: + - pstatus-graphql-ktor/** + +defaults: + run: + working-directory: pstatus-graphql-ktor/ + +jobs: + unit-test: + name: Unit Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: 1.22.0 + - name: Vet + run: go vet ./... + - name: Run Tests + run: go test -coverprofile=c.out -coverpkg=./... ./... + - name: Show coverage + run: go tool cover -func=c.out + - name: Build Check + run: go build -o pstatus-graphql-ktor ./cmd/main.go \ No newline at end of file diff --git a/.github/graphql-service.yml b/.github/graphql-service.yml new file mode 100644 index 00000000..aefa5807 --- /dev/null +++ b/.github/graphql-service.yml @@ -0,0 +1,23 @@ +name: DEV - GraphQL Service + +on: + workflow_dispatch: + inputs: + REF: + description: Branch from CDCgov/data-exchange-processing-status that you want to deploy to the dev environment. + default: main + required: true + type: string + push: + branches: + - main + paths: + - pstatus-graphql-ktor/** + +jobs: + remote-trigger: + uses: ./.github/workflows/remote-cd-trigger-template.yml + with: + WORKFLOW: graphql-service.yml + REF: ${{ inputs.REF }} + secrets: inherit \ No newline at end of file diff --git a/.github/remote-cd-trigger-template.yml b/.github/remote-cd-trigger-template.yml new file mode 100644 index 00000000..ba2df354 --- /dev/null +++ b/.github/remote-cd-trigger-template.yml @@ -0,0 +1,45 @@ +name: Template - Remote trigger to CDCent +on: + workflow_call: + inputs: + WORKFLOW: + type: string + description: "Workflow yml file that should be triggered." + required: true + REF: + type: string + description: "Git tag or branch the workflow is running on." + required: false + default: 'main' + +jobs: + invoke-cd-trigger: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout Gen GitHub App Access Token + uses: actions/checkout@v3 + with: + repository: kave/github-app-token + - name: Generate Token + run: | + sudo gem install jwt + echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem + chmod +x ./get-github-app-access-token.sh; + . ./get-github-app-access-token.sh; + echo "access_token=${TOKEN}" >> "$GITHUB_ENV" + - name: Manually Dispatch Remote CICD Trigger Event + uses: actions/github-script@v6 + with: + github-token: ${{ env.access_token }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: 'cdcent', + repo: 'data-exchange-pstatus-devops', + workflow_id: '${{ inputs.WORKFLOW }}', + ref: 'main', + inputs: { + REF: '${{ inputs.REF }}' + } + }) \ No newline at end of file From 90cfb19517d13bc7ac284f8354d123fbda8ad589 Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Mon, 1 Jul 2024 15:52:42 -0400 Subject: [PATCH 42/91] CI pipeline for GraphQL service --- .github/workflows/deploy-rc.yml | 15 ++ .github/workflows/graphql-service-ci.yml | 31 +++ .github/workflows/graphql-service.yml | 23 ++ .github/workflows/ps-notify-remote-cicd.yml | 236 ------------------ .../workflows/remote-cd-trigger-template.yml | 45 ++++ 5 files changed, 114 insertions(+), 236 deletions(-) create mode 100644 .github/workflows/deploy-rc.yml create mode 100644 .github/workflows/graphql-service-ci.yml create mode 100644 .github/workflows/graphql-service.yml delete mode 100644 .github/workflows/ps-notify-remote-cicd.yml create mode 100644 .github/workflows/remote-cd-trigger-template.yml diff --git a/.github/workflows/deploy-rc.yml b/.github/workflows/deploy-rc.yml new file mode 100644 index 00000000..4be179c2 --- /dev/null +++ b/.github/workflows/deploy-rc.yml @@ -0,0 +1,15 @@ +name: "TST/STG - Deploy release candidate" + +on: + workflow_dispatch: + push: + tags: + - '*' + +jobs: + staging: + uses: ./.github/workflows/remote-cd-trigger-template.yml + with: + WORKFLOW: build-and-deploy-rc.yml + REF: '${{ github.ref_name }}' # Resolves to the tag that is pushed + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/graphql-service-ci.yml b/.github/workflows/graphql-service-ci.yml new file mode 100644 index 00000000..b00d4d2b --- /dev/null +++ b/.github/workflows/graphql-service-ci.yml @@ -0,0 +1,31 @@ +name: "CI - GraphQL Service" + +on: + workflow_dispatch: + pull_request: + paths: + - pstatus-graphql-ktor/** + +defaults: + run: + working-directory: pstatus-graphql-ktor/ + +jobs: + unit-test: + name: Unit Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: 1.22.0 + - name: Vet + run: go vet ./... + - name: Run Tests + run: go test -coverprofile=c.out -coverpkg=./... ./... + - name: Show coverage + run: go tool cover -func=c.out + - name: Build Check + run: go build -o pstatus-graphql-ktor ./cmd/main.go \ No newline at end of file diff --git a/.github/workflows/graphql-service.yml b/.github/workflows/graphql-service.yml new file mode 100644 index 00000000..aefa5807 --- /dev/null +++ b/.github/workflows/graphql-service.yml @@ -0,0 +1,23 @@ +name: DEV - GraphQL Service + +on: + workflow_dispatch: + inputs: + REF: + description: Branch from CDCgov/data-exchange-processing-status that you want to deploy to the dev environment. + default: main + required: true + type: string + push: + branches: + - main + paths: + - pstatus-graphql-ktor/** + +jobs: + remote-trigger: + uses: ./.github/workflows/remote-cd-trigger-template.yml + with: + WORKFLOW: graphql-service.yml + REF: ${{ inputs.REF }} + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/ps-notify-remote-cicd.yml b/.github/workflows/ps-notify-remote-cicd.yml deleted file mode 100644 index 05cbb19e..00000000 --- a/.github/workflows/ps-notify-remote-cicd.yml +++ /dev/null @@ -1,236 +0,0 @@ -name: Trigger Processing Status Notify CI/CD Workflow -on: - workflow_dispatch: - inputs: - data-exchange-hl7-workflow: - description: '(Required) Specify CI/CD workflow associated with one function app to trigger autodeploy from CDCEnt/data-exchange-hl7-devops repo' - required: true - type: choice - default: 'deploy-pstatus-notify.yml' - options: - - deploy-pstatus-notify.yml - commitID: - description: '(Optional) Enter Full Commit Hash to trigger redeploy of prior version' - required: false - type: string - data-exchange-hl7-branch: - description: '(Optional) Enter Source Branch to trigger deployment of tip revision to the development or test environment. Defaults to develop branch' - required: false - type: string - default: 'develop' - targetEnv: - description: 'Environment to deploy' - required: true - type: string - default: 'dev' - - pull_request: - types: - - synchronize - - opened - branches: - - 'develop' - - 'main' - paths: - - processing-status-notifications-fa/** - push: - branches: - - 'develop' - - 'main' - paths: - - processing-status-notifications-fa/** - -jobs: - invoke-manual-cicd-trigger: - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - name: Invoke ${{ inputs.data-exchange-hl7-workflow }} - environment: dev - env: - GH_TOKEN: ${{ github.token }} - steps: - - name: Gen GitHub App Access Token For Manual Trigger - id: github-app-token - run: | - echo ${{ github.workspace }} - if [ ! -d data-exchange-hl7 ]; then git clone https://github.com/kave/github-app-token.git; fi; - sudo tree -df - cd github-app-token - sudo gem install jwt - echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem - chmod +x ./get-github-app-access-token.sh; - . ./get-github-app-access-token.sh; - echo "access_token=${TOKEN}" >> "$GITHUB_ENV" - - name: Git Commit SHA - id: getsha - run: | - echo "sha=${{ inputs.commitID }}" >> "$GITHUB_ENV" - - name: Manually Dispatch Remote CICD Trigger Event - id: manual-devops-wkflow-dispatch - uses: aurelien-baudet/workflow-dispatch@v2.1.1 - with: - workflow: '${{ github.event.inputs.data-exchange-hl7-workflow }}' - repo: cdcent/data-exchange-hl7-devops - token: ${{ env.access_token }} - inputs: '{ "targetEnv": "${{ github.event.inputs.targetEnv }}", "commitID": "${{ env.sha }}", "data-exchange-hl7-branch": "${{ github.event.inputs.data-exchange-hl7-branch }}" }' - ref: 'main' - wait-for-completion: true - wait-for-completion-timeout: 120m - wait-for-completion-interval: 300s - display-workflow-run-url: true - # workflow-logs: print --- This nice-to-have feature is documented in readme but apparantly not implemented in current version - follow up required - # uses: actions/github-script@v6.4.1 - # with: - # debug: ${{ secrets.ACTIONS_RUNNER_DEBUG }} - # github-token: '${{ env.access_token }}' - # script: | - # try { - # const result = await github.rest.actions.createWorkflowDispatch({ - # owner: 'cdcent', - # repo: 'data-exchange-hl7-devops', - # workflow_id: '${{ github.event.inputs.data-exchange-hl7-workflow }}', - # ref: 'main', - # inputs: { - # commitID: '${{ steps.getsha.outputs.commitID }}', - # 'data-exchange-hl7-branch': '${{ github.event.inputs.data-exchange-hl7-branch }}' - # } - # }) - # console.log(result); - # } catch(error) { - # console.error(error); - # core.setFailed(error); - # } - prepare-remote-cicd: - permissions: write-all - if: github.event_name != 'workflow_dispatch' - runs-on: ubuntu-latest - environment: dev - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - merge_branch: ${{ steps.gettargetbranch.outputs.target_branch }} - env: - GH_TOKEN: ${{ github.token }} - steps: - - name: Get PR Commits - if: ${{ github.event_name }} == 'pull_request' - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - name: Get User Commits - uses: actions/checkout@v3 - with: - ref: ${{ github.ref }} - fetch-depth: 0 - - name: Get PR Merge Target Branch - id: gettargetbranch - run: | - if [[ ${{ github.event_name }} == 'pull_request' ]]; - then - echo "target_branch=${{ github.base_ref }}" >> "$GITHUB_ENV" - echo "target_branch=${{ github.base_ref }}" >> "$GITHUB_OUTPUT" - else - echo "target_branch=${{ github.ref_name }}" >> "$GITHUB_ENV" - echo "target_branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" - fi - # Create a list (matrix) of workflow IDs to trigger based on folders impacted by file changes in current branch - # Convert workflow shell array into a stringified JSON so it can be bound to matrix workflow_id property used in invoke job - # Autodeploy on Push (PR merge) to develop or main branch. All other Push events targeting feature branches will be trated as - # Pull_Request event and will trigger CI (unit test) workflow - - name: Determine Processing Status Workflow(s) to Trigger - id: set-matrix - run: | - workflows=() - commitfldrs=$(git log -m -1 --name-only --pretty="format:"${{ github.sha }}) - echo " Files Changed in PR commit: $commitfldrs" - if [[ ${{ github.event_name }} == 'push' && (${{ github.ref_name }} == 'develop' || ${{ github.ref_name }} == 'main' ) ]]; - then - case $commitfldrs in - *processing-status-api-function-app*) workflows+=("deploy-processing-status.yml") ;;& - *) ;; - esac - else - case $commitfldrs in - *processing-status-api-function-app*) workflows+=("ci-processing-status.yml") ;;& - *) ;; - esac - fi - for value in "${workflows[@]}" - do - echo "$value will be triggered as part of this PR" - echo "matrix=${workflows[@]}" >> "$GITHUB_ENV" - echo "matrix=${workflows[@]}" >> "$GITHUB_OUTPUT" - done - echo "matrix=$(printf '%s\n' "${workflows[@]}" | jq -R . | jq -cs )" >> "$GITHUB_OUTPUT" - echo "matrix=$(printf '%s\n' "${workflows[@]}" | jq -R . | jq -cs )" >> "$GITHUB_ENV" - echo "deploy workflows=${workflows[@]}" - - name: No CI/CD Notification - id: no-cicd-notice - if: fromJson(env.matrix)[0] == null - run: | - echo "No testable,deployable changes Detected in Processing Status Build Workspace" - - trigger-remote-cicd: - if: ${{ github.event_name != 'workflow_dispatch' && fromJson(needs.prepare-remote-cicd.outputs.matrix)[0] != null }} - needs: prepare-remote-cicd - runs-on: ubuntu-latest - name: Dispatch ${{ matrix.workflow_id }} Workflow - strategy: - fail-fast: false - matrix: - workflow_id: ${{fromJson(needs.prepare-remote-cicd.outputs.matrix)}} - environment: dev - steps: - - name: Gen GitHub App Access Token for Automated Trigger - id: github-app-token - run: | - echo ${{ github.workspace }} - if [ ! -d github-app-token ]; then git clone https://github.com/kave/github-app-token.git; fi; - sudo tree -df - cd github-app-token - sudo gem install jwt - echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem - chmod +x ./get-github-app-access-token.sh; - . ./get-github-app-access-token.sh; - echo "access_token=${TOKEN}" >> "$GITHUB_ENV" - - name: Get Commit SHA - id: getsha - run: | - echo "commitID=$(echo ${GITHUB_SHA})" >> $GITHUB_OUTPUT - - name: Automatically Dispatch Remote CICD Trigger Event - id: auto-devops-wkflow-dispatch - if: ${{ matrix.workflow_id != '' }} - uses: aurelien-baudet/workflow-dispatch@v2.1.1 - with: - workflow: ${{ matrix.workflow_id }} - repo: cdcent/data-exchange-hl7-devops - token: ${{ env.access_token }} - inputs: '{ "targetEnv": "dev", "commitID": "${{ steps.getsha.outputs.commitID }}", "data-exchange-hl7-branch": "${{ needs.prepare-remote-cicd.outputs.merge_branch }}" }' - ref: 'main' - wait-for-completion: true - wait-for-completion-timeout: 120m - wait-for-completion-interval: 300s - display-workflow-run-url: true - - # workflow-logs: print - # uses: actions/github-script@v6.4.1 - # with: - # debug: ${{ secrets.ACTIONS_RUNNER_DEBUG }} - # github-token: '${{ env.access_token }}' - # script: | - # try { - # const result = await github.rest.actions.createWorkflowDispatch({ - # owner: 'cdcent', - # repo: 'data-exchange-hl7-devops', - # workflow_id: '${{ matrix.workflow_id }}', - # ref: 'main', - # inputs: { - # commitID: '${{ steps.getsha.outputs.commitID }}', - # 'data-exchange-hl7-branch': '${{ needs.prepare-remote-cicd.outputs.merge_branch }}' - # } - # }) - # console.log(result); - # } catch(error) { - # console.error(error); - # core.setFailed(error); - # } \ No newline at end of file diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml new file mode 100644 index 00000000..ba2df354 --- /dev/null +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -0,0 +1,45 @@ +name: Template - Remote trigger to CDCent +on: + workflow_call: + inputs: + WORKFLOW: + type: string + description: "Workflow yml file that should be triggered." + required: true + REF: + type: string + description: "Git tag or branch the workflow is running on." + required: false + default: 'main' + +jobs: + invoke-cd-trigger: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout Gen GitHub App Access Token + uses: actions/checkout@v3 + with: + repository: kave/github-app-token + - name: Generate Token + run: | + sudo gem install jwt + echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem + chmod +x ./get-github-app-access-token.sh; + . ./get-github-app-access-token.sh; + echo "access_token=${TOKEN}" >> "$GITHUB_ENV" + - name: Manually Dispatch Remote CICD Trigger Event + uses: actions/github-script@v6 + with: + github-token: ${{ env.access_token }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: 'cdcent', + repo: 'data-exchange-pstatus-devops', + workflow_id: '${{ inputs.WORKFLOW }}', + ref: 'main', + inputs: { + REF: '${{ inputs.REF }}' + } + }) \ No newline at end of file From 6e0728a0968cee05fa43fba314d48267b30b48dd Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Mon, 1 Jul 2024 15:55:00 -0400 Subject: [PATCH 43/91] CI pipeline for GraphQL service --- .github/deploy-rc.yml | 15 --------- .github/graphql-service-ci.yml | 31 ------------------ .github/graphql-service.yml | 23 ------------- .github/remote-cd-trigger-template.yml | 45 -------------------------- 4 files changed, 114 deletions(-) delete mode 100644 .github/deploy-rc.yml delete mode 100644 .github/graphql-service-ci.yml delete mode 100644 .github/graphql-service.yml delete mode 100644 .github/remote-cd-trigger-template.yml diff --git a/.github/deploy-rc.yml b/.github/deploy-rc.yml deleted file mode 100644 index 4be179c2..00000000 --- a/.github/deploy-rc.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: "TST/STG - Deploy release candidate" - -on: - workflow_dispatch: - push: - tags: - - '*' - -jobs: - staging: - uses: ./.github/workflows/remote-cd-trigger-template.yml - with: - WORKFLOW: build-and-deploy-rc.yml - REF: '${{ github.ref_name }}' # Resolves to the tag that is pushed - secrets: inherit \ No newline at end of file diff --git a/.github/graphql-service-ci.yml b/.github/graphql-service-ci.yml deleted file mode 100644 index b00d4d2b..00000000 --- a/.github/graphql-service-ci.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: "CI - GraphQL Service" - -on: - workflow_dispatch: - pull_request: - paths: - - pstatus-graphql-ktor/** - -defaults: - run: - working-directory: pstatus-graphql-ktor/ - -jobs: - unit-test: - name: Unit Test - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v4 - with: - go-version: 1.22.0 - - name: Vet - run: go vet ./... - - name: Run Tests - run: go test -coverprofile=c.out -coverpkg=./... ./... - - name: Show coverage - run: go tool cover -func=c.out - - name: Build Check - run: go build -o pstatus-graphql-ktor ./cmd/main.go \ No newline at end of file diff --git a/.github/graphql-service.yml b/.github/graphql-service.yml deleted file mode 100644 index aefa5807..00000000 --- a/.github/graphql-service.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: DEV - GraphQL Service - -on: - workflow_dispatch: - inputs: - REF: - description: Branch from CDCgov/data-exchange-processing-status that you want to deploy to the dev environment. - default: main - required: true - type: string - push: - branches: - - main - paths: - - pstatus-graphql-ktor/** - -jobs: - remote-trigger: - uses: ./.github/workflows/remote-cd-trigger-template.yml - with: - WORKFLOW: graphql-service.yml - REF: ${{ inputs.REF }} - secrets: inherit \ No newline at end of file diff --git a/.github/remote-cd-trigger-template.yml b/.github/remote-cd-trigger-template.yml deleted file mode 100644 index ba2df354..00000000 --- a/.github/remote-cd-trigger-template.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Template - Remote trigger to CDCent -on: - workflow_call: - inputs: - WORKFLOW: - type: string - description: "Workflow yml file that should be triggered." - required: true - REF: - type: string - description: "Git tag or branch the workflow is running on." - required: false - default: 'main' - -jobs: - invoke-cd-trigger: - runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} - steps: - - name: Checkout Gen GitHub App Access Token - uses: actions/checkout@v3 - with: - repository: kave/github-app-token - - name: Generate Token - run: | - sudo gem install jwt - echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem - chmod +x ./get-github-app-access-token.sh; - . ./get-github-app-access-token.sh; - echo "access_token=${TOKEN}" >> "$GITHUB_ENV" - - name: Manually Dispatch Remote CICD Trigger Event - uses: actions/github-script@v6 - with: - github-token: ${{ env.access_token }} - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: 'cdcent', - repo: 'data-exchange-pstatus-devops', - workflow_id: '${{ inputs.WORKFLOW }}', - ref: 'main', - inputs: { - REF: '${{ inputs.REF }}' - } - }) \ No newline at end of file From ed693310006968506a7034b1e04c341437a7a170 Mon Sep 17 00:00:00 2001 From: Madhavi Tammineni Date: Tue, 2 Jul 2024 12:49:08 -0400 Subject: [PATCH 44/91] Update Postman Collection (#122) --- ...cessing Status API.postman_collection.json | 179 +++++++++--------- 1 file changed, 90 insertions(+), 89 deletions(-) diff --git a/processing-status-api-function-app/test/postman/Processing Status API.postman_collection.json b/processing-status-api-function-app/test/postman/Processing Status API.postman_collection.json index 53d57281..63d766a6 100644 --- a/processing-status-api-function-app/test/postman/Processing Status API.postman_collection.json +++ b/processing-status-api-function-app/test/postman/Processing Status API.postman_collection.json @@ -1,8 +1,10 @@ { "info": { - "_postman_id": "b5f1bd40-742a-4988-bd3b-f455dbacf8be", + "_postman_id": "96e8a269-204e-48c4-b789-e3156d5894cf", "name": "Processing Status API", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "7274570", + "_collection_link": "https://speeding-star-507143.postman.co/workspace/Quest~68573676-b355-4f2a-b5d0-95cb61a584e1/collection/7274570-96e8a269-204e-48c4-b789-e3156d5894cf?action=share&source=collection_link&creator=7274570" }, "item": [ { @@ -24,7 +26,8 @@ "", "pm.environment.set(\"uploadId\", uploadId);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -34,7 +37,8 @@ "var jsonData = JSON.parse(responseBody);", "pm.environment.set(\"reportId\", jsonData.report_id);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -46,7 +50,7 @@ "raw": "{\n \"schema_version\":\"0.0.1\",\n \"schema_name\":\"dex-metadata-verify\",\n \"filename\":\"some_upload1.csv\",\n \"metadata\":{\n \"meta_field2\":\"value3\"\n },\n \"issues\":[\n \"Missing required metadata field, 'meta_field1'.\",\n \"Metadata field, 'meta_field2' is set to 'value3' and does not contain one of the allowed values: [ 'value1', value2']\"\n ]\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-metadata-verify&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-metadata-verify&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -63,11 +67,11 @@ "value": "dex-metadata-verify" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -88,7 +92,8 @@ "", "pm.environment.set(\"uploadId\", uploadId);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -98,7 +103,8 @@ "var jsonData = JSON.parse(responseBody);", "pm.environment.set(\"reportId\", jsonData.report_id);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -110,7 +116,7 @@ "raw": "{\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"dex-metadata-verify\",\n \"filename\": \"some_upload1.csv\",\n \"metadata\":{\n \"meta_field1\":\"value1\"\n },\n \"issues\": null\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-metadata-verify&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-metadata-verify&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -127,11 +133,11 @@ "value": "dex-metadata-verify" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -148,7 +154,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -158,7 +165,8 @@ "var jsonData = JSON.parse(responseBody);", "pm.environment.set(\"reportId\", jsonData.report_id);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -170,7 +178,7 @@ "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\",\n \"tguid\": \"{{uploadId}}\",\n \"offset\": 0,\n \"size\": 27472691,\n \"filename\": \"some_upload1.csv\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"end_time_epoch_millis\": 1700009141546,\n \"start_time_epoch_millis\": 1700009137234,\n \"metadata\": {\n \"filename\": \"10MB-test-file\",\n \"filetype\": \"text/plain\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"meta_ext_source\": \"IZGW\",\n \"meta_ext_sourceversion\": \"V2022-12-31\",\n \"meta_ext_entity\": \"DD2\",\n \"meta_username\": \"ygj6@cdc.gov\",\n \"meta_ext_objectkey\": \"2b18d70c-8559-11ee-b9d1-0242ac120002\",\n \"meta_ext_filename\": \"10MB-test-file\",\n \"meta_ext_submissionperiod\": \"1\"\n }\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -187,11 +195,11 @@ "value": "dex-upload" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -209,7 +217,8 @@ "var jsonData = JSON.parse(responseBody);", "pm.environment.set(\"reportId\", jsonData.report_id);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -221,7 +230,7 @@ "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\",\n \"tguid\": \"{{uploadId}}\",\n \"offset\": 27472691,\n \"size\": 27472691,\n \"filename\": \"some_upload1.csv\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"end_time_epoch_millis\": 1700009141546,\n \"start_time_epoch_millis\": 1700009137234,\n \"metadata\": {\n \"filename\": \"10MB-test-file\",\n \"filetype\": \"text/plain\",\n \"meta_destination_id\": \"ndlp\",\n \"meta_ext_event\": \"routineImmunization\",\n \"meta_ext_source\": \"IZGW\",\n \"meta_ext_sourceversion\": \"V2022-12-31\",\n \"meta_ext_entity\": \"DD2\",\n \"meta_username\": \"ygj6@cdc.gov\",\n \"meta_ext_objectkey\": \"2b18d70c-8559-11ee-b9d1-0242ac120002\",\n \"meta_ext_filename\": \"10MB-test-file\",\n \"meta_ext_submissionperiod\": \"1\"\n }\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -238,11 +247,11 @@ "value": "dex-upload" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -260,7 +269,8 @@ "var jsonData = JSON.parse(responseBody);", "pm.environment.set(\"reportId\", jsonData.report_id);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -272,7 +282,7 @@ "raw": "{\n \"schema_name\": \"dex-file-copy\",\n \"schema_version\": \"1.0\",\n \"file_source_blob_url\": \"../csv_file1.csv\",\n \"file_destination_blob_url\": \".../edav/csv_file1_87847487844.csv\",\n \"timestamp\": \"\",\n \"result\": \"success|failed\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-routing&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-routing&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -289,11 +299,11 @@ "value": "dex-routing" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -311,7 +321,8 @@ "var jsonData = JSON.parse(responseBody);", "pm.environment.set(\"reportId\", jsonData.report_id);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -323,7 +334,7 @@ "raw": "{\n \"file_name\": \"postman-dtx022-19fcf009-3c83-48d0-be62-d33c2c68c45e.txt\",\n \"file_uuid\": \"{{$randomUUID}}\",\n \"routing_metadata\": {\n \"upload_id\": \"{{uploadId}}\"\n },\n \"message_batch\": \"SINGLE\",\n \"number_of_messages\": 100,\n \"number_of_messages_not_propagated\": 0,\n \"error_messages\": [],\n \"metadata\": {\n \"processes\": [\n {\n \"process_name\": \"RECEIVER\"\n }\n ]\n },\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"hl7_debatch_report\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=receiver&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=receiver&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -340,11 +351,11 @@ "value": "receiver" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -362,7 +373,8 @@ "var jsonData = JSON.parse(responseBody);", "pm.environment.set(\"reportId\", jsonData.report_id);" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -374,7 +386,7 @@ "raw": "{\n \"id\": \"c5d9a1dd-f8e5-44fe-be7c-30cda29f6833\",\n \"message_uuid\": \"c5d9a1dd-f8e5-44fe-be7c-30cda29f6833\",\n \"message_info\": {\n \"event_code\": \"10110\",\n \"route\": \"hepatitis\",\n \"reporting_jurisdiction\": \"01\",\n \"type\": \"CASE\",\n \"local_record_id\": \"CAS13728088AL01\"\n },\n \"routing_metadata\": {\n \"upload_id\": \"UNKNOWN\"\n },\n \"metadata\": {\n \"provenance\": {\n \"event_id\": \"876b0c8b-601e-006d-38c5-5d346d06d59d\",\n \"event_timestamp\": \"2024-02-12T15:06:05.3884264Z\",\n \"file_uuid\": \"c933b793-65c5-4a5b-b5a1-ae15a8c02eba\",\n \"file_path\": \"https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/postman-dtx022-e4a79d2c-6654-420b-8d79-e4f2045fae0a.txt\",\n \"file_timestamp\": \"2024-02-12T15:06:05+00:00\",\n \"file_size\": 5513,\n \"single_or_batch\": \"SINGLE\",\n \"message_hash\": \"7e080a0ef4a2b0b7a1240304602b6197\",\n \"ext_system_provider\": \"POSTMAN\",\n \"ext_original_file_name\": \"postman-dtx022-e4a79d2c-6654-420b-8d79-e4f2045fae0a.txt\",\n \"message_index\": 1,\n \"ext_original_file_timestamp\": \"dtx0-test\",\n \"source_metadata\": {\n \"orginal_file_name\": \"test-da2e4021-4b56-48d0-add1-d3ad9f3d1c53.txt\"\n }\n },\n \"processes\": [\n {\n \"status\": \"SUCCESS\",\n \"process_name\": \"RECEIVER\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:06.579\",\n \"eventhub_offset\": 240518168576,\n \"eventhub_sequence_number\": 115413,\n \"configs\": [],\n \"start_processing_time\": \"2024-02-12T15:06:07.435+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:07.53+00:00\"\n },\n {\n \"status\": \"SUCCESS\",\n \"report\": {\n \"entries\": [],\n \"status\": \"SUCCESS\"\n },\n \"process_name\": \"REDACTOR\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:08.595\",\n \"eventhub_offset\": 124554051584,\n \"eventhub_sequence_number\": 5658,\n \"configs\": [\n \"case_config.txt\"\n ],\n \"start_processing_time\": \"2024-02-12T15:06:09.46+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:09.479+00:00\"\n },\n {\n \"status\": \"SUCCESS\",\n \"report\": {\n \"entries\": {\n \"structure\": [],\n \"content\": [],\n \"value-set\": []\n },\n \"error-count\": {\n \"structure\": 0,\n \"value-set\": 0,\n \"content\": 0\n },\n \"warning-count\": {\n \"structure\": 0,\n \"value-set\": 0,\n \"content\": 0\n },\n \"status\": \"VALID_MESSAGE\"\n },\n \"process_name\": \"STRUCTURE-VALIDATOR\",\n \"process_version\": \"1.0.0\",\n \"eventhub_queued_time\": \"2024-02-12T15:06:10.47\",\n \"eventhub_offset\": 485331304448,\n \"eventhub_sequence_number\": 188792,\n \"configs\": [\n \"NOTF_ORU_V3.0\"\n ],\n \"start_processing_time\": \"2024-02-12T15:06:11.454+00:00\",\n \"end_processing_time\": \"2024-02-12T15:06:11.475+00:00\"\n }\n ]\n },\n \"summary\": {\n \"current_status\": \"VALID_MESSAGE\"\n },\n \"schema_version\": \"0.0.1\",\n \"schema_name\": \"DEX HL7v2\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-hl7-structure&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-hl7-structure&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -391,11 +403,11 @@ "value": "dex-hl7-structure" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -470,7 +482,7 @@ "raw": "" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/uploadId/49f78a80-bf57-4858-bbfb-5b6c7f27ae22", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/uploadId/75ab40c4-b595-4700-b967-7ea00d013f54", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -478,7 +490,7 @@ "api", "report", "uploadId", - "49f78a80-bf57-4858-bbfb-5b6c7f27ae22" + "75ab40c4-b595-4700-b967-7ea00d013f54" ] } }, @@ -497,7 +509,7 @@ "raw": "" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/dex-upload?", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/dex-upload", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -509,7 +521,7 @@ ], "query": [ { - "key": "eventType", + "key": "dataStreamRoute", "value": "routineImmunization", "disabled": true } @@ -531,7 +543,7 @@ "raw": "" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/stage2?eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/stage2?dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -543,7 +555,7 @@ ], "query": [ { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -564,7 +576,7 @@ "raw": "" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/dex-hl7-validation?eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/dex-testing/dex-hl7-validation?dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -576,7 +588,7 @@ ], "query": [ { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -584,8 +596,7 @@ }, "response": [] } - ], - "_postman_isSubFolder": true + ] }, { "name": "Error Paths", @@ -600,7 +611,7 @@ "raw": "{\n \"schema_version\": \"1.0\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -617,11 +628,11 @@ "value": "dex-upload" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -639,7 +650,7 @@ "raw": "{\n \"schema_name\": \"upload\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -656,11 +667,11 @@ "value": "dex-upload" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -678,7 +689,7 @@ "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -695,7 +706,7 @@ "value": "dex-upload" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -713,7 +724,7 @@ "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamId=dex-testing", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -730,7 +741,7 @@ "value": "dex-upload" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" } ] @@ -748,7 +759,7 @@ "raw": "{\n \"schema_version\": \"1.0\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamId=dex-testing&dataStreamRouting=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -765,11 +776,11 @@ "value": "dex-upload" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRouting", "value": "test-event1" } ] @@ -787,7 +798,7 @@ "raw": "{\n \"schema_name\": \"upload\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamId=dex-testing&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -804,11 +815,11 @@ "value": "dex-upload" }, { - "key": "destinationId", + "key": "dataStreamId", "value": "dex-testing" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -826,7 +837,7 @@ "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&eventType=test-event1", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamRoute=test-event1", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -843,7 +854,7 @@ "value": "dex-upload" }, { - "key": "eventType", + "key": "dataStreamRoute", "value": "test-event1" } ] @@ -861,7 +872,7 @@ "raw": "{\n \"schema_name\": \"upload\",\n \"schema_version\": \"1.0\"\n}" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&destinationId=dex-testing", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/json/uploadId/{{uploadId}}?stageName=dex-upload&dataStreamRoute=dex-testing", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -878,7 +889,7 @@ "value": "dex-upload" }, { - "key": "destinationId", + "key": "dataStreamRoute", "value": "dex-testing" } ] @@ -886,8 +897,7 @@ }, "response": [] } - ], - "_postman_isSubFolder": true + ] } ] }, @@ -978,7 +988,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -993,7 +1004,7 @@ "raw": "" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/counts/9de71b83-54ed-4231-a426-ff5f208d75ff", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/counts/75ab40c4-b595-4700-b967-7ea00d013f54", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -1001,7 +1012,7 @@ "api", "report", "counts", - "9de71b83-54ed-4231-a426-ff5f208d75ff" + "75ab40c4-b595-4700-b967-7ea00d013f54" ] } }, @@ -1016,7 +1027,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -1031,7 +1043,7 @@ "raw": "" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/counts?destination_id=dex-testing&ext_event=test-event1&date_start=20240207T190000Z", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/report/counts?data_stream_id=dex-testing&data_stream_route=test-event1&date_start=20240207T190000Z", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -1042,11 +1054,11 @@ ], "query": [ { - "key": "destination_id", + "key": "data_stream_id", "value": "dex-testing" }, { - "key": "ext_event", + "key": "data_stream_route", "value": "test-event1" }, { @@ -1067,7 +1079,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -1082,14 +1095,14 @@ "raw": "" }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/status/9de71b83-54ed-4231-a426-ff5f208d75ff", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/status/75ab40c4-b595-4700-b967-7ea00d013f54", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], "path": [ "api", "status", - "9de71b83-54ed-4231-a426-ff5f208d75ff" + "75ab40c4-b595-4700-b967-7ea00d013f54" ] } }, @@ -1100,12 +1113,8 @@ "request": { "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { - "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/upload/dex-testing?page_number=1&page_size=20&sort_by=date&sort_order=descending&ext_event=test-event1&date_start=20240207T190000Z", + "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/upload/dex-testing?page_number=1&page_size=20&sort_by=date&sort_order=descending&ext_event=test-event1&date_start=20240701T190000Z", "host": [ "{{PROCESSING_STATUS_BASE_URL}}" ], @@ -1137,7 +1146,7 @@ }, { "key": "date_start", - "value": "20240207T190000Z" + "value": "20240701T190000Z" } ] } @@ -1151,10 +1160,6 @@ "request": { "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{PROCESSING_STATUS_BASE_URL}}/api/health", "host": [ @@ -1173,10 +1178,6 @@ "request": { "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "" } From a482ba50885ac858e8b381fe3cb1862e97f94b37 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Tue, 2 Jul 2024 16:39:18 -0500 Subject: [PATCH 45/91] Added version to report sink (#123) Co-authored-by: Matt B Krystof --- .../gov/cdc/ocio/processingstatusapi/plugins/Routing.kt | 4 ++++ pstatus-report-sink-ktor/src/main/resources/application.conf | 2 ++ 2 files changed, 6 insertions(+) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt index c288e99e..dbb7919c 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/Routing.kt @@ -11,9 +11,13 @@ import io.ktor.server.routing.* * @receiver Application */ fun Application.configureRouting() { + val version = environment.config.propertyOrNull("ktor.version")?.getString() ?: "unknown" routing { get("/health") { call.respond(HealthQueryService().getHealth()) } + get("/version") { + call.respondText(version) + } } } diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index 9f1db1db..d418292f 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -7,6 +7,8 @@ ktor { application { modules = [ gov.cdc.ocio.processingstatusapi.ApplicationKt.module ] } + + version = "0.0.1" } azure { From 696a2643808e04e77b5935728e2751344685ef58 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Tue, 2 Jul 2024 20:04:17 -0500 Subject: [PATCH 46/91] Dev updates to health checks for both the report-sink and graphql services (#124) * Updated report-sink health checks to timeout for cosmos and service bus * Updated report-sink version * Fixed comment in report-sink * Changed listening port back to 8080 * Code cleanup in report-sink * Fixed issue when no graphql path specified in the application.conf (running locally) * Made so the cosmosdb connection can timeout and the health check work --------- Co-authored-by: Matt B Krystof --- .../ocio/processingstatusapi/Application.kt | 3 ++ .../cosmos/CosmosClientManager.kt | 41 +++++++++++++--- .../cosmos/CosmosConfiguration.kt | 10 ++++ .../cosmos/CosmosContainerManager.kt | 31 +++++++----- .../cosmos/CosmosRepository.kt | 2 +- .../loaders/ReportDeadLetterLoader.kt | 22 +++------ .../processingstatusapi/plugins/GraphQL.kt | 15 ++++-- .../queries/HealthQueryService.kt | 49 +++++++++++++++++-- .../src/main/resources/application.conf | 4 +- .../ocio/processingstatusapi/Application.kt | 4 ++ .../ocio/processingstatusapi/HealthCheck.kt | 44 +++++++---------- .../cosmos/CosmosClientManager.kt | 45 +++++++++++++---- .../cosmos/CosmosConfiguration.kt | 10 ++++ .../cosmos/CosmosContainerManager.kt | 22 +++++---- .../src/main/resources/application.conf | 2 +- 15 files changed, 213 insertions(+), 91 deletions(-) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosConfiguration.kt create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosConfiguration.kt 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 3648f23a..6d8ee153 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 @@ -1,5 +1,6 @@ package gov.cdc.ocio.processingstatusapi +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosConfiguration import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.plugins.configureRouting @@ -22,6 +23,8 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp single(createdAtStart = true) { CosmosRepository(uri, authKey, "Reports", "/uploadId") } single(createdAtStart = true) { CosmosDeadLetterRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } + // Create a CosmosDB config that can be dependency injected (for health checks) + single(createdAtStart = true) { CosmosConfiguration(uri, authKey) } } return modules(listOf(cosmosModule)) } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt index f952f533..4b3e7ba1 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt @@ -3,23 +3,48 @@ package gov.cdc.ocio.processingstatusapi.cosmos import com.azure.cosmos.ConsistencyLevel import com.azure.cosmos.CosmosClient import com.azure.cosmos.CosmosClientBuilder +import kotlinx.coroutines.* +import java.time.Duration class CosmosClientManager { companion object { private var client: CosmosClient? = null + /** + * Establishes a connection to the CosmosDB and returns a client + * + * @param uri String + * @param authKey String + * @return CosmosClient? + */ + @OptIn(DelicateCoroutinesApi::class) fun getCosmosClient(uri: String, authKey: String): CosmosClient? { // Initialize a connection to cosmos that will persist across HTTP triggers if (client == null) { - client = CosmosClientBuilder() - .endpoint(uri) - .key(authKey) - .consistencyLevel(ConsistencyLevel.EVENTUAL) - .gatewayMode() - .contentResponseOnWriteEnabled(true) - .clientTelemetryEnabled(false) - .buildClient() + return try { + var d: Deferred? = null + GlobalScope.launch { + d = async { + CosmosClientBuilder() + .endpoint(uri) + .key(authKey) + .consistencyLevel(ConsistencyLevel.EVENTUAL) + .gatewayMode() + .contentResponseOnWriteEnabled(true) + .clientTelemetryEnabled(false) + .buildClient() + } + } + runBlocking { + withTimeout(Duration.ofSeconds(10).toMillis()) { + client = d?.await() + } // wait with timeout + } + client + } catch (ex: TimeoutCancellationException) { + null + } } return client } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosConfiguration.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosConfiguration.kt new file mode 100644 index 00000000..e4e62d30 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosConfiguration.kt @@ -0,0 +1,10 @@ +package gov.cdc.ocio.processingstatusapi.cosmos + +/** + * CosmosDB client configuration + * + * @property uri String + * @property authKey String + * @constructor + */ +data class CosmosConfiguration(val uri: String, val authKey: String) \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt index 51b7cd51..e0ba3ea0 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt @@ -21,26 +21,35 @@ class CosmosContainerManager { return cosmosClient.getDatabase(databaseResponse.properties.id) } + /** + * The function which creates the cosmos container instance + * @param uri String + * @param authKey String + * @param containerName String + * @param partitionKey String + */ fun initDatabaseContainer(uri: String, authKey: String, containerName: String, partitionKey: String): CosmosContainer? { val logger = KotlinLogging.logger {} try { logger.info("calling getCosmosClient...") - val cosmosClient = CosmosClientManager.getCosmosClient(uri, authKey) ?: return null + val cosmosClient = CosmosClientManager.getCosmosClient(uri, authKey) - // setup database - logger.info("calling createDatabaseIfNotExists...") - val db = createDatabaseIfNotExists(cosmosClient, "ProcessingStatus")!! + cosmosClient?.run { + // setup database + logger.info("calling createDatabaseIfNotExists...") + val db = createDatabaseIfNotExists(cosmosClient, "ProcessingStatus") - val containerProperties = CosmosContainerProperties(containerName, partitionKey) + val containerProperties = CosmosContainerProperties(containerName, partitionKey) - // Provision throughput - val throughputProperties = ThroughputProperties.createAutoscaledThroughput(1000) + // Provision throughput + val throughputProperties = ThroughputProperties.createAutoscaledThroughput(1000) - // Create container with 1000 RU/s - logger.info("calling createContainerIfNotExists...") - val databaseResponse = db.createContainerIfNotExists(containerProperties, throughputProperties) + // Create container with 1000 RU/s + logger.info("calling createContainerIfNotExists...") + val databaseResponse = db?.createContainerIfNotExists(containerProperties, throughputProperties) - return db.getContainer(databaseResponse.properties.id) + return db?.getContainer(databaseResponse?.properties?.id) + } } catch (ex: CosmosException) { logger.error("exception: ${ex.localizedMessage}") diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt index 42f63e70..52993768 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosRepository.kt @@ -8,5 +8,5 @@ class CosmosRepository(uri: String, authKey: String, reportsContainerName: Strin class CosmosDeadLetterRepository(uri: String, authKey: String, reportsContainerName: String, partitionKey: String) { val reportsDeadLetterContainer = - CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey)!! + CosmosContainerManager.initDatabaseContainer(uri, authKey, reportsContainerName, partitionKey) } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt index 0acf0600..e11e4a8f 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -20,7 +20,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { fun getByUploadId(uploadId: String): List { val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.id = '$uploadId'" - val reportItems = reportsDeadLetterContainer.queryItems( + val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), ReportDao::class.java ) @@ -47,7 +47,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { "and r.dataStreamRoute= '$dataStreamRoute' " + "and $timeRangeWhereClause" - val reportItems = reportsDeadLetterContainer.queryItems( + val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), ReportDao::class.java ) @@ -65,7 +65,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { * @param startDate String * @param endDate String */ - fun getCountByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String?, startDate:String?, endDate:String?, daysInterval:Int?): Int{ + fun getCountByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String?, startDate:String?, endDate:String?, daysInterval:Int?): Int { val logger = KotlinLogging.logger {} val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed @@ -75,19 +75,13 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { val reportsSqlQuery = "select value count(1) from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + "and $timeRangeWhereClause " + if (dataStreamRoute!=null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" - val reportItems = reportsDeadLetterContainer.queryItems( + val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), Int::class.java ) - var count = 0 - if (reportItems.iterator().hasNext()) { - count = reportItems.iterator().next() - logger.info("Count of records: $count") - - } else { - logger.info("Count of records: 0") - } - return count + val count = reportItems?.count() ?: 0 + logger.info("Count of records: $count") + return count } /** @@ -98,7 +92,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.id in ($quotedIds)" - val reportItems = reportsDeadLetterContainer.queryItems( + val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), ReportDao::class.java ) 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 039353fe..7507b940 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 @@ -13,6 +13,7 @@ import io.ktor.serialization.jackson.* import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* +import io.ktor.server.config.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -30,7 +31,7 @@ fun Application.graphQLModule() { val issuer = environment.config.property("jwt.issuer").getString() val audience = environment.config.property("jwt.audience").getString() val myRealm = environment.config.property("jwt.realm").getString() - val graphQLPath = environment.config.property("graphql.path").getString() + val graphQLPath = environment.config.tryGetString("graphql.path") install(Authentication) { jwt { jwt("auth-jwt") { @@ -97,8 +98,14 @@ fun Application.graphQLModule() { graphQLPostRoute() graphQLSubscriptionsRoute() graphQLSDLRoute() - graphiQLRoute( - graphQLEndpoint = "$graphQLPath/graphql", - subscriptionsEndpoint = "$graphQLPath/subscriptions") // Go to http://localhost:8080/graphiql for the GraphQL playground + // Go to http://localhost:8080/graphiql for the GraphQL playground + if (graphQLPath.isNullOrEmpty()) { + graphiQLRoute() + } else { + graphiQLRoute( + graphQLEndpoint = "$graphQLPath/graphql", + subscriptionsEndpoint = "$graphQLPath/subscriptions" + ) + } } } \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt index 783bc733..119bfe08 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/HealthQueryService.kt @@ -2,10 +2,20 @@ package gov.cdc.ocio.processingstatusapi.queries import com.expediagroup.graphql.generator.annotations.GraphQLDescription import com.expediagroup.graphql.server.operations.Query -import gov.cdc.ocio.processingstatusapi.loaders.ReportLoader +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosClientManager +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosConfiguration import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject 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 { @GraphQLDescription("Status of the dependency") @@ -18,10 +28,22 @@ abstract class HealthCheckSystem { open val service: String = "" } +/** + * Concrete implementation of the cosmosdb service health check. + * + * @property service String + */ class CosmosDb: HealthCheckSystem() { override val service = "Cosmos DB" } +/** + * Run health checks for the service. + * + * @property status String? + * @property totalChecksDuration String? + * @property dependencyHealthChecks MutableList + */ class HealthCheck { @GraphQLDescription("Overall status of the service") @@ -34,10 +56,25 @@ class HealthCheck { var dependencyHealthChecks = mutableListOf() } +/** + * GraphQL query service for getting health status. + */ class HealthQueryService : Query { + fun getHealth() = HealthCheckService().getHealth() +} + +/** + * Health checks for the service and any dependencies. + * + * @property logger KLogger + * @property cosmosConfiguration CosmosConfiguration + */ +class HealthCheckService : KoinComponent { private val logger = KotlinLogging.logger {} + private val cosmosConfiguration by inject() + @GraphQLDescription("Performs a service health check of the processing status API and it's dependencies.") @Suppress("unused") fun getHealth(): HealthCheck { @@ -45,7 +82,7 @@ class HealthQueryService : Query { val cosmosDBHealth = CosmosDb() val time = measureTimeMillis { try { - cosmosDBHealthy = isCosmosDBHealthy() + cosmosDBHealthy = isCosmosDBHealthy(config = cosmosConfiguration) cosmosDBHealth.status = "UP" } catch (ex: Exception) { cosmosDBHealth.healthIssues = ex.message @@ -63,10 +100,14 @@ class HealthQueryService : Query { /** * Check whether CosmosDB is healthy. * + * @param config CosmosConfiguration * @return Boolean */ - private fun isCosmosDBHealthy(): Boolean { - return ReportLoader().getAnyReport() != null + private fun isCosmosDBHealthy(config: CosmosConfiguration): Boolean { + return if (CosmosClientManager.getCosmosClient(config.uri, config.authKey) == null) + throw Exception("Failed to establish a CosmosDB client.") + else + true } /** diff --git a/pstatus-graphql-ktor/src/main/resources/application.conf b/pstatus-graphql-ktor/src/main/resources/application.conf index 47c77d70..c9a850e4 100644 --- a/pstatus-graphql-ktor/src/main/resources/application.conf +++ b/pstatus-graphql-ktor/src/main/resources/application.conf @@ -8,11 +8,11 @@ ktor { modules = [ gov.cdc.ocio.processingstatusapi.ApplicationKt.module ] } - version = "0.0.3" + version = "0.0.4" } graphql { - path = ${GRAPHQL_PATH} + path = ${?GRAPHQL_PATH} } jwt { 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 6f2e0640..693039a2 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 @@ -1,5 +1,6 @@ package gov.cdc.ocio.processingstatusapi +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosConfiguration import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.plugins.AzureServiceBusConfiguration @@ -26,6 +27,9 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp val authKey = environment.config.property("azure.cosmos_db.client.key").getString() single(createdAtStart = true) { CosmosRepository(uri, authKey, "Reports", "/uploadId") } single(createdAtStart = true) { CosmosDeadLetterRepository(uri, authKey, "Reports-DeadLetter", "/uploadId") } + + // Create a CosmosDB config that can be dependency injected (for health checks) + single(createdAtStart = true) { CosmosConfiguration(uri, authKey) } } val asbConfigModule = module { // Create an azure service bus config that can be dependency injected (for health checks) 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 34131d4b..376427f2 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,17 +1,17 @@ package gov.cdc.ocio.processingstatusapi -import com.azure.cosmos.models.CosmosQueryRequestOptions -import com.azure.messaging.servicebus.ServiceBusClientBuilder -import com.azure.messaging.servicebus.models.ServiceBusReceiveMode +import com.azure.core.exception.ResourceNotFoundException +import com.azure.messaging.servicebus.administration.ServiceBusAdministrationClientBuilder import com.microsoft.azure.servicebus.primitives.ServiceBusException -import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository -import gov.cdc.ocio.processingstatusapi.models.Report +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosClientManager +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosConfiguration import gov.cdc.ocio.processingstatusapi.plugins.AzureServiceBusConfiguration import mu.KotlinLogging import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.system.measureTimeMillis + /** * Abstract class used for modeling the health issues of an individual service. * @@ -66,14 +66,14 @@ class HealthCheck { * Service for querying the health of the report-sink service and its dependencies. * * @property logger KLogger - * @property cosmosRepository CosmosRepository + * @property cosmosConfiguration CosmosConfiguration * @property azureServiceBusConfiguration AzureServiceBusConfiguration */ class HealthQueryService: KoinComponent { private val logger = KotlinLogging.logger {} - private val cosmosRepository by inject() + private val cosmosConfiguration by inject() private val azureServiceBusConfiguration by inject() @@ -89,7 +89,7 @@ class HealthQueryService: KoinComponent { val serviceBusHealth = HealthCheckServiceBus() val time = measureTimeMillis { try { - cosmosDBHealthy = isCosmosDBHealthy() + cosmosDBHealthy = isCosmosDBHealthy(config = cosmosConfiguration) cosmosDBHealth.status = "UP" } catch (ex: Exception) { cosmosDBHealth.healthIssues = ex.message @@ -116,15 +116,14 @@ class HealthQueryService: KoinComponent { /** * Check whether CosmosDB is healthy. * + * @param config CosmosConfiguration * @return Boolean */ - private fun isCosmosDBHealthy(): Boolean { - val sqlQuery = "select * t offset 0 limit 1" - cosmosRepository.reportsContainer?.queryItems( - sqlQuery, CosmosQueryRequestOptions(), - Report::class.java - ) - return true + private fun isCosmosDBHealthy(config: CosmosConfiguration): Boolean { + return if (CosmosClientManager.getCosmosClient(config.uri, config.authKey) == null) + throw Exception("Failed to establish a CosmosDB client.") + else + true } /** @@ -132,22 +131,15 @@ class HealthQueryService: KoinComponent { * * @return Boolean */ - @Throws(InterruptedException::class, ServiceBusException::class) + @Throws(ResourceNotFoundException::class, ServiceBusException::class) private fun isServiceBusHealthy(config: AzureServiceBusConfiguration): Boolean { - val receiverClient = ServiceBusClientBuilder() + val adminClient = ServiceBusAdministrationClientBuilder() .connectionString(config.connectionString) - .receiver() - .topicName(config.topicName) - .subscriptionName(config.subscriptionName) - .receiveMode(ServiceBusReceiveMode.PEEK_LOCK) // PEEK_LOCK mode to avoid consuming messages .buildClient() - // Attempt to open the connection - receiverClient.peekMessage() - - // Close the receiver client - receiverClient.close() + // Get the properties of the topic to check the connection + adminClient.getTopic(config.topicName) return true } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt index 54fdb132..4b3e7ba1 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosClientManager.kt @@ -3,25 +3,50 @@ package gov.cdc.ocio.processingstatusapi.cosmos import com.azure.cosmos.ConsistencyLevel import com.azure.cosmos.CosmosClient import com.azure.cosmos.CosmosClientBuilder +import kotlinx.coroutines.* +import java.time.Duration class CosmosClientManager { companion object { private var client: CosmosClient? = null - fun getCosmosClient(uri: String, authKey: String): CosmosClient { + /** + * Establishes a connection to the CosmosDB and returns a client + * + * @param uri String + * @param authKey String + * @return CosmosClient? + */ + @OptIn(DelicateCoroutinesApi::class) + fun getCosmosClient(uri: String, authKey: String): CosmosClient? { // Initialize a connection to cosmos that will persist across HTTP triggers if (client == null) { - client = CosmosClientBuilder() - .endpoint(uri) - .key(authKey) - .consistencyLevel(ConsistencyLevel.EVENTUAL) - .gatewayMode() - .contentResponseOnWriteEnabled(true) - .clientTelemetryEnabled(false) - .buildClient() + return try { + var d: Deferred? = null + GlobalScope.launch { + d = async { + CosmosClientBuilder() + .endpoint(uri) + .key(authKey) + .consistencyLevel(ConsistencyLevel.EVENTUAL) + .gatewayMode() + .contentResponseOnWriteEnabled(true) + .clientTelemetryEnabled(false) + .buildClient() + } + } + runBlocking { + withTimeout(Duration.ofSeconds(10).toMillis()) { + client = d?.await() + } // wait with timeout + } + client + } catch (ex: TimeoutCancellationException) { + null + } } - return client!! + return client } } } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosConfiguration.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosConfiguration.kt new file mode 100644 index 00000000..e4e62d30 --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosConfiguration.kt @@ -0,0 +1,10 @@ +package gov.cdc.ocio.processingstatusapi.cosmos + +/** + * CosmosDB client configuration + * + * @property uri String + * @property authKey String + * @constructor + */ +data class CosmosConfiguration(val uri: String, val authKey: String) \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt index 15b79c6f..20013173 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/cosmos/CosmosContainerManager.kt @@ -40,20 +40,22 @@ class CosmosContainerManager { logger.info("calling getCosmosClient...") val cosmosClient = CosmosClientManager.getCosmosClient(uri, authKey) - // setup database - logger.info("calling createDatabaseIfNotExists...") - val db = createDatabaseIfNotExists(cosmosClient, "ProcessingStatus")!! + cosmosClient?.run { + // setup database + logger.info("calling createDatabaseIfNotExists...") + val db = createDatabaseIfNotExists(cosmosClient, "ProcessingStatus") - val containerProperties = CosmosContainerProperties(containerName, partitionKey) + val containerProperties = CosmosContainerProperties(containerName, partitionKey) - // Provision throughput - val throughputProperties = ThroughputProperties.createAutoscaledThroughput(1000) + // Provision throughput + val throughputProperties = ThroughputProperties.createAutoscaledThroughput(1000) - // Create container with 1000 RU/s - logger.info("calling createContainerIfNotExists...") - val databaseResponse = db.createContainerIfNotExists(containerProperties, throughputProperties) + // Create container with 1000 RU/s + logger.info("calling createContainerIfNotExists...") + val databaseResponse = db?.createContainerIfNotExists(containerProperties, throughputProperties) - return db.getContainer(databaseResponse.properties.id) + return db?.getContainer(databaseResponse?.properties?.id) + } } catch (ex: CosmosException) { logger.error("exception: ${ex.localizedMessage}") diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index d418292f..a7167772 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -8,7 +8,7 @@ ktor { modules = [ gov.cdc.ocio.processingstatusapi.ApplicationKt.module ] } - version = "0.0.1" + version = "0.0.2" } azure { From 18f2dde3b4780803ad75f26d4cd688793d515aa5 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Fri, 5 Jul 2024 10:35:04 -0500 Subject: [PATCH 47/91] DASB-536 - Updates to the notifications service (#125) * Removing .gradle folder, added .gitignore to notifications service * Code cleanup, added health and version endpoints, added listen for topic and process --------- Co-authored-by: Matt B Krystof --- pstatus-notifications-ktor/.gitignore | 36 ++++ .../.gradle/8.5/checksums/checksums.lock | Bin 17 -> 0 bytes .../dependencies-accessors.lock | Bin 17 -> 0 bytes .../8.5/dependencies-accessors/gc.properties | 0 .../.gradle/8.5/fileChanges/last-build.bin | Bin 1 -> 0 bytes .../.gradle/8.5/fileHashes/fileHashes.lock | Bin 17 -> 0 bytes .../.gradle/8.5/gc.properties | 0 .../.gradle/vcs-1/gc.properties | 0 pstatus-notifications-ktor/build.gradle | 36 ++-- .../src/main/kotlin/Application.kt | 33 +++- .../src/main/kotlin/HealthCheck.kt | 125 ++++++++++++ .../src/main/kotlin/Routes.kt | 18 +- .../ReportsNotificationProcessor.kt | 6 +- .../src/main/kotlin/servicebus/ServiceBus.kt | 187 ++++++++++++++++++ .../src/main/resources/application.conf | 11 +- 15 files changed, 422 insertions(+), 30 deletions(-) create mode 100644 pstatus-notifications-ktor/.gitignore delete mode 100644 pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock delete mode 100644 pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock delete mode 100644 pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/gc.properties delete mode 100644 pstatus-notifications-ktor/.gradle/8.5/fileChanges/last-build.bin delete mode 100644 pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock delete mode 100644 pstatus-notifications-ktor/.gradle/8.5/gc.properties delete mode 100644 pstatus-notifications-ktor/.gradle/vcs-1/gc.properties create mode 100644 pstatus-notifications-ktor/src/main/kotlin/HealthCheck.kt create mode 100644 pstatus-notifications-ktor/src/main/kotlin/servicebus/ServiceBus.kt diff --git a/pstatus-notifications-ktor/.gitignore b/pstatus-notifications-ktor/.gitignore new file mode 100644 index 00000000..c426c32f --- /dev/null +++ b/pstatus-notifications-ktor/.gitignore @@ -0,0 +1,36 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock b/pstatus-notifications-ktor/.gradle/8.5/checksums/checksums.lock deleted file mode 100644 index dc41b8b14344f8c5a9f10df803a78ac3e6e70c7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 VcmZRUKRZwLDPzlT1~6cf2LLgl1UUcz diff --git a/pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock b/pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock deleted file mode 100644 index 2ad8c8569a18c28c24792822b069a610b2ab8d8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 TcmZQhHmWte_PhEv0~7!NGzSE3 diff --git a/pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/gc.properties b/pstatus-notifications-ktor/.gradle/8.5/dependencies-accessors/gc.properties deleted file mode 100644 index e69de29b..00000000 diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileChanges/last-build.bin b/pstatus-notifications-ktor/.gradle/8.5/fileChanges/last-build.bin deleted file mode 100644 index f76dd238ade08917e6712764a16a22005a50573d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1 IcmZPo000310RR91 diff --git a/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/pstatus-notifications-ktor/.gradle/8.5/fileHashes/fileHashes.lock deleted file mode 100644 index 346088e05d2708857a3675a5c04a2a12fa3e1c27..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17 VcmZR6)VyMuPwtv*1~9NF1^_!u1u_5t diff --git a/pstatus-notifications-ktor/.gradle/8.5/gc.properties b/pstatus-notifications-ktor/.gradle/8.5/gc.properties deleted file mode 100644 index e69de29b..00000000 diff --git a/pstatus-notifications-ktor/.gradle/vcs-1/gc.properties b/pstatus-notifications-ktor/.gradle/vcs-1/gc.properties deleted file mode 100644 index e69de29b..00000000 diff --git a/pstatus-notifications-ktor/build.gradle b/pstatus-notifications-ktor/build.gradle index ede9b42a..17ff6237 100644 --- a/pstatus-notifications-ktor/build.gradle +++ b/pstatus-notifications-ktor/build.gradle @@ -36,32 +36,38 @@ group "gov.cdc.ocio" version "0.0.1" dependencies { - 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.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 "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.azure:azure-messaging-servicebus:7.17.1' + implementation 'com.microsoft.azure:azure-servicebus:3.6.7' + 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 'ch.qos.logback.contrib:logback-json-classic:0.1.5' + implementation group: 'ch.qos.logback.contrib', name: 'logback-jackson', version: '0.1.5' + 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("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") 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("org.mockito.kotlin:mockito-kotlin:4.0.0") + testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" testImplementation 'org.testng:testng:7.7.0' testImplementation "org.mockito:mockito-inline:3.11.2" testImplementation "io.mockk:mockk:1.13.9" - testImplementation("io.ktor:ktor-server-tests-jvm") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") + testImplementation "io.ktor:ktor-server-tests-jvm" + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" } diff --git a/pstatus-notifications-ktor/src/main/kotlin/Application.kt b/pstatus-notifications-ktor/src/main/kotlin/Application.kt index e15ccbf8..14d525b2 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/Application.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/Application.kt @@ -1,31 +1,50 @@ package gov.cdc.ocio.processingstatusnotifications - - +import gov.cdc.ocio.processingstatusnotifications.servicebus.AzureServiceBusConfiguration +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.serialization.kotlinx.json.* import io.ktor.server.routing.* -/*fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinApplication { +import org.koin.core.KoinApplication +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin + -}*/ +/** + * Load the environment configuration values + * Instantiate a singleton CosmosDatabase container instance + * @param environment ApplicationEnvironment + */ +fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinApplication { + + val asbConfigModule = module { + // Create an azure service bus config that can be dependency injected (for health checks) + single(createdAtStart = true) { AzureServiceBusConfiguration(environment.config, configurationPath = "azure.service_bus") } + } + + return modules(listOf(asbConfigModule)) +} fun main(args: Array) { embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true) } fun Application.module() { - + install(Koin) { + loadKoinModules(environment) + } install(ContentNegotiation) { - json() + jackson() } routing { subscribeEmailNotificationRoute() unsubscribeEmailNotificationRoute() subscribeWebhookRoute() unsubscribeWebhookRoute() + healthCheckRoute() + versionRoute() } } diff --git a/pstatus-notifications-ktor/src/main/kotlin/HealthCheck.kt b/pstatus-notifications-ktor/src/main/kotlin/HealthCheck.kt new file mode 100644 index 00000000..159a596a --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/HealthCheck.kt @@ -0,0 +1,125 @@ +package gov.cdc.ocio.processingstatusnotifications + +import com.azure.core.exception.ResourceNotFoundException +import com.azure.messaging.servicebus.administration.ServiceBusAdministrationClientBuilder +import com.microsoft.azure.servicebus.primitives.ServiceBusException +import gov.cdc.ocio.processingstatusnotifications.servicebus.AzureServiceBusConfiguration +import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +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 Azure Service Bus health check. + * + * @property service String + */ +class HealthCheckServiceBus: HealthCheckSystem() { + override val service: String = "Azure Service Bus" +} + +/** + * 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 report-sink service and its dependencies. + * + * @property logger KLogger + * @property cosmosConfiguration CosmosConfiguration + * @property azureServiceBusConfiguration AzureServiceBusConfiguration + */ +class HealthQueryService: KoinComponent { + + private val logger = KotlinLogging.logger {} + + private val azureServiceBusConfiguration by inject() + + /** + * Returns a HealthCheck object with the overall health of the report-sink service and its dependencies. + * + * @return HealthCheck + */ + fun getHealth(): HealthCheck { + var serviceBusHealthy = false + val serviceBusHealth = HealthCheckServiceBus() + val time = measureTimeMillis { + 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}") + } + } + + return HealthCheck().apply { + status = if (serviceBusHealthy) "UP" else "DOWN" + totalChecksDuration = formatMillisToHMS(time) + dependencyHealthChecks.add(serviceBusHealth) + } + } + + /** + * Check whether service bus is healthy. + * + * @return Boolean + */ + @Throws(ResourceNotFoundException::class, ServiceBusException::class) + private fun isServiceBusHealthy(config: AzureServiceBusConfiguration): Boolean { + + val adminClient = ServiceBusAdministrationClientBuilder() + .connectionString(config.connectionString) + .buildClient() + + // Get the properties of the topic to check the connection + adminClient.getTopic(config.topicName) + + return true + } + + /** + * 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-ktor/src/main/kotlin/Routes.kt b/pstatus-notifications-ktor/src/main/kotlin/Routes.kt index 16e70fec..cb8b2c8e 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/Routes.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/Routes.kt @@ -5,7 +5,6 @@ package gov.cdc.ocio.processingstatusnotifications import gov.cdc.ocio.processingstatusnotifications.notifications.UnSubscribeNotifications import gov.cdc.ocio.processingstatusnotifications.notifications.SubscribeEmailNotifications import gov.cdc.ocio.processingstatusnotifications.notifications.SubscribeWebhookNotifications -import kotlinx.serialization.Serializable import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -15,7 +14,6 @@ import io.ktor.server.routing.* /** * Email Subscription data class which is serialized back and forth */ -@Serializable data class EmailSubscription(val dataStreamId:String, val dataStreamRoute:String, val email: String, @@ -24,7 +22,6 @@ data class EmailSubscription(val dataStreamId:String, /** * Webhook Subscription data class which is serialized back and forth */ -@Serializable data class WebhookSubscription(val dataStreamId:String, val dataStreamRoute:String, val url: String, @@ -34,13 +31,11 @@ data class WebhookSubscription(val dataStreamId:String, /** * UnSubscription data class which is serialized back and forth when we need to unsubscribe by the subscriptionId */ -@Serializable data class UnSubscription(val subscriptionId:String) /** * The resultant class for subscription of email/webhooks */ -@Serializable data class SubscriptionResult( var subscription_id: String? = null, var timestamp: Long? = null, @@ -91,4 +86,17 @@ fun Route.unsubscribeWebhookRoute() { val result = UnSubscribeNotifications().run(subscription.subscriptionId) call.respond(result) } +} + +fun Route.healthCheckRoute() { + get("/health") { + call.respond(HealthQueryService().getHealth()) + } +} + +fun Route.versionRoute() { + val version = environment?.config?.propertyOrNull("ktor.version")?.getString() ?: "unknown" + get("/version") { + call.respondText(version) + } } \ No newline at end of file diff --git a/pstatus-notifications-ktor/src/main/kotlin/servicebus/ReportsNotificationProcessor.kt b/pstatus-notifications-ktor/src/main/kotlin/servicebus/ReportsNotificationProcessor.kt index 800c3979..cbe0779f 100644 --- a/pstatus-notifications-ktor/src/main/kotlin/servicebus/ReportsNotificationProcessor.kt +++ b/pstatus-notifications-ktor/src/main/kotlin/servicebus/ReportsNotificationProcessor.kt @@ -1,5 +1,6 @@ package gov.cdc.ocio.processingstatusnotifications.servicebus +import com.azure.messaging.servicebus.ServiceBusReceivedMessage import com.google.gson.GsonBuilder import com.google.gson.JsonSyntaxException import com.google.gson.ToNumberPolicy @@ -29,9 +30,10 @@ class ReportsNotificationProcessor { * @throws BadStateException */ @Throws(BadRequestException::class, InvalidSchemaDefException::class) - fun withMessage(message: String): String { + fun withMessage(message: ServiceBusReceivedMessage): String { + val sbMessage = message.body.toString() try { - return sendNotificationForReportStatus(gson.fromJson(message, ReportNotificationServiceBusMessage::class.java)) + return sendNotificationForReportStatus(gson.fromJson(sbMessage, ReportNotificationServiceBusMessage::class.java)) } catch (e: JsonSyntaxException) { logger.error("Failed to parse CreateReportSBMessage: ${e.localizedMessage}") throw BadRequestException("Unable to interpret the create report message") diff --git a/pstatus-notifications-ktor/src/main/kotlin/servicebus/ServiceBus.kt b/pstatus-notifications-ktor/src/main/kotlin/servicebus/ServiceBus.kt new file mode 100644 index 00000000..4610732f --- /dev/null +++ b/pstatus-notifications-ktor/src/main/kotlin/servicebus/ServiceBus.kt @@ -0,0 +1,187 @@ +package gov.cdc.ocio.processingstatusnotifications.servicebus + +import com.azure.core.amqp.AmqpTransportType +import com.azure.core.amqp.exception.AmqpException +import com.azure.messaging.servicebus.* +import com.azure.messaging.servicebus.models.DeadLetterOptions +import gov.cdc.ocio.processingstatusnotifications.exception.BadRequestException +import io.ktor.server.application.* +import io.ktor.server.application.hooks.* +import io.ktor.server.config.* +import io.ktor.util.logging.* +import org.apache.qpid.proton.engine.TransportException +import java.util.concurrent.TimeUnit + +internal val LOGGER = KtorSimpleLogger("pstatus-notifications") + +/** + * Class which initializes configuration values + * @param config ApplicationConfig + * + */ +class AzureServiceBusConfiguration(config: ApplicationConfig, configurationPath: String? = null) { + private val configPath = if (configurationPath != null) "$configurationPath." else "" + val connectionString = config.tryGetString("${configPath}connection_string") ?: "" + val queueName = config.tryGetString("${configPath}queue_name") ?: "" + val topicName = config.tryGetString("${configPath}topic_name") ?: "" + val subscriptionName = config.tryGetString("${configPath}subscription_name") ?: "" +} + +val AzureServiceBus = createApplicationPlugin( + name = "AzureServiceBus", + configurationPath = "azure.service_bus", + createConfiguration = ::AzureServiceBusConfiguration) { + + val connectionString = pluginConfig.connectionString + val queueName = pluginConfig.queueName + val topicName = pluginConfig.topicName + val subscriptionName = pluginConfig.subscriptionName + + // Initialize Service Bus client for queue + val processorQueueClient by lazy { + ServiceBusClientBuilder() + .connectionString(connectionString) + .transportType(AmqpTransportType.AMQP_WEB_SOCKETS) + .processor() + .queueName(queueName) + .processMessage{ context -> processMessage(context) } + .processError { context -> processError(context) } + .buildProcessorClient() + } + + // Initialize Service Bus client for topics + val processorTopicClient by lazy { + ServiceBusClientBuilder() + .connectionString(connectionString) + .transportType(AmqpTransportType.AMQP_WEB_SOCKETS) + .processor() + .topicName(topicName) + .subscriptionName(subscriptionName) + .processMessage{ context -> processMessage(context) } + .processError { context -> processError(context) } + .buildProcessorClient() + } + + /** + * Function which starts receiving messages from queues and topics + * @throws AmqpException + * @throws TransportException + * @throws Exception generic + */ + @Throws(InterruptedException::class) + fun receiveMessages() { + try { + // Create an instance of the processor through the ServiceBusClientBuilder + println("Starting the Azure service bus processor") + println("connectionString = $connectionString, queueName = $queueName, topicName= $topicName, subscriptionName=$subscriptionName") + processorQueueClient.start() + processorTopicClient.start() + } + + catch (e:AmqpException){ + println("Non-ServiceBusException occurred : ${e.message}") + } + catch (e:TransportException){ + println("Non-ServiceBusException occurred : ${e.message}") + } + + catch (e:Exception){ + println("Non-ServiceBusException occurred : ${e.message}") + } + + } + + on(MonitoringEvent(ApplicationStarted)) { application -> + application.log.info("Server is started") + receiveMessages() + } + on(MonitoringEvent(ApplicationStopped)) { application -> + application.log.info("Server is stopped") + println("Stopping and closing the processor") + processorQueueClient.close() + processorTopicClient.close() + // Release resources and unsubscribe from events + application.environment.monitor.unsubscribe(ApplicationStarted) {} + application.environment.monitor.unsubscribe(ApplicationStopped) {} + } +} + +/** + * Function which processes the message received in the queue or topics + * @param context ServiceBusReceivedMessageContext + * @throws BadRequestException + * @throws IllegalArgumentException + * @throws Exception generic + */ +private fun processMessage(context: ServiceBusReceivedMessageContext) { + val message = context.message + + LOGGER.trace( + "Processing message. Session: {}, Sequence #: {}. Contents: {}", + message.messageId, + message.sequenceNumber, + message.body + ) + try { + ReportsNotificationProcessor().withMessage(message) + } + //This will handle all missing required fields, invalid schema definition and malformed json all under the BadRequest exception and writes to dead-letter queue or topics depending on the context + catch (e: BadRequestException) { + LOGGER.warn("Unable to parse the message: {}", e.localizedMessage) + val deadLetterOptions = DeadLetterOptions() + .setDeadLetterReason("Validation failed") + .setDeadLetterErrorDescription(e.message) + context.deadLetter(deadLetterOptions) + LOGGER.info("Message sent to the dead-letter queue.") + } + catch (e: Exception) { + LOGGER.warn("Failed to process service bus message: {}", e.localizedMessage) + } + +} + +/** + * Function to handle and process the error generated during the processing of messages from queue or topics + * @param context ServiceBusErrorContext + */ +private fun processError(context: ServiceBusErrorContext) { + System.out.printf( + "Error when receiving messages from namespace: '%s'. Entity: '%s'%n", + context.fullyQualifiedNamespace, context.entityPath + ) + if (context.exception !is ServiceBusException) { + System.out.printf("Non-ServiceBusException occurred: %s%n", context.exception) + return + } + val exception = context.exception as ServiceBusException + val reason = exception.reason + if (reason === ServiceBusFailureReason.MESSAGING_ENTITY_DISABLED || reason === ServiceBusFailureReason.MESSAGING_ENTITY_NOT_FOUND || reason === ServiceBusFailureReason.UNAUTHORIZED) { + System.out.printf( + "An unrecoverable error occurred. Stopping processing with reason %s: %s%n", + reason, exception.message + ) + } else if (reason === ServiceBusFailureReason.MESSAGE_LOCK_LOST) { + System.out.printf("Message lock lost for message: %s%n", context.exception) + } else if (reason === ServiceBusFailureReason.SERVICE_BUSY) { + try { + // Choosing an arbitrary amount of time to wait until trying again. + TimeUnit.SECONDS.sleep(1) + } catch (e: InterruptedException) { + System.err.println("Unable to sleep for period of time") + } + } else { + System.out.printf( + "Error source %s, reason %s, message: %s%n", context.errorSource, + reason, context.exception + ) + } +} + +/** + * The main application module which runs always + */ +fun Application.serviceBusModule() { + install(AzureServiceBus) { + // any additional configuration goes here + } +} diff --git a/pstatus-notifications-ktor/src/main/resources/application.conf b/pstatus-notifications-ktor/src/main/resources/application.conf index fefe723e..670edb2c 100644 --- a/pstatus-notifications-ktor/src/main/resources/application.conf +++ b/pstatus-notifications-ktor/src/main/resources/application.conf @@ -1,11 +1,20 @@ ktor { deployment { - port = 8081 + port = 8080 host = 0.0.0.0 } application { modules = [ gov.cdc.ocio.processingstatusnotifications.ApplicationKt.module ] } + + version = "0.0.1" } +azure { + service_bus { + connection_string = ${SERVICE_BUS_CONNECTION_STRING} + topic_name = ${SERVICE_BUS_REPORT_TOPIC_NAME} + subscription_name = ${SERVICE_BUS_NOTIFICATION_TOPIC_SUBSCRIPTION_NAME} + } +} \ No newline at end of file From 332589f3e8b4892f19139e4eca41fcdf044c4ec2 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Mon, 8 Jul 2024 12:55:06 -0400 Subject: [PATCH 48/91] Added functionality to provide meaningful response when graphql mutation service cannot connect to notification service or when there is an error (non-200) comes from notification service --- .../mutations/NotificationsMutationService.kt | 133 ++++++++++++++---- 1 file changed, 106 insertions(+), 27 deletions(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt index a537c534..01b71fea 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/NotificationsMutationService.kt @@ -6,13 +6,17 @@ import com.expediagroup.graphql.generator.annotations.GraphQLDescription import com.expediagroup.graphql.server.operations.Mutation import io.ktor.client.* import io.ktor.client.call.* +import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.statement.* import io.ktor.serialization.kotlinx.json.* import io.ktor.http.* import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable + /** * Email Subscription data class which is serialized back and forth which is in turn subscribed in to the MemCache * @param dataStreamId String @@ -55,12 +59,21 @@ data class SubscriptionResult( */ class NotificationsMutationService : Mutation { - val notificationsRouteBaseUrl =System.getenv("PSTATUS_NOTIFICATIONS_BASE_URL") + private val notificationsRouteBaseUrl: String =System.getenv("PSTATUS_NOTIFICATIONS_BASE_URL") + private val serviceUnavailable ="Notification service is unavailable and no connection has been established. Make sure the service is running" private val client = HttpClient { install(ContentNegotiation) { json() } - + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.INFO + } + install(HttpTimeout) { + requestTimeoutMillis = 10000 + connectTimeoutMillis = 10000 + socketTimeoutMillis = 10000 + } } /** @@ -76,18 +89,26 @@ class NotificationsMutationService : Mutation { @Suppress("unused") fun subscribeEmail(dataStreamId:String, dataStreamRoute:String,email: String, stageName: String, statusType:String):SubscriptionResult { val url = "$notificationsRouteBaseUrl/subscribe/email" - return runBlocking { - client.post(url) { - contentType(io.ktor.http.ContentType.Application.Json) - setBody(EmailSubscription(dataStreamId, dataStreamRoute,email,stageName,statusType)) - }.body() + return runBlocking { + try { + val response= client.post(url) { + contentType(ContentType.Application.Json) + setBody(EmailSubscription(dataStreamId, dataStreamRoute, email, stageName, statusType)) + } + return@runBlocking ProcessResponse(response) + } + catch (e:Exception){ + if(e.message!!.contains("Status:")){ + ProcessErrorCodes(url,e, null) + } + throw Exception(serviceUnavailable) + } } - } /** - * UnSubscribeEmail function which inturn uses the http client to invoke the notifications ktor microservice route to unsubscribe email notifications using the subscriberId + * UnSubscribeEmail function which in turn uses the http client to invoke the notifications ktor microservice route to unsubscribe email notifications using the subscriberId * @param subscriptionId String */ @@ -95,17 +116,26 @@ class NotificationsMutationService : Mutation { @Suppress("unused") fun unsubscribeEmail(subscriptionId:String):SubscriptionResult { val url = "$notificationsRouteBaseUrl/unsubscribe/email" - return runBlocking { - client.post(url) { - contentType(ContentType.Application.Json) - setBody(UnSubscription(subscriptionId)) - }.body() - } + return runBlocking { + try { + val response = client.post(url) { + contentType(ContentType.Application.Json) + setBody(UnSubscription(subscriptionId)) + } + return@runBlocking ProcessResponse(response) + } + catch (e:Exception){ + if(e.message!!.contains("Status:")){ + ProcessErrorCodes(url,e, subscriptionId) + } + throw Exception(serviceUnavailable) + } + } } /** - * SubscribeWebhook function which inturn uses the http client to invoke the notifications ktor microservice route to subscribe webhook notifications + * SubscribeWebhook function which in turn uses the http client to invoke the notifications ktor microservice route to subscribe webhook notifications * @param dataStreamId String * @param dataStreamRoute String * @param email String @@ -118,17 +148,24 @@ class NotificationsMutationService : Mutation { fun subscribeWebhook(dataStreamId:String, dataStreamRoute:String,email: String, stageName: String, statusType:String):SubscriptionResult { val url = "$notificationsRouteBaseUrl/subscribe/webhook" return runBlocking { - client.post(url) { - contentType(io.ktor.http.ContentType.Application.Json) + try{ + val response= client.post(url) { + contentType(ContentType.Application.Json) setBody(EmailSubscription(dataStreamId, dataStreamRoute,email,stageName,statusType)) - }.body() - + } + return@runBlocking ProcessResponse(response) + } + catch (e:Exception){ + if(e.message!!.contains("Status:")){ + ProcessErrorCodes(url,e,null) + } + throw Exception(serviceUnavailable) + } } - } /** - * UnSubscribeWebhook function which inturn uses the http client to invoke the notifications ktor microservice route to unsubscribe webhook notifications + * UnSubscribeWebhook function which in turn uses the http client to invoke the notifications ktor microservice route to unsubscribe webhook notifications * @param subscriptionId String */ @@ -136,12 +173,54 @@ class NotificationsMutationService : Mutation { @Suppress("unused") fun unsubscribeWebhook(subscriptionId:String):SubscriptionResult { val url = "$notificationsRouteBaseUrl/unsubscribe/webhook" - return runBlocking { - client.post(url) { - contentType(ContentType.Application.Json) - setBody(UnSubscription(subscriptionId)) - }.body() + return runBlocking { + try { + val response = client.post(url) { + contentType(ContentType.Application.Json) + setBody(UnSubscription(subscriptionId)) + } + return@runBlocking ProcessResponse(response) + } + catch (e:Exception){ + if(e.message!!.contains("Status:")){ + ProcessErrorCodes(url,e, subscriptionId) + } + throw Exception(serviceUnavailable) + } + } + } + + companion object { + /** + * Function to process the http response coming from notifications service + * @param response HttpResponse + */ + private suspend fun ProcessResponse(response:HttpResponse):SubscriptionResult { + if (response.status == HttpStatusCode.OK) { + return response.body() + } else { + throw Exception("Notification service is unavailable. Status:${response.status}") + } } + } + +} +@Throws(Exception::class) +/** + * Function to process the http response codes and throw exception accordingly + * @param url String + * @param e Exception + * @param subscriptionId String? + */ +internal 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) } } \ No newline at end of file From 0264ef4410cefde97f7cccd53ff32da753494eed Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Wed, 10 Jul 2024 14:34:04 -0400 Subject: [PATCH 49/91] gradle test included --- .github/workflows/graphql-service-ci.yml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/graphql-service-ci.yml b/.github/workflows/graphql-service-ci.yml index b00d4d2b..76e0975a 100644 --- a/.github/workflows/graphql-service-ci.yml +++ b/.github/workflows/graphql-service-ci.yml @@ -17,15 +17,5 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v4 - with: - go-version: 1.22.0 - - name: Vet - run: go vet ./... - - name: Run Tests - run: go test -coverprofile=c.out -coverpkg=./... ./... - - name: Show coverage - run: go tool cover -func=c.out - - name: Build Check - run: go build -o pstatus-graphql-ktor ./cmd/main.go \ No newline at end of file + - name: Run Gradle Test + run: ./gradlew test \ No newline at end of file From e75020d052195a1e9f57ebfaab39427edcc0703d Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Wed, 17 Jul 2024 14:10:57 -0500 Subject: [PATCH 50/91] DASB-517 - Added initial ABAC graphql security (#128) * Updated the jwt auth issuer * Code cleanup * Initial version of ABAC security for protecting the getReports query * Added jwt.enabled to enable/disable the JWT security * Made security enabled an env var, defaults to enabled if not provided * Added missing function headers, log messages * Added missing headers from report loader. --------- Co-authored-by: Matt B Krystof --- .../loaders/ReportLoader.kt | 56 ++++++++++---- .../processingstatusapi/models/DataStream.kt | 20 +++++ .../plugins/CustomGraphQLContextFactory.kt | 25 ++++++ .../plugins/CustomGraphQLStatusPages.kt | 17 ++++ .../plugins/CustomSchemaGeneratorHooks.kt | 13 ++++ .../processingstatusapi/plugins/GraphQL.kt | 77 ++++++++++++------- .../queries/ReportQueryService.kt | 23 +++++- .../queries/UploadQueryService.kt | 25 +++++- .../src/main/resources/application.conf | 3 +- 9 files changed, 215 insertions(+), 44 deletions(-) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DataStream.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomGraphQLContextFactory.kt create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomGraphQLStatusPages.kt diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt index b15452bb..df26e108 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt @@ -3,25 +3,33 @@ package gov.cdc.ocio.processingstatusapi.loaders import com.azure.cosmos.models.CosmosQueryRequestOptions import gov.cdc.ocio.processingstatusapi.models.Report import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao +import gov.cdc.ocio.processingstatusapi.models.DataStream +import graphql.schema.DataFetchingEnvironment +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* import java.time.ZoneOffset -class ReportLoader: CosmosLoader() { +class ForbiddenException(message: String) : RuntimeException(message) - fun getAnyReport(): Report? { - val reportsSqlQuery = "select * from $reportsContainerName r offset 0 limit 1" - val reportItems = reportsContainer?.queryItems( - reportsSqlQuery, CosmosQueryRequestOptions(), - ReportDao::class.java - ) - return if (reportItems == null || reportItems.count() == 0) - null - else { - daoToReport(reportItems.first()) +class ReportLoader: CosmosLoader() { + + /** + * Get all reports associated with the provided upload id. + * + * @param dataFetchingEnvironment DataFetchingEnvironment + * @param uploadId String + * @return List + */ + fun getByUploadId(dataFetchingEnvironment: DataFetchingEnvironment, uploadId: String): List { + // Obtain the data streams available to the user from the data fetching env. + val authContext = dataFetchingEnvironment.graphQlContext.get("AuthContext") + var dataStreams: List? = null + authContext?.run { + val principal = authContext.principal() + dataStreams = principal?.payload?.getClaim("dataStreams")?.asList(DataStream::class.java) } - } - fun getByUploadId(uploadId: String): List { val reportsSqlQuery = "select * from $reportsContainerName r where r.uploadId = '$uploadId'" val reportItems = reportsContainer?.queryItems( @@ -29,12 +37,26 @@ class ReportLoader: CosmosLoader() { ReportDao::class.java ) + // Convert the report DAOs to reports and ensure the user has access to them. val reports = mutableListOf() - reportItems?.forEach { reports.add(daoToReport(it)) } + reportItems?.forEach { reportItem -> + val report = daoToReport(reportItem) + dataStreams?.run { + if (dataStreams?.firstOrNull { ds -> ds.name == report.dataStreamId && ds.route == report.dataStreamRoute } == null) + throw ForbiddenException("You are not allowed to access this resource.") + } + reports.add(report) + } return reports } + /** + * Search for reports with the provided ids. + * + * @param ids List + * @return List + */ fun search(ids: List): List { val quotedIds = ids.joinToString("\",\"", "\"", "\"") @@ -51,6 +73,12 @@ class ReportLoader: CosmosLoader() { return reports } + /** + * Converts a Report DAO (as received by CosmosDB) into a Report object. + * + * @param reportDao ReportDao + * @return Report + */ private fun daoToReport(reportDao: ReportDao): Report { return Report().apply { this.id = reportDao.id diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DataStream.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DataStream.kt new file mode 100644 index 00000000..31b180f5 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DataStream.kt @@ -0,0 +1,20 @@ +package gov.cdc.ocio.processingstatusapi.models + +import java.util.ArrayList + +/** + * Defines the data stream which is used in determining access permissions. + * + * @property id Int + * @property code String? + * @property name String? + * @property route String? + * @property jurisdictions ArrayList? + */ +class DataStream { + var id: Int = 0 + var code: String? = null + var name: String? = null + var route: String? = null + var jurisdictions: ArrayList? = null +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomGraphQLContextFactory.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomGraphQLContextFactory.kt new file mode 100644 index 00000000..936b74b4 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomGraphQLContextFactory.kt @@ -0,0 +1,25 @@ +package gov.cdc.ocio.processingstatusapi.plugins + +import com.expediagroup.graphql.generator.extensions.plus +import com.expediagroup.graphql.server.ktor.DefaultKtorGraphQLContextFactory +import graphql.GraphQLContext +import io.ktor.server.request.* +import io.ktor.util.* + +/** + * Custom graphql context so that we have include additional context to the default graphql context. + */ +class CustomGraphQLContextFactory : DefaultKtorGraphQLContextFactory() { + + /** + * Override the method to generate context so we can add to it. + * + * @param request ApplicationRequest + * @return GraphQLContext + */ + override suspend fun generateContext(request: ApplicationRequest): GraphQLContext = + super.generateContext(request).plus( + // Add the AuthContext to the graphql context + mapOf("AuthContext" to (request.call.attributes.getOrNull(AttributeKey("AuthContext")))) + ) +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomGraphQLStatusPages.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomGraphQLStatusPages.kt new file mode 100644 index 00000000..4cb40816 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomGraphQLStatusPages.kt @@ -0,0 +1,17 @@ +package gov.cdc.ocio.processingstatusapi.plugins + +import gov.cdc.ocio.processingstatusapi.loaders.ForbiddenException +import io.ktor.http.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.response.* + +fun StatusPagesConfig.customGraphQLStatusPages(): StatusPagesConfig { + exception { call, cause -> + when (cause) { + is UnsupportedOperationException -> call.respond(HttpStatusCode.MethodNotAllowed) + is ForbiddenException -> call.respond(HttpStatusCode.Forbidden, cause.message ?: "Forbidden") + else -> call.respond(HttpStatusCode.BadRequest) + } + } + return this +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomSchemaGeneratorHooks.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomSchemaGeneratorHooks.kt index 995bd713..78a16a4f 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomSchemaGeneratorHooks.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/CustomSchemaGeneratorHooks.kt @@ -10,12 +10,25 @@ import java.time.OffsetDateTime import kotlin.reflect.KClass import kotlin.reflect.KType +/** + * Define a GraphQL scalar for the Long type. + */ val graphqlLongClassType: GraphQLScalarType = GraphQLScalarType.newScalar() .name("Long") .coercing(ExtendedScalars.GraphQLLong.coercing) .build() +/** + * Custom schema generator hooks for the PS API reports. + */ class CustomSchemaGeneratorHooks : SchemaGeneratorHooks { + + /** + * Override the graphql schema generator types to include the new scalars for Long, DateTime, Maps, etc. + * + * @param type KType + * @return GraphQLType? + */ override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) { OffsetDateTime::class -> DateTimeScalar.INSTANCE Long::class ->graphqlLongClassType 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 7507b940..b8279707 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 @@ -6,8 +6,8 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory 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.queries.* -import gov.cdc.ocio.processingstatusapi.mutations.* import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.server.application.* @@ -18,44 +18,64 @@ import io.ktor.server.plugins.statuspages.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.websocket.* +import mu.KotlinLogging import java.time.Duration +import java.util.* + +/** + * Implementation of the GraphQL module for PS API. + * + * @receiver Application + */ fun Application.graphQLModule() { + val logger = KotlinLogging.logger {} + install(WebSockets) { // needed for subscriptions pingPeriod = Duration.ofSeconds(1) contentConverter = JacksonWebsocketContentConverter() } // see https://ktor.io/docs/server-jwt.html#configure-verifier + // Get security settings and default to enabled if missing + val securityEnabled = environment.config.tryGetString("jwt.enabled")?.lowercase() != "false" val secret = environment.config.property("jwt.secret").getString() val issuer = environment.config.property("jwt.issuer").getString() val audience = environment.config.property("jwt.audience").getString() val myRealm = environment.config.property("jwt.realm").getString() val graphQLPath = environment.config.tryGetString("graphql.path") - install(Authentication) { - jwt { - jwt("auth-jwt") { - realm = myRealm - verifier(JWT - .require(Algorithm.HMAC256(secret)) - .withAudience(audience) - .withIssuer(issuer) - .build()) - validate { credential -> - if (credential.payload.getClaim("username").asString() != "") { - JWTPrincipal(credential.payload) - } else { - null + if (securityEnabled) { + install(Authentication) { + jwt { + jwt("auth-jwt") { + realm = myRealm + verifier( + JWT + .require(Algorithm.HMAC256(Base64.getDecoder().decode(secret))) + .withAudience(audience) + .withIssuer(issuer) + .build() + ) + validate { credential -> + if (credential.payload.getClaim("username").asString() != "") { + JWTPrincipal(credential.payload) + } else { + logger.error("username missing from JWT claims, denying the token") + null + } + } + challenge { defaultScheme, realm -> + call.respond( + HttpStatusCode.Unauthorized, + "Token is not valid or has expired; defaultScheme = $defaultScheme, realm = $realm" + ) } - } - challenge { _, _ -> - call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired") } } } } install(StatusPages) { - defaultGraphQLStatusPages() + customGraphQLStatusPages() } // install(CORS) { // anyHost() @@ -85,17 +105,20 @@ fun Application.graphQLModule() { ReportDeadLetterDataLoader ) } + if (securityEnabled) { + server { + contextFactory = CustomGraphQLContextFactory() + } + } } install(Routing) { - authenticate("auth-jwt") { -// post("/graphql") { -// val principal = call.principal() -// val username = principal!!.payload.getClaim("username").asString() -// val expiresAt = principal.expiresAt?.time?.minus(System.currentTimeMillis()) -// call.respondText("Hello, $username! Token is expired at $expiresAt ms.") -// } + if (securityEnabled) { + authenticate("auth-jwt") { + graphQLPostRoute() + } + } else { + graphQLPostRoute() } - graphQLPostRoute() graphQLSubscriptionsRoute() graphQLSDLRoute() // Go to http://localhost:8080/graphiql for the GraphQL playground diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportQueryService.kt index a91df96f..b3e39b16 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportQueryService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportQueryService.kt @@ -10,10 +10,25 @@ import java.util.concurrent.CompletableFuture class ReportQueryService : Query { + /** + * Query for retrieving reports for the uploadId provided. + * + * @param dataFetchingEnvironment DataFetchingEnvironment + * @param uploadId String + * @return List + */ @GraphQLDescription("Return all the reports associated with the provided uploadId") @Suppress("unused") - fun getReports(uploadId: String) = ReportLoader().getByUploadId(uploadId) + fun getReports(dataFetchingEnvironment: DataFetchingEnvironment, + uploadId: String) = ReportLoader().getByUploadId(dataFetchingEnvironment, uploadId) + /** + * Searches for reports with the given search parameters. + * + * @param params ReportSearchParameters + * @param dfe DataFetchingEnvironment + * @return CompletableFuture> + */ @GraphQLDescription("Return list of reports based on ReportSearchParameters options") @Suppress("unused") fun searchReports(params: ReportSearchParameters, dfe: DataFetchingEnvironment): CompletableFuture> = @@ -21,6 +36,12 @@ class ReportQueryService : Query { .loadMany(params.ids) } +/** + * Report search parameters, which is a list of report IDs. + * + * @property ids List + * @constructor + */ @GraphQLDescription("Parameters for searching for reports") data class ReportSearchParameters( @GraphQLDescription("Array of report IDs to search for and retrieve") diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/UploadQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/UploadQueryService.kt index fec339c1..ed34e9f5 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/UploadQueryService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/UploadQueryService.kt @@ -7,6 +7,19 @@ import gov.cdc.ocio.processingstatusapi.loaders.UploadStatusLoader class UploadQueryService : Query { + /** + * Get the upload status for the given search criteria. + * + * @param dataStreamId String + * @param dataStreamRoute String? + * @param dateStart String? + * @param dateEnd String? + * @param pageSize Int + * @param pageNumber Int + * @param sortBy String? + * @param sortOrder String? + * @return UploadsStatus + */ @GraphQLDescription("Get the upload statuses for the given filter, sort, and pagination criteria") @Suppress("unused") fun uploads(dataStreamId: String, @@ -26,7 +39,17 @@ class UploadQueryService : Query { sortBy, sortOrder) - @GraphQLDescription("Return various uploads statistics") + /** + * Provide the upload statistics for the given search criteria. + * + * @param dataStreamId String + * @param dataStreamRoute String + * @param dateStart String? + * @param dateEnd String? + * @param daysInterval Int? + * @return UploadStats + */ + @GraphQLDescription("Return various uploads statistics") @Suppress("unused") fun getUploadStats(@GraphQLDescription("Data stream ID") dataStreamId: String, diff --git a/pstatus-graphql-ktor/src/main/resources/application.conf b/pstatus-graphql-ktor/src/main/resources/application.conf index c9a850e4..9a0a3866 100644 --- a/pstatus-graphql-ktor/src/main/resources/application.conf +++ b/pstatus-graphql-ktor/src/main/resources/application.conf @@ -16,8 +16,9 @@ graphql { } jwt { + enabled = ${?SECURITY_ENABLED} secret = "secret" - issuer = "http://0.0.0.0:8080/" + issuer = "CDC B2C/MMS" audience = "http://0.0.0.0:8080/graphql" realm = "Access to 'graphql'" } From e5d32df14bdc5bcb5bc282d90dd101dd1f2c8f16 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 17 Jul 2024 17:04:01 -0400 Subject: [PATCH 51/91] Report schema validation logic Unit tests --- pstatus-report-sink-ktor/build.gradle | 6 +- .../plugins/ServiceBusProcessor.kt | 246 ++++++++++++------ .../sample_reports/Report-invalid.json | 53 ++++ .../plugins/sample_reports/Report-valid.json | 84 ++++++ .../plugins/schema/base.1.0.0.schema.json | 160 ++++++++++++ .../schema/hl7v2-debatch.1.0.0.schema.json | 75 ++++++ ...schema_missing_contentType_validation.json | 53 ++++ ...ort_schema_missing_content_validation.json | 53 ++++ ...chema_missing_dataStreamId_validation.json | 83 ++++++ ...ma_missing_dataStreamRoute_validation.json | 83 ++++++ ..._schema_missing_schemaName_validation.json | 83 ++++++ ...hema_missing_schemaVersion_validation.json | 83 ++++++ ...t_schema_missing_stageInfo_validation.json | 76 ++++++ ...rt_schema_missing_uploadId_validation.json | 53 ++++ .../data/report_schema_validation_pass.json | 84 ++++++ .../test/ReportSchemaValidationTests.kt | 142 ++++++++++ 16 files changed, 1339 insertions(+), 78 deletions(-) create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-invalid.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-valid.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/base.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-debatch.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_contentType_validation.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_content_validation.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_dataStreamId_validation.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_dataStreamRoute_validation.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_schemaName_validation.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_schemaVersion_validation.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_stageInfo_validation.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_uploadId_validation.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_validation_pass.json create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index 417ccd99..af48b00b 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -71,6 +71,10 @@ dependencies { implementation 'org.danilopianini:khttp:1.3.1' implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9' + // JSON validations + implementation("com.networknt:json-schema-validator:1.0.73") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.+") + implementation 'org.owasp.encoder:encoder:1.2.3' implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.3.1' @@ -90,7 +94,7 @@ test { events "passed", "skipped", "failed" } //Change this to "true" if we want to execute unit tests - systemProperty("isTestEnvironment", "false") + systemProperty("isTestEnvironment", "true") // Set the test classpath, if required } 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 f8467ac4..b0b7ec65 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 @@ -8,13 +8,20 @@ import com.google.gson.ToNumberPolicy import gov.cdc.ocio.processingstatusapi.ReportManager import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException -import gov.cdc.ocio.processingstatusapi.exceptions.InvalidSchemaDefException import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage -import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition import gov.cdc.ocio.processingstatusapi.models.reports.Source import mu.KotlinLogging - +import com.fasterxml.jackson.databind.ObjectMapper import java.util.* +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.networknt.schema.JsonSchema +import com.networknt.schema.JsonSchemaFactory +import com.networknt.schema.SpecVersion +import com.networknt.schema.ValidationMessage +import java.awt.datatransfer.MimeTypeParseException +import java.io.File +import javax.activation.MimeType /** * The service bus is another interface for receiving reports. @@ -39,6 +46,7 @@ class ServiceBusProcessor { */ @Throws(BadRequestException::class) fun withMessage(message: ServiceBusReceivedMessage) { + validateJsonSchema(message) val sbMessageId = message.messageId var sbMessage = message.body.toString() val sbMessageStatus = message.state.name @@ -70,7 +78,6 @@ class ServiceBusProcessor { @Throws(BadRequestException::class) private fun createReport(messageId: String, messageStatus: String, createReportMessage: CreateReportSBMessage) { try { - validateReport(createReportMessage) val uploadId = createReportMessage.uploadId val stageName = createReportMessage.stageName logger.info("Creating report for uploadId = ${uploadId} with stageName = $stageName") @@ -96,92 +103,177 @@ class ServiceBusProcessor { } } - /** - * Function to validate report attributes for missing required fields, for schema validation and malformed content message - * @param createReportMessage CreateReportSBMessage + * Function to validate report attributes for missing required fields, for schema validation and malformed content message using networknt/json-schema-validator + * @param message ServiceBusReceivedMessage * @throws BadRequestException */ - private fun validateReport(createReportMessage: CreateReportSBMessage) { + private fun validateJsonSchema(message: ServiceBusReceivedMessage){ val invalidData = mutableListOf() - var reason = "" + var reason: String + //TODO : this needs to be replaced with more robust source or a URL of some sorts + val schemaDirectoryPath = System.getenv("SCHEMA_DIRECTORY_PATH") + // Convert the message body to a JSON string + val messageBody = String(message.body.toBytes()) + val objectMapper: ObjectMapper = jacksonObjectMapper() - if (createReportMessage.uploadId.isNullOrBlank()) { - invalidData.add("uploadId") - } - if (createReportMessage.dataStreamId.isNullOrBlank()) { - invalidData.add("dataStreamId") - } - if (createReportMessage.dataStreamRoute.isNullOrBlank()) { - invalidData.add("dataStreamRoute") - } - if (createReportMessage.stageName.isNullOrBlank()) { - invalidData.add("stageName") - } - if (createReportMessage.contentType.isNullOrBlank()) { - invalidData.add("contentType") - } - if (isNullOrEmpty(createReportMessage.content)) { - invalidData.add("content") - } - if (invalidData.isNotEmpty()) { - reason = "Missing fields: ${invalidData.joinToString(", ")}" + // Check for the presence of `report_schema_version` + try { + val createReportMessage: CreateReportSBMessage = if (System.getProperty("isTestEnvironment") != "true") { + gson.fromJson(messageBody, CreateReportSBMessage::class.java) + } else{ + gson.fromJson(message.body.toString(), CreateReportSBMessage::class.java) + } + + //Convert to JSON + val jsonNode: JsonNode =if (System.getProperty("isTestEnvironment") != "true") { + objectMapper.readTree(messageBody) + } else{ + objectMapper.readTree(message.body.toString()) + } + // Check for the presence of `report_schema_version` + val reportSchemaVersionNode = jsonNode.get("report_schema_version") + if (reportSchemaVersionNode == null || reportSchemaVersionNode.asText().isEmpty()) { + reason ="Report rejected: `report_schema_version` field is missing or empty." + ProcessError(reason,invalidData,createReportMessage) } else { - try { - SchemaDefinition.fromJsonString(createReportMessage.content) - } catch (e: InvalidSchemaDefException) { - reason = "Invalid schema definition: ${e.localizedMessage}" - invalidData.add(reason) - - } catch (e: Exception) { - reason = "Malformed message: ${e.localizedMessage}" - invalidData.add(reason) - //convert content to base64 encoded string - createReportMessage.content = convertToStringOrBase64(createReportMessage.content) + + val reportSchemaVersion = reportSchemaVersionNode.asText() + val schemaFilePath = "$schemaDirectoryPath/base.$reportSchemaVersion.schema.json" + + // Attempt to load the schema + val schemaFile = File(schemaFilePath) + if (!schemaFile.exists()) { + reason ="Report rejected: Schema file not found for base schema version $reportSchemaVersion." + ProcessError(reason,invalidData,createReportMessage) + } + //Validate report schema version schema + ValidateSchema(jsonNode,schemaFile,objectMapper,invalidData,createReportMessage) + + // Check if the content_type is JSON + val contentTypeNode = jsonNode.get("content_type") + if (contentTypeNode == null) { + reason="Report rejected: `content_type` is not JSON or is missing." + ProcessError(reason,invalidData,createReportMessage) + } + + else{ + if(!isJsonMimeType(contentTypeNode.asText())) + { + //don't need to go further down if the mimetype is other than json. i.e. xml or text etc.... + return + } + } + // Open the content as JSON + val contentNode = jsonNode.get("content") + if (contentNode == null) { + reason="Report rejected: `content` is not JSON or is missing." + ProcessError(reason,invalidData,createReportMessage) + } + // Check for `content_schema_name` and `content_schema_version` + val contentSchemaNameNode = contentNode.get("content_schema_name") + val contentSchemaVersionNode = contentNode.get("content_schema_version") + if (contentSchemaNameNode == null || contentSchemaNameNode.asText().isEmpty() || + contentSchemaVersionNode == null || contentSchemaVersionNode.asText().isEmpty() + ) { + reason= "Report rejected: `content_schema_name` or `content_schema_version` is missing or empty." + ProcessError(reason,invalidData,createReportMessage) } - } - 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, - createReportMessage.dataStreamRoute, - createReportMessage.stageName, - createReportMessage.dispositionType, - createReportMessage.contentType, - createReportMessage.content, - invalidData - ) + val contentSchemaName = contentSchemaNameNode.asText() + val contentSchemaVersion = contentSchemaVersionNode.asText() + //TODO Will this be from the same source??? + val contentSchemaFilePath = "$schemaDirectoryPath/$contentSchemaName.$contentSchemaVersion.schema.json" + + // Attempt to load the schema + val contentSchemaFile = File(contentSchemaFilePath) + if (!contentSchemaFile .exists()) { + reason ="Report rejected: Content schema file not found for content schema name $contentSchemaName and schema version $contentSchemaVersion." + ProcessError(reason,invalidData,createReportMessage) + } + //Validate content schema + ValidateSchema(contentNode,contentSchemaFile,objectMapper,invalidData, createReportMessage) + } + + if (invalidData.isNotEmpty()) { + reason ="Validation of the json schema failed" + ProcessError(reason,invalidData,createReportMessage) } - throw BadRequestException(reason) - } - } - /** Function to check whether the value is null or empty based on its type - * @param value Any - */ - private fun isNullOrEmpty(value: Any?): Boolean { - return when (value) { - null -> true - is String -> value.isEmpty() - is Collection<*> -> value.isEmpty() - is Map<*, *> -> value.isEmpty() - else -> false // You can adjust this to your needs + } catch (e: Exception) { + LOGGER.error("Report rejected: Malformed JSON or error processing the report - ${e.message}") + throw e } } - /** - * Convert the malformed content to base64 encoded string - * @param obj Any - */ - private fun convertToStringOrBase64(obj: Any?): String { - val bytes = when (obj) { - is ByteArray -> obj - else -> obj.toString().toByteArray() + companion object { + /** + * Function to send the invalid data reasons to the deadLetter queue + * @param invalidData MutableList + * @param createReportMessage CreateReportSBMessage + */ + @JvmStatic + private fun SendToDeadLetter(invalidData:MutableList, createReportMessage: CreateReportSBMessage){ + 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, + createReportMessage.dataStreamRoute, + createReportMessage.stageName, + createReportMessage.dispositionType, + createReportMessage.contentType, + createReportMessage.content, + invalidData + ) + } + throw BadRequestException(invalidData.joinToString(separator = ",")) + } + } + /** + * Function to process the error by logging it and adding to the invalidData list and sending it to deadletter + * @param reason String + * @param invalidData MutableList + * @param createReportMessage CreateReportSBMessage + */ + @JvmStatic + private fun ProcessError(reason:String, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { + LOGGER.error(reason) + invalidData.add(reason) + SendToDeadLetter(invalidData, createReportMessage) + } + + /** + * Function to validate the schema based on the schema file and the json contents passed into it + * @param schemaFile String + * @param objectMapper ObjectMapper + + */ + @JvmStatic + private fun ValidateSchema(jsonNode:JsonNode,schemaFile:File, objectMapper: ObjectMapper,invalidData:MutableList,createReportMessage: CreateReportSBMessage) { + val schemaNode: JsonNode = objectMapper.readTree(schemaFile) + val schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) + val schema: JsonSchema = schemaFactory.getSchema(schemaNode) + val schemaValidationMessages: Set = schema.validate(jsonNode) + + if (schemaValidationMessages.isEmpty()) { + LOGGER.info("JSON is valid against the content schema $schema.") + } else { + val reason ="JSON is invalid against the content schema $schema.Errors:" + schemaValidationMessages.forEach { invalidData.add(it.message) } + ProcessError(reason, invalidData,createReportMessage) + } + } + @JvmStatic + private fun isJsonMimeType(contentType: String): Boolean { + return try { + val mimeType = MimeType(contentType) + mimeType.primaryType == "application" && mimeType.subType == "json" + } catch (e: MimeTypeParseException) { + false + } } - return Base64.getEncoder().encodeToString(bytes) } } \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-invalid.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-invalid.json new file mode 100644 index 00000000..7ff6974c --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-invalid.json @@ -0,0 +1,53 @@ +{ + "upload_id": "319101ba2fd7835983f3257713819f7b", + "user_id": null, + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "message_metadata": null, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": null, + "data": null, + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "DEX HL7v2 RECEIVER", + "content_schema_version": "2.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-valid.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-valid.json new file mode 100644 index 00000000..68d6441b --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-valid.json @@ -0,0 +1,84 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "status": "SUCCESS", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "hl7v2-debatch", + "content_schema_version": "1.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/base.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/base.1.0.0.schema.json new file mode 100644 index 00000000..3ce6a91e --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/base.1.0.0.schema.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/base", + "title": "Base Report", + "type": "object", + "required": ["report_schema_version", "upload_id", "data_stream_id", "data_stream_route", "dex_ingest_timestamp", "sender_id", "status", "stage_info", "content_type"], + "if": { + "properties": { + "content_type": { + "const": "json" + } + } + }, + "then": { + "properties": { + "content": { + "$ref": "#/$defs/jsonContent" + } + } + }, + "properties": { + "report_schema_version": { + "type": "string" + }, + "upload_id": { + "$ref": "#/$defs/uuid", + "description": "Unique upload identifier associated with this report." + }, + "user_id": { + "type": "string", + "description": "User or system id that uploaded the file, not the provider of this report." + }, + "data_stream_id": { + "type": "string" + }, + "data_stream_route": { + "type": "string" + }, + "jurisdiction": { + "type": "string" + }, + "sender_id": { + "type": "string" + }, + "data_producer_id": { + "type": "string" + }, + "dex_ingest_timestamp": { + "$ref": "#/$defs/timestamp" + }, + "message_metadata": { + "$ref": "#/$defs/messageMetadata", + "description": "Metadata associated with the message this report belong to." + }, + "stage_info": { + "$ref": "#/$defs/stageInfo", + "description": "Describes the stage that is providing this report." + }, + "tags": { + "$ref": "#/$defs/keyValueMap", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "#/$defs/keyValueMap", + "description": "Optional data associated with this report." + }, + "content_type": { + "type": "string" + } + }, + "$defs": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "string", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + } + }, + "stageInfo": { + "service": { + "type": "string", + "description": "Name of the service associated with this report." + }, + "stage": { + "type": "string", + "description": "Action the stage was conducting when providing this report." + }, + "version": { + "type": "string", + "description": "Version of the stage providing this report" + }, + "status": { + "type": "string", + "enum": ["SUCCESS", "FAILURE"] + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "WARNING", + "ERROR" + ] + }, + "message": { + "type": "string" + } + } + } + }, + "start_processing_time": { + "$ref": "#/$defs/timestamp" + }, + "end_processing_time": { + "$ref": "#/$defs/timestamp" + } + }, + "keyValueMap": { + "type" : "object", + "existingJavaType" : "java.util.Map" + }, + "jsonContent": { + "type": "object", + "properties": { + "content_schema_name": { + "type": "string" + }, + "content_schema_version": { + "type": "string" + } + } + } + } +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-debatch.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-debatch.1.0.0.schema.json new file mode 100644 index 00000000..13c9958a --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-debatch.1.0.0.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/hl7-receiver-report", + "title": "HL7v2 Receiver Schema", + "type": "object", + "properties": { + "content_schema_name": { + "type": "string", + "const": "hl7v2-debatch" + }, + "content_schema_version": { + "type": "string", + "const": "1.0.0" + }, + + "report": { + "type": "object", + "properties": { + "ingested_file_path": { + "type": "string" + }, + "ingested_file_timestamp": { + "type": "string" + }, + "ingested_file_size": { + "type": "integer" + }, + "received_filename": { + "type": "string" + }, + "supporting_metadata": { + "$ref": "#/$defs/keyValueMap", + "description": "Extra metadata provided by the sender" + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"] + }, + "number_of_messages": { + "type": "integer" + }, + "number_of_messages_not_propagated": { + "type": "integer" + }, + "error_messages": { + "type": "array", + "items": { + "$ref": "#/$defs/ingest_error" + } + } + } + } +}, + "$defs": { + "keyValueMap": { + "type" : "object", + "existingJavaType" : "java.util.Map" + }, + "ingest_error": { + "type": "object", + "properties": { + "message_uuid": { + "type": "string" + }, + "message_index": { + "type": "integer" + }, + "error_message": { + "type": "string" + } + } + } + } +} + diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_contentType_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_contentType_validation.json new file mode 100644 index 00000000..3f4eb248 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_contentType_validation.json @@ -0,0 +1,53 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": null, + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "message_metadata": null, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": null, + "data": null, + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "DEX HL7v2 RECEIVER", + "content_schema_version": "2.0.0" + }, + "report_schema_version": "1.0.0" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_content_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_content_validation.json new file mode 100644 index 00000000..54546c40 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_content_validation.json @@ -0,0 +1,53 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "status": "SUCCESS", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_dataStreamId_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_dataStreamId_validation.json new file mode 100644 index 00000000..aa3641f1 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_dataStreamId_validation.json @@ -0,0 +1,83 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "status": "SUCCESS", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "hl7v2-debatch", + "content_schema_version": "1.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_dataStreamRoute_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_dataStreamRoute_validation.json new file mode 100644 index 00000000..54762b32 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_dataStreamRoute_validation.json @@ -0,0 +1,83 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_id": "celr", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "status": "SUCCESS", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "hl7v2-debatch", + "content_schema_version": "1.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_schemaName_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_schemaName_validation.json new file mode 100644 index 00000000..70ea4b61 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_schemaName_validation.json @@ -0,0 +1,83 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "status": "SUCCESS", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_version": "1.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_schemaVersion_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_schemaVersion_validation.json new file mode 100644 index 00000000..76c09444 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_schemaVersion_validation.json @@ -0,0 +1,83 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "status": "SUCCESS", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "hl7v2-debatch" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_stageInfo_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_stageInfo_validation.json new file mode 100644 index 00000000..9007ea06 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_stageInfo_validation.json @@ -0,0 +1,76 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "status": "SUCCESS", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "hl7v2-debatch", + "content_schema_version": "1.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_uploadId_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_uploadId_validation.json new file mode 100644 index 00000000..7262e51c --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_missing_uploadId_validation.json @@ -0,0 +1,53 @@ +{ + + "user_id": null, + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "message_metadata": null, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": null, + "data": null, + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "DEX HL7v2 RECEIVER", + "content_schema_version": "2.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_validation_pass.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_validation_pass.json new file mode 100644 index 00000000..68d6441b --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_validation_pass.json @@ -0,0 +1,84 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "status": "SUCCESS", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "hl7v2-debatch", + "content_schema_version": "1.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt new file mode 100644 index 00000000..62246a7a --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt @@ -0,0 +1,142 @@ + +package test + + +import com.microsoft.azure.functions.ExecutionContext +import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager +import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException +import gov.cdc.ocio.processingstatusapi.plugins.ServiceBusProcessor +import io.mockk.* +import org.mockito.Mockito +import org.testng.Assert +import org.testng.annotations.BeforeMethod +import org.testng.annotations.Test +import java.io.File + +class ReportSchemaValidationTests { + + private lateinit var context: ExecutionContext + + @BeforeMethod + fun setUp() { + context = Mockito.mock(ExecutionContext::class.java) + mockkObject(CosmosContainerManager) + every { CosmosContainerManager.initDatabaseContainer(any(), any(),any(), any()) } returns null + + } + + @Test + fun testReportSchemaValidationMissingUploadId() { + val testMessage =File("./src/test/kotlin/data/report_schema_missing_uploadId_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + // Mock file + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("$.upload_id: is missing but it is required") + } + Assert.assertTrue(exceptionThrown) + } + @Test + fun testReportSchemaValidationMissingDataStreamId() { + val testMessage =File("./src/test/kotlin/data/report_schema_missing_dataStreamId_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("$.data_stream_id: is missing but it is required") + } + Assert.assertTrue(exceptionThrown) + } + @Test + fun testReportSchemaValidationMissingRoute() { + val testMessage =File("./src/test/kotlin/data/report_schema_missing_dataStreamRoute_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("$.data_stream_route: is missing but it is required") + } + Assert.assertTrue(exceptionThrown) + } + @Test + fun testReportSchemaValidationMissingStageInfo() { + val testMessage =File("./src/test/kotlin/data/report_schema_missing_stageInfo_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("$.stage_info: is missing but it is required") + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testReportSchemaValidationMissingContentType() { + val testMessage =File("./src/test/kotlin/data/report_schema_missing_contentType_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("$.content_type: is missing but it is required") + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testReportSchemaValidationMissingContent() { + val testMessage =File("./src/test/kotlin/data/report_schema_missing_content_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("Report rejected: `content` is not JSON or is missing.") + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testReportSchemaValidationMissingSchemaName() { + val testMessage =File("./src/test/kotlin/data/report_schema_missing_schemaName_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("Report rejected: `content_schema_name` or `content_schema_version` is missing or empty.") + } + Assert.assertTrue(exceptionThrown) + } + + @Test + fun testReportSchemaValidationMissingSchemaVersion() { + val testMessage =File("./src/test/kotlin/data/report_schema_missing_schemaVersion_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("Report rejected: `content_schema_name` or `content_schema_version` is missing or empty.") + } + Assert.assertTrue(exceptionThrown) + } + @Test + fun testReportSchemaValidationPass() { + val testMessage =File("./src/test/kotlin/data/report_schema_validation_pass.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } + + +} + + + + From b7015293c838ca713144eac8970edd34dbd19289 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 17 Jul 2024 17:13:26 -0400 Subject: [PATCH 52/91] Report schema validation logic Unit tests --- ...chema_contentSchemaVersion_validation.json | 84 +++++++++++++++++++ .../test/ReportSchemaValidationTests.kt | 13 +++ 2 files changed, 97 insertions(+) create mode 100644 pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_contentSchemaVersion_validation.json diff --git a/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_contentSchemaVersion_validation.json b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_contentSchemaVersion_validation.json new file mode 100644 index 00000000..e6629c11 --- /dev/null +++ b/pstatus-report-sink-ktor/src/test/kotlin/data/report_schema_contentSchemaVersion_validation.json @@ -0,0 +1,84 @@ +{ + "upload_id": "fe85fdb7-2f07-4ad1-b73a-646cc2e25bbc", + "user_id": "test-event1", + "data_stream_id": "celr", + "data_stream_route": "hl7_out_recdeb", + "jurisdiction": "SMOKE", + "sender_id": "APHL", + "data_producer_id": "smoke-test-data-producer", + "dex_ingest_timestamp": "2024-07-10T15:40:01Z", + "status": "SUCCESS", + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "UUID", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + + } + }, + "stage_info": { + "service": "HL7v2 Pipeline", + "stage": "RECEIVER", + "version": "0.0.49-SNAPSHOT", + "status": "SUCCESS", + "issues": null, + "start_processing_time": "2024-07-10T15:40:10.162+00:00", + "end_processing_time": "2024-07-10T15:40:10.228+00:00" + }, + "tags": { + "$ref": "test-ref", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "keyValueMap", + "description": "Optional data associated with this report." + }, + + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "hl7v2-debatch", + "content_schema_version": "2.0.0" + }, + "report_schema_version": "1.0.0", + "content_type": "application/json" + +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt index 62246a7a..584271ea 100644 --- a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt +++ b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt @@ -127,6 +127,19 @@ class ReportSchemaValidationTests { } Assert.assertTrue(exceptionThrown) } + @Test + fun testReportContentSchemaValidationFileNotFound() { + val testMessage =File("./src/test/kotlin/data/report_schema_contentSchemaVersion_validation.json").readText() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + var exceptionThrown = false + try { + ServiceBusProcessor().withMessage(serviceBusReceivedMessage) + } catch(ex: BadRequestException) { + exceptionThrown = ex.localizedMessage.contains("Report rejected: Content schema file not found for content schema name hl7v2-debatch and schema version 2.0.0.") + } + Assert.assertTrue(exceptionThrown) + } + @Test fun testReportSchemaValidationPass() { val testMessage =File("./src/test/kotlin/data/report_schema_validation_pass.json").readText() From ab091ca0c69003314baac179f341e0c4563e52ee Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Thu, 18 Jul 2024 09:55:19 -0500 Subject: [PATCH 53/91] DASB-550 - Made so report content is generic when retrieving reports, consolidated code (#129) * Made so report content is generic when retrieving reports, consolidated code * Added missing class header --------- Co-authored-by: Matt B Krystof --- .../loaders/ReportDeadLetterLoader.kt | 36 ++++-------- .../loaders/ReportLoader.kt | 30 ++-------- .../ocio/processingstatusapi/models/Report.kt | 5 +- .../models/ReportDeadLetter.kt | 35 +---------- .../models/dao/ReportDao.kt | 58 +++++++++++-------- 5 files changed, 54 insertions(+), 110 deletions(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt index e11e4a8f..f889db4c 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -5,9 +5,10 @@ import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao import gov.cdc.ocio.processingstatusapi.utils.SqlClauseBuilder import java.text.SimpleDateFormat -import java.time.ZoneOffset import java.util.* import mu.KotlinLogging + + /** * Class for generating reporting queries from cosmos db container which is then wrapped in a graphQl query service */ @@ -26,7 +27,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { ) val reports = mutableListOf() - reportItems?.forEach { reports.add(daoToReport(it)) } + reportItems?.forEach { reports.add(it.toReport() as ReportDeadLetter) } return reports } @@ -53,13 +54,14 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { ) val reports = mutableListOf() - reportItems?.forEach { reports.add(daoToReport(it)) } + reportItems?.forEach { reports.add(it.toReport() as ReportDeadLetter) } return reports } /** - * Function which returns count of ReportDeadLetter items based on the specified parameters + * Function which returns count of ReportDeadLetter items based on the specified parameters. + * * @param dataStreamId String * @param dataStreamRoute String? * @param startDate String @@ -85,7 +87,10 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { } /** + * Search the report deadletters by report id. * + * @param ids List + * @return List */ fun search(ids: List): List { val quotedIds = ids.joinToString("\",\"", "\"", "\"") @@ -97,36 +102,17 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { ReportDao::class.java ) val reports = mutableListOf() - reportItems?.forEach { reports.add(daoToReport(it)) } + reportItems?.forEach { reports.add(it.toReport() as ReportDeadLetter) } return reports } - /** - * Function which converts cosmos data object to Report obhect - * @param reportDao ReportDao - */ - private fun daoToReport(reportDao: ReportDao): ReportDeadLetter { - return ReportDeadLetter().apply { - this.id = reportDao.id - this.uploadId = reportDao.uploadId - this.reportId = reportDao.reportId - this.dataStreamId = reportDao.dataStreamId - this.dataStreamRoute = reportDao.dataStreamRoute - this.messageId = reportDao.messageId - this.status = reportDao.status - this.timestamp = reportDao.timestamp?.toInstant()?.atOffset(ZoneOffset.UTC) - this.contentType = reportDao.contentType - this.content = reportDao.contentAsType - } - } - /** * Function which converts the inputted date to expected date format * @param inputDate String */ private fun getFormattedDateAsString(inputDate:String?):String?{ - if(inputDate == null) return null + if (inputDate == null) return null val inputDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") val outputDateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'") val date: Date = inputDateFormat.parse(inputDate) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt index df26e108..3745a8e7 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt @@ -7,11 +7,13 @@ import gov.cdc.ocio.processingstatusapi.models.DataStream import graphql.schema.DataFetchingEnvironment import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* -import java.time.ZoneOffset class ForbiddenException(message: String) : RuntimeException(message) +/** + * Report loader for graphql + */ class ReportLoader: CosmosLoader() { /** @@ -40,7 +42,7 @@ class ReportLoader: CosmosLoader() { // Convert the report DAOs to reports and ensure the user has access to them. val reports = mutableListOf() reportItems?.forEach { reportItem -> - val report = daoToReport(reportItem) + val report = reportItem.toReport() dataStreams?.run { if (dataStreams?.firstOrNull { ds -> ds.name == report.dataStreamId && ds.route == report.dataStreamRoute } == null) throw ForbiddenException("You are not allowed to access this resource.") @@ -68,30 +70,8 @@ class ReportLoader: CosmosLoader() { ) val reports = mutableListOf() - reportItems?.forEach { reports.add(daoToReport(it)) } + reportItems?.forEach { reports.add(it.toReport()) } return reports } - - /** - * Converts a Report DAO (as received by CosmosDB) into a Report object. - * - * @param reportDao ReportDao - * @return Report - */ - private fun daoToReport(reportDao: ReportDao): Report { - return Report().apply { - this.id = reportDao.id - this.uploadId = reportDao.uploadId - this.reportId = reportDao.reportId - this.dataStreamId = reportDao.dataStreamId - this.dataStreamRoute = reportDao.dataStreamRoute - this.stageName = reportDao.stageName - this.messageId = reportDao.messageId - this.status = reportDao.status - this.timestamp = reportDao.timestamp?.toInstant()?.atOffset(ZoneOffset.UTC) - this.contentType = reportDao.contentType - this.content = reportDao.contentAsType - } - } } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt index 9adddc09..67892547 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt @@ -1,7 +1,6 @@ package gov.cdc.ocio.processingstatusapi.models import com.expediagroup.graphql.generator.annotations.GraphQLDescription -import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition import java.time.OffsetDateTime /** @@ -19,7 +18,7 @@ import java.time.OffsetDateTime * @property timestamp OffsetDateTime */ @GraphQLDescription("Contains Report content.") -data class Report( +open class Report( @GraphQLDescription("Identifier of the report recorded by the database") var id : String? = null, @@ -49,7 +48,7 @@ data class Report( var status : String? = null, @GraphQLDescription("Content of the report. If the report is JSON then the content will be shown as JSON. Otherwise, the content is a base64 encoded string.") - var content: SchemaDefinition? = null, + var content : Map<*, *>? = null, @GraphQLDescription("Datestamp the report was recorded in the database") var timestamp: OffsetDateTime? = null diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt index b5098820..8acb8d69 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt @@ -1,8 +1,6 @@ package gov.cdc.ocio.processingstatusapi.models import com.expediagroup.graphql.generator.annotations.GraphQLDescription -import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition -import java.time.OffsetDateTime /** * Report for a given stage. @@ -18,35 +16,4 @@ import java.time.OffsetDateTime * @property timestamp OffsetDateTime */ @GraphQLDescription("Contains Report DeadLetter content.") -data class ReportDeadLetter ( - - @GraphQLDescription("Identifier of the report recorded by the database") - var id : String? = null, - - @GraphQLDescription("Upload identifier this report belongs to") - var uploadId: String? = null, - - @GraphQLDescription("Unique report identifier") - var reportId: String? = null, - - @GraphQLDescription("Data stream ID") - var dataStreamId: String? = null, - - @GraphQLDescription("Data stream route") - var dataStreamRoute: String? = null, - - @GraphQLDescription("Indicates the content type of the content; e.g. JSON, XML") - var contentType : String? = null, - - @GraphQLDescription("Message id this report belongs to; set to null if not applicable") - var messageId: String? = null, - - @GraphQLDescription("Status this report is indicating, such as success or failure") - var status : String? = null, - - @GraphQLDescription("Content of the report. If the report is JSON then the content will be shown as JSON. Otherwise, the content is a base64 encoded string.") - var content: SchemaDefinition? = null, - - @GraphQLDescription("Datestamp the report was recorded in the database") - var timestamp: OffsetDateTime? = null -) \ No newline at end of file +class ReportDeadLetter: Report() \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDao.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDao.kt index a758f5f6..be2af221 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDao.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDao.kt @@ -2,11 +2,27 @@ package gov.cdc.ocio.processingstatusapi.models.dao import com.google.gson.Gson import com.google.gson.annotations.SerializedName -import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition -import gov.cdc.ocio.processingstatusapi.models.reports.MetadataVerifyContent -import gov.cdc.ocio.processingstatusapi.models.reports.UploadStatusContent +import gov.cdc.ocio.processingstatusapi.models.Report +import java.time.ZoneOffset import java.util.* +/** + * Data access object for reports, which is the structure returned from CosmosDB queries. + * + * @property id String? + * @property uploadId String? + * @property reportId String? + * @property dataStreamId String? + * @property dataStreamRoute String? + * @property stageName String? + * @property contentType String? + * @property messageId String? + * @property status String? + * @property timestamp Date? + * @property content Any? + * @property contentAsString String? + * @constructor + */ data class ReportDao( var id : String? = null, @@ -39,26 +55,6 @@ data class ReportDao( var content: Any? = null ) { - val contentAsType: SchemaDefinition? - get() { - if (content == null) return null - - return when (contentType?.lowercase(Locale.getDefault())) { - "json" -> { - if (content is LinkedHashMap<*, *>) { - val json = Gson().toJson(content, MutableMap::class.java).toString() - when ((content as LinkedHashMap<*, *>)["schema_name"]) { - "dex-metadata-verify" -> return Gson().fromJson(json, MetadataVerifyContent::class.java) - "upload" -> return Gson().fromJson(json, UploadStatusContent::class.java) - else -> return null - } - } else - null // unable to determine a JSON schema - } - - else -> null // unable to determine a JSON schema - } - } val contentAsString: String? get() { @@ -74,4 +70,20 @@ data class ReportDao( else -> content.toString() } } + + /** + * Convenience function to convert a cosmos data object to a Report object + */ + fun toReport() = Report().apply { + this.id = this@ReportDao.id + this.uploadId = this@ReportDao.uploadId + this.reportId = this@ReportDao.reportId + this.dataStreamId = this@ReportDao.dataStreamId + this.dataStreamRoute = this@ReportDao.dataStreamRoute + this.messageId = this@ReportDao.messageId + this.status = this@ReportDao.status + this.timestamp = this@ReportDao.timestamp?.toInstant()?.atOffset(ZoneOffset.UTC) + this.contentType = this@ReportDao.contentType + this.content = this@ReportDao.content as? Map<*, *> + } } \ No newline at end of file From 3bbb0efe3e10d6fcbacc86b2f21a993b34208308 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 11:09:02 -0400 Subject: [PATCH 54/91] Added all the schemas from Readme Replaced test environment to false. Change to true when running tests. --- pstatus-report-sink-ktor/build.gradle | 2 +- .../schema/blob-file-copy.1.0.0.schema.json | 28 ++++++ .../buzz-file-capture.1.0.0.schema.json | 86 +++++++++++++++++++ ...v2-json-lake-transformer.1.0.0.schema.json | 23 +++++ ...ake-segments-transformer.1.0.0.schema.json | 22 +++++ .../schema/hl7v2-redact.1.0.0.schema.json | 49 +++++++++++ ...7v2-structure-validation.1.0.0.schema.json | 69 +++++++++++++++ .../schema/metadata-verify.1.0.0.schema.json | 32 +++++++ .../schema/upload-status.1.0.0.schema.json | 33 +++++++ 9 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json create mode 100644 pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index af48b00b..d3744a38 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -94,7 +94,7 @@ test { events "passed", "skipped", "failed" } //Change this to "true" if we want to execute unit tests - systemProperty("isTestEnvironment", "true") + systemProperty("isTestEnvironment", "false") // Set the test classpath, if required } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json new file mode 100644 index 00000000..2cae7805 --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/blob-file-copy", + "title": "Blob File Copy Report", + "type": "object", + "required": ["content_schema_name", "content_schema_version", "file_source_blob_url", "file_destination_blob_url"], + "properties": { + "content_schema_name": { + "type": "string" + }, + "content_schema_version": { + "type": "string" + }, + "file_source_blob_url": { + "type": "string", + "description": "URL of the source blob file to be copied." + }, + "file_destination_blob_url": { + "type": "string", + "description": "URL of destination blob file." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the file copy was executed." + } + } +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json new file mode 100644 index 00000000..b44df10b --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/buzz-file-capture", + "title": "Buzz File Capture", + "type": "object", + "required": ["content_schema_name", "content_schema_version", "source", "name", "id", "src_url", "date_created", "date_modified", "user_email", "parent_name", "parent_id", "mime"], + "properties": { + "content_schema_name": { + "type": "string" + }, + "content_schema_version": { + "type": "string" + }, + "source": { + "type": "string", + "description": "Source of the content." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Timestamp of the content." + }, + "name": { + "type": "string", + "description": "Name of the file." + }, + "id": { + "type": "string", + "description": "Unique identifier for the file." + }, + "src_url": { + "type": "string", + "format": "uri", + "description": "Source URL of the file." + }, + "date_created": { + "type": "string", + "format": "date-time", + "description": "Creation date of the file." + }, + "date_modified": { + "type": "string", + "format": "date-time", + "description": "Last modification date of the file." + }, + "user_email": { + "type": "string", + "format": "email", + "description": "Email of the user associated with the file." + }, + "parent_name": { + "type": "string", + "description": "Name of the parent directory or REDCap project." + }, + "parent_id": { + "type": "string", + "description": "Unique identifier of the parent directory or REDCap project." + }, + "file_size": { + "type": ["integer", "null"], + "description": "Size of the file in bytes." + }, + "mime": { + "type": "string", + "description": "MIME type of the file." + }, + "fingerprints": { + "type": ["array", "null"], + "items": { + "type": "object", + "required": ["algo", "hash"], + "properties": { + "algo": { + "type": "string", + "description": "Algorithm used for the hash." + }, + "hash": { + "type": "string", + "description": "Hash value of the file." + } + } + }, + "description": "Optional list of fingerprints associated with the file." + } + } +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json new file mode 100644 index 00000000..1feff7ca --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/base", + "title": "HL7v2 JSON Lake Transformer Report", + "type": "object", + "required": ["content_schema_name", "content_schema_version"], + "properties": { + "content_schema_name": { + "type": "string", + "const": "hl7v2-json-lake-transformer" + }, + "content_schema_version": { + "type": "string", + "const": "1.0.0" + }, + "configs": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json new file mode 100644 index 00000000..1357e0ff --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HL7 v2 Message Schema", + "type": "object", + "properties": { + "content_schema_name": { + "const": "hl7v2-lake-segments-transformer" + }, + "content_schema_version": { + "type": "string", + "const": "1.0.0" + }, + + "configs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["content_schema_name", "content_schema_version"] +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json new file mode 100644 index 00000000..9f19abf7 --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/hl7-redaction-report", + "title": "HL7v2 Redactor Schema", + "type": "object", + "properties": { + "content_schema_name": { + "type": "string", + "const": "hl7v2-redact" + }, + "content_schema_version": { + "type": "string", + "const": "1.0.0" + }, + "report": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "rule": { + "type": "string" + }, + "lineNumber": { + "type": "integer" + }, + "fieldIndex": { + "type": "integer" + } + }, + "required": ["path", "rule", "lineNumber"] + } + } + } + }, + "configs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["content_schema_name", "content_schema_version", "report", "configs"] +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json new file mode 100644 index 00000000..cec31bd2 --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/hl7-validation-report", + "title": "HL7v2 Validation Report Schema", + "type": "object", + "properties": { + "content_schema_name": { + "type" : "string", + "const": "hl7v2-structure-validation" + }, + "content_schema_version": {"type": "string", "const": "1.0.0"}, + "report": { + "type": "object", + "properties": { + "entries": { + "structure": {"$ref": "#/$defs/reportItem"}, + "content" : {"$ref": "#/$defs/reportItem"}, + "value-set": {"$ref": "#/$defs/reportItem"} + }, + "error-count": { + "type": "object", + "properties": { + "structure": {"type": "integer"}, + "value-set": {"type": "integer"}, + "content" : {"type": "integer"} + }, + "required": ["structure", "value-set", "content"] + }, + "warning-count": { + "type": "object", + "properties": { + "structure": {"type": "integer"}, + "value-set": {"type": "integer"}, + "content" : {"type": "integer"} + }, + "required": ["structure", "value-set", "content"] + }, + "status": {"type": "string"} + }, + "required": ["entries", "error-count", "warning-count", "status"] + }, + "configs": { + "type": "array", + "items": {"type": "string"} + } + }, + "$defs": { + "reportItem": { + "type": "object", + "properties": { + "structure": { + "type": "array", + "items": { + "type": "object", + "properties": { + "line" : { "type": "integer" }, + "column" : { "type": "integer" }, + "path" : { "type": "string" }, + "description" : { "type": "string" }, + "category" : { "type": "string" }, + "classification": { "type": "string" }, + "stackTrace" : { "type": ["null", "array"] } + } + } + } + } + } + } +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json new file mode 100644 index 00000000..bf13fd9c --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/base", + "title": "Metadata Verify Report", + "type": "object", + "required": ["content_schema_name", "content_schema_version", "filename", "metadata"], + "properties": { + "content_schema_name": { + "type": "string" + }, + "content_schema_version": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "metadata": { + "$ref": "#/$defs/metadataMap" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the metadata verification step was executed." + } + }, + "$defs": { + "metadataMap": { + "type" : "object", + "existingJavaType" : "java.util.Map" + } + } +} \ No newline at end of file diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json new file mode 100644 index 00000000..ad80594d --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/base", + "title": "Upload Status Report", + "type": "object", + "required": ["schema_name", "schema_version", "tguid", "offset", "size", "filename"], + "properties": { + "schema_name": { + "type": "string" + }, + "schema_version": { + "type": "string" + }, + "tguid": { + "$ref": "#/$defs/uuid" + }, + "offset": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "filename": { + "type": "string" + } + }, + "$defs": { + "uuid": { + "type": "string", + "format": "uuid" + } + } +} \ No newline at end of file From ee4dd347df3a9c6b0c44712a63560fb2b06e8c20 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 12:21:01 -0400 Subject: [PATCH 55/91] Changed schema to https://json-schema.org/draft-07/schema --- .../plugins/schema/blob-file-copy.1.0.0.schema.json | 2 +- .../plugins/schema/buzz-file-capture.1.0.0.schema.json | 2 +- .../schema/hl7v2-json-lake-transformer.1.0.0.schema.json | 2 +- .../schema/hl7v2-lake-segments-transformer.1.0.0.schema.json | 2 +- .../plugins/schema/hl7v2-redact.1.0.0.schema.json | 2 +- .../plugins/schema/hl7v2-structure-validation.1.0.0.schema.json | 2 +- .../plugins/schema/metadata-verify.1.0.0.schema.json | 2 +- .../plugins/schema/upload-status.1.0.0.schema.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json index 2cae7805..729f85d2 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "https://json-schema.org/draft-07/schema", "$id": "https://github.com/cdcent/data-exchange-messages/reports/blob-file-copy", "title": "Blob File Copy Report", "type": "object", diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json index b44df10b..69a66042 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "https://json-schema.org/draft-07/schema", "$id": "https://github.com/cdcent/data-exchange-messages/reports/buzz-file-capture", "title": "Buzz File Capture", "type": "object", diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json index 1feff7ca..afd438f6 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "https://json-schema.org/draft-07/schema", "$id": "https://github.com/cdcent/data-exchange-messages/reports/base", "title": "HL7v2 JSON Lake Transformer Report", "type": "object", diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json index 1357e0ff..bd76c7a8 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "http://json-schema.org/draft-07/schema", "title": "HL7 v2 Message Schema", "type": "object", "properties": { diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json index 9f19abf7..d0cbf644 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "https://json-schema.org/draft-07/schema", "$id": "https://github.com/cdcent/data-exchange-messages/reports/hl7-redaction-report", "title": "HL7v2 Redactor Schema", "type": "object", diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json index cec31bd2..e770c00d 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "https://json-schema.org/draft-07/schema", "$id": "https://github.com/cdcent/data-exchange-messages/reports/hl7-validation-report", "title": "HL7v2 Validation Report Schema", "type": "object", diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json index bf13fd9c..fcd303dd 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "https://json-schema.org/draft-07/schema", "$id": "https://github.com/cdcent/data-exchange-messages/reports/base", "title": "Metadata Verify Report", "type": "object", diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json index ad80594d..d55b2863 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "https://json-schema.org/draft-07/schema", "$id": "https://github.com/cdcent/data-exchange-messages/reports/base", "title": "Upload Status Report", "type": "object", From c38c12340f064543527177b34d8a78d656e5961d Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 12:54:28 -0400 Subject: [PATCH 56/91] Changed the createServiceBusReceivedMessageFromString to createServiceBusReceivedMessageFromBinary so that we don't have to use conditions to check for test env property based conversions --- .../plugins/ServiceBusProcessor.kt | 22 ++------- .../test/ReportSchemaValidationTests.kt | 41 ++++++++-------- .../src/test/kotlin/test/ServiceBusTests.kt | 48 +++++++++---------- 3 files changed, 49 insertions(+), 62 deletions(-) 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 b0b7ec65..9799543b 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 @@ -48,7 +48,8 @@ class ServiceBusProcessor { fun withMessage(message: ServiceBusReceivedMessage) { validateJsonSchema(message) val sbMessageId = message.messageId - var sbMessage = message.body.toString() + //var sbMessage = message.body.toString() + var sbMessage =String(message.body.toBytes()) val sbMessageStatus = message.state.name try { logger.info { "Before Message received = $sbMessage" } @@ -116,21 +117,12 @@ class ServiceBusProcessor { // Convert the message body to a JSON string val messageBody = String(message.body.toBytes()) val objectMapper: ObjectMapper = jacksonObjectMapper() - // Check for the presence of `report_schema_version` try { - val createReportMessage: CreateReportSBMessage = if (System.getProperty("isTestEnvironment") != "true") { - gson.fromJson(messageBody, CreateReportSBMessage::class.java) - } else{ - gson.fromJson(message.body.toString(), CreateReportSBMessage::class.java) - } + val createReportMessage: CreateReportSBMessage = gson.fromJson(messageBody, CreateReportSBMessage::class.java) //Convert to JSON - val jsonNode: JsonNode =if (System.getProperty("isTestEnvironment") != "true") { - objectMapper.readTree(messageBody) - } else{ - objectMapper.readTree(message.body.toString()) - } + val jsonNode: JsonNode =objectMapper.readTree(messageBody) // Check for the presence of `report_schema_version` val reportSchemaVersionNode = jsonNode.get("report_schema_version") if (reportSchemaVersionNode == null || reportSchemaVersionNode.asText().isEmpty()) { @@ -140,7 +132,6 @@ class ServiceBusProcessor { val reportSchemaVersion = reportSchemaVersionNode.asText() val schemaFilePath = "$schemaDirectoryPath/base.$reportSchemaVersion.schema.json" - // Attempt to load the schema val schemaFile = File(schemaFilePath) if (!schemaFile.exists()) { @@ -149,14 +140,12 @@ class ServiceBusProcessor { } //Validate report schema version schema ValidateSchema(jsonNode,schemaFile,objectMapper,invalidData,createReportMessage) - // Check if the content_type is JSON val contentTypeNode = jsonNode.get("content_type") if (contentTypeNode == null) { reason="Report rejected: `content_type` is not JSON or is missing." ProcessError(reason,invalidData,createReportMessage) } - else{ if(!isJsonMimeType(contentTypeNode.asText())) { @@ -164,7 +153,7 @@ class ServiceBusProcessor { return } } - // Open the content as JSON + // Open the content as JSON val contentNode = jsonNode.get("content") if (contentNode == null) { reason="Report rejected: `content` is not JSON or is missing." @@ -184,7 +173,6 @@ class ServiceBusProcessor { val contentSchemaVersion = contentSchemaVersionNode.asText() //TODO Will this be from the same source??? val contentSchemaFilePath = "$schemaDirectoryPath/$contentSchemaName.$contentSchemaVersion.schema.json" - // Attempt to load the schema val contentSchemaFile = File(contentSchemaFilePath) if (!contentSchemaFile .exists()) { diff --git a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt index 584271ea..9956a25d 100644 --- a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt +++ b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt @@ -1,7 +1,6 @@ package test - import com.microsoft.azure.functions.ExecutionContext import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException @@ -27,8 +26,8 @@ class ReportSchemaValidationTests { @Test fun testReportSchemaValidationMissingUploadId() { - val testMessage =File("./src/test/kotlin/data/report_schema_missing_uploadId_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_missing_uploadId_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { // Mock file @@ -40,8 +39,8 @@ class ReportSchemaValidationTests { } @Test fun testReportSchemaValidationMissingDataStreamId() { - val testMessage =File("./src/test/kotlin/data/report_schema_missing_dataStreamId_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_missing_dataStreamId_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -52,8 +51,8 @@ class ReportSchemaValidationTests { } @Test fun testReportSchemaValidationMissingRoute() { - val testMessage =File("./src/test/kotlin/data/report_schema_missing_dataStreamRoute_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_missing_dataStreamRoute_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -64,8 +63,8 @@ class ReportSchemaValidationTests { } @Test fun testReportSchemaValidationMissingStageInfo() { - val testMessage =File("./src/test/kotlin/data/report_schema_missing_stageInfo_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_missing_stageInfo_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { @@ -78,8 +77,8 @@ class ReportSchemaValidationTests { @Test fun testReportSchemaValidationMissingContentType() { - val testMessage =File("./src/test/kotlin/data/report_schema_missing_contentType_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_missing_contentType_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -91,8 +90,8 @@ class ReportSchemaValidationTests { @Test fun testReportSchemaValidationMissingContent() { - val testMessage =File("./src/test/kotlin/data/report_schema_missing_content_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_missing_content_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -104,8 +103,8 @@ class ReportSchemaValidationTests { @Test fun testReportSchemaValidationMissingSchemaName() { - val testMessage =File("./src/test/kotlin/data/report_schema_missing_schemaName_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_missing_schemaName_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -117,8 +116,8 @@ class ReportSchemaValidationTests { @Test fun testReportSchemaValidationMissingSchemaVersion() { - val testMessage =File("./src/test/kotlin/data/report_schema_missing_schemaVersion_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_missing_schemaVersion_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -129,8 +128,8 @@ class ReportSchemaValidationTests { } @Test fun testReportContentSchemaValidationFileNotFound() { - val testMessage =File("./src/test/kotlin/data/report_schema_contentSchemaVersion_validation.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_contentSchemaVersion_validation.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -142,8 +141,8 @@ class ReportSchemaValidationTests { @Test fun testReportSchemaValidationPass() { - val testMessage =File("./src/test/kotlin/data/report_schema_validation_pass.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/report_schema_validation_pass.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) ServiceBusProcessor().withMessage(serviceBusReceivedMessage) } diff --git a/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt b/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt index a940bb8a..e406e7f2 100644 --- a/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt +++ b/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt @@ -39,8 +39,8 @@ class ServiceBusTests { } @Test fun testServiceBusMessageMissingUploadId() { - val testMessage =File("./src/test/kotlin/data/service_bus_missing_upload_id.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage =File("./src/test/kotlin/data/service_bus_missing_upload_id.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { // Mock file @@ -52,8 +52,8 @@ class ServiceBusTests { } @Test fun testServiceBusMessageMissingDataStreamId() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_data_stream_id.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_missing_data_stream_id.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -65,8 +65,8 @@ class ServiceBusTests { @Test fun testServiceBusMessageMissingRoute() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_data_stream_route.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_missing_data_stream_route.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -77,8 +77,8 @@ class ServiceBusTests { } @Test fun testServiceBusMessageMissingStageName() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_stage_name.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_missing_stage_name.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { @@ -91,8 +91,8 @@ class ServiceBusTests { @Test fun testServiceBusMessageMissingContentType() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_content_type.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_missing_content_type.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -104,8 +104,8 @@ class ServiceBusTests { @Test fun testServiceBusMessageMissingContent() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_content.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_missing_content.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -117,8 +117,8 @@ class ServiceBusTests { @Test fun testServiceBusMessageContentMissingSchemaName() { - val testMessage = File("./src/test/kotlin/data/service_bus_content_missing_schema_name.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_content_missing_schema_name.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -130,8 +130,8 @@ class ServiceBusTests { @Test fun testServiceBusMessageContentMissingSchemaVersion() { - val testMessage = File("./src/test/kotlin/data/service_bus_content_missing_schema_version.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_content_missing_schema_version.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -142,22 +142,22 @@ class ServiceBusTests { } @Test fun testServiceBusGoodMessage_V1() { - val testMessage = File("./src/test/kotlin/data/service_bus_good_message_V1.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_good_message_V1.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) ServiceBusProcessor().withMessage(serviceBusReceivedMessage) } @Test fun testServiceBusGoodMessage() { - val testMessage = File("./src/test/kotlin/data/service_bus_good_message.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_good_message.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) ServiceBusProcessor().withMessage(serviceBusReceivedMessage) } @Test fun testServiceBusMessageEscapeQuotedJson() { - val testMessage = File("./src/test/kotlin/data/service_bus_escape_quoted_json.json").readText() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromString(testMessage) + val testMessage = File("./src/test/kotlin/data/service_bus_escape_quoted_json.json").readBytes() + val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) @@ -168,13 +168,13 @@ class ServiceBusTests { } } -fun createServiceBusReceivedMessageFromString(messageBody: String): ServiceBusReceivedMessage { +fun createServiceBusReceivedMessageFromBinary(messageBody: ByteArray): ServiceBusReceivedMessage { val message = mockk(relaxed = true) val messageId ="MessageId123" val status ="Active" every{ message.messageId } returns messageId every{ message.state.name } returns status - every { message.body.toString() } returns messageBody + every { message.body.toBytes() } returns messageBody return message } From 32de61fe2075ded3f47287a6ddce75664043cba6 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 13:28:03 -0400 Subject: [PATCH 57/91] Removed ServiceBusTests.kt since its no longer required with the intro of new schema validations --- pstatus-report-sink-ktor/build.gradle | 2 +- .../test/ReportSchemaValidationTests.kt | 11 ++ .../src/test/kotlin/test/ServiceBusTests.kt | 181 ------------------ 3 files changed, 12 insertions(+), 182 deletions(-) delete mode 100644 pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index d3744a38..af48b00b 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -94,7 +94,7 @@ test { events "passed", "skipped", "failed" } //Change this to "true" if we want to execute unit tests - systemProperty("isTestEnvironment", "false") + systemProperty("isTestEnvironment", "true") // Set the test classpath, if required } diff --git a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt index 9956a25d..8bb585e8 100644 --- a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt +++ b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt @@ -1,6 +1,7 @@ package test +import com.azure.messaging.servicebus.ServiceBusReceivedMessage import com.microsoft.azure.functions.ExecutionContext import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException @@ -146,6 +147,16 @@ class ReportSchemaValidationTests { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) } + private fun createServiceBusReceivedMessageFromBinary(messageBody: ByteArray): ServiceBusReceivedMessage { + val message = mockk(relaxed = true) + val messageId ="MessageId123" + val status ="Active" + every{ message.messageId } returns messageId + every{ message.state.name } returns status + every { message.body.toBytes() } returns messageBody + return message + } + } diff --git a/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt b/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt deleted file mode 100644 index e406e7f2..00000000 --- a/pstatus-report-sink-ktor/src/test/kotlin/test/ServiceBusTests.kt +++ /dev/null @@ -1,181 +0,0 @@ - -package test - -import com.google.gson.Gson -import com.microsoft.azure.functions.ExecutionContext -import gov.cdc.ocio.processingstatusapi.cosmos.CosmosContainerManager -import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException -import gov.cdc.ocio.processingstatusapi.models.reports.CreateReportSBMessage -import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition -import gov.cdc.ocio.processingstatusapi.plugins.ServiceBusProcessor -import io.mockk.* -import org.mockito.Mockito -import org.testng.Assert -import org.testng.annotations.BeforeMethod -import org.testng.annotations.Test -import java.io.File -import com.azure.messaging.servicebus.ServiceBusReceivedMessage - -class ServiceBusTests { - - private lateinit var context: ExecutionContext - - @BeforeMethod - fun setUp() { - context = Mockito.mock(ExecutionContext::class.java) - mockkObject(CosmosContainerManager) - every { CosmosContainerManager.initDatabaseContainer(any(), any(),any(), any()) } returns null - - } - @Test - fun testParseJsonContentSchemaDefinition() { - val testMessage = File("./src/test/kotlin/data/service_bus_good_message.json").readText() - - val createReportSBMessage = Gson().fromJson(testMessage, CreateReportSBMessage::class.java) - val schemaDefinition = SchemaDefinition.fromJsonString(createReportSBMessage.content) - - Assert.assertEquals(schemaDefinition.schemaName, "dex-hl7-validation") - Assert.assertEquals(schemaDefinition.schemaVersion, "0.0.1") - } - @Test - fun testServiceBusMessageMissingUploadId() { - val testMessage =File("./src/test/kotlin/data/service_bus_missing_upload_id.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - var exceptionThrown = false - try { - // Mock file - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Missing fields: uploadId" - } - Assert.assertTrue(exceptionThrown) - } - @Test - fun testServiceBusMessageMissingDataStreamId() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_data_stream_id.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - var exceptionThrown = false - try { - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Missing fields: dataStreamId, content" - } - Assert.assertTrue(exceptionThrown) - } - - @Test - fun testServiceBusMessageMissingRoute() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_data_stream_route.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - var exceptionThrown = false - try { - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Missing fields: dataStreamRoute, content" - } - Assert.assertTrue(exceptionThrown) - } - @Test - fun testServiceBusMessageMissingStageName() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_stage_name.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - - var exceptionThrown = false - try { - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Missing fields: stageName, content" - } - Assert.assertTrue(exceptionThrown) - } - - @Test - fun testServiceBusMessageMissingContentType() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_content_type.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - var exceptionThrown = false - try { - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Missing fields: contentType, content" - } - Assert.assertTrue(exceptionThrown) - } - - @Test - fun testServiceBusMessageMissingContent() { - val testMessage = File("./src/test/kotlin/data/service_bus_missing_content.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - var exceptionThrown = false - try { - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Missing fields: content" - } - Assert.assertTrue(exceptionThrown) - } - - @Test - fun testServiceBusMessageContentMissingSchemaName() { - val testMessage = File("./src/test/kotlin/data/service_bus_content_missing_schema_name.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - var exceptionThrown = false - try { - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Invalid schema definition: Invalid schema_name provided" - } - Assert.assertTrue(exceptionThrown) - } - - @Test - fun testServiceBusMessageContentMissingSchemaVersion() { - val testMessage = File("./src/test/kotlin/data/service_bus_content_missing_schema_version.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - var exceptionThrown = false - try { - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Invalid schema definition: Invalid schema_version provided" - } - Assert.assertTrue(exceptionThrown) - } - @Test - fun testServiceBusGoodMessage_V1() { - val testMessage = File("./src/test/kotlin/data/service_bus_good_message_V1.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } - - @Test - fun testServiceBusGoodMessage() { - val testMessage = File("./src/test/kotlin/data/service_bus_good_message.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } - - @Test - fun testServiceBusMessageEscapeQuotedJson() { - val testMessage = File("./src/test/kotlin/data/service_bus_escape_quoted_json.json").readBytes() - val serviceBusReceivedMessage = createServiceBusReceivedMessageFromBinary(testMessage) - var exceptionThrown = false - try { - ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { - exceptionThrown = ex.localizedMessage == "Malformed message: class java.lang.String cannot be cast to class java.util.Map (java.lang.String and java.util.Map are in module java.base of loader 'bootstrap')" - } - Assert.assertTrue(exceptionThrown) - } -} - -fun createServiceBusReceivedMessageFromBinary(messageBody: ByteArray): ServiceBusReceivedMessage { - val message = mockk(relaxed = true) - val messageId ="MessageId123" - val status ="Active" - every{ message.messageId } returns messageId - every{ message.state.name } returns status - every { message.body.toBytes() } returns messageBody - return message -} - - From fb2e8093a54931616a28ec82174a6b3f29b5253f Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 13:28:36 -0400 Subject: [PATCH 58/91] Set TestEnvironment to false --- pstatus-report-sink-ktor/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index af48b00b..d3744a38 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -94,7 +94,7 @@ test { events "passed", "skipped", "failed" } //Change this to "true" if we want to execute unit tests - systemProperty("isTestEnvironment", "true") + systemProperty("isTestEnvironment", "false") // Set the test classpath, if required } From d22e2a75e61da6d13e69886adc083e7ac649433b Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 15:00:18 -0400 Subject: [PATCH 59/91] Fixed code review comments --- .../ocio/processingstatusapi/ReportManager.kt | 4 +- .../plugins/ServiceBusProcessor.kt | 52 +++++++++---------- 2 files changed, 27 insertions(+), 29 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 9ca70187..26a6f663 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 @@ -69,13 +69,13 @@ class ReportManager: KoinComponent { source: Source ): String { // Verify the content contains the minimum schema information - try { + /* try { SchemaDefinition.fromJsonString(content) } catch(e: InvalidSchemaDefException) { throw BadRequestException("Invalid schema definition: ${e.localizedMessage}") } catch(e: Exception) { throw BadRequestException("Malformed message: ${e.localizedMessage}") - } + }*/ if (System.getProperty("isTestEnvironment") != "true") { return createReport( uploadId, 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 9799543b..45e868f2 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 @@ -46,9 +46,7 @@ class ServiceBusProcessor { */ @Throws(BadRequestException::class) fun withMessage(message: ServiceBusReceivedMessage) { - validateJsonSchema(message) val sbMessageId = message.messageId - //var sbMessage = message.body.toString() var sbMessage =String(message.body.toBytes()) val sbMessageStatus = message.state.name try { @@ -60,12 +58,13 @@ class ServiceBusProcessor { sbMessage = sbMessage.replace("event_type", "data_stream_route") } logger.info { "After Message received = $sbMessage" } + validateJsonSchema(message) createReport(sbMessageId, sbMessageStatus, gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) } catch (e: BadRequestException) { - println("Validation failed: ${e.message}") + logger.error("Validation failed: ${e.message}") throw e } catch (e: JsonSyntaxException) { - logger.error("Failed to parse CreateReportSBMessage: ${e.localizedMessage}") + logger.error("Failed to parse service bus message: ${e.localizedMessage}") throw BadStateException("Unable to interpret the create report message") } } @@ -80,14 +79,17 @@ class ServiceBusProcessor { private fun createReport(messageId: String, messageStatus: String, createReportMessage: CreateReportSBMessage) { try { val uploadId = createReportMessage.uploadId - val stageName = createReportMessage.stageName + var stageName = createReportMessage.stageName + if (stageName.isNullOrEmpty()) { + stageName = "" + } logger.info("Creating report for uploadId = ${uploadId} with stageName = $stageName") ReportManager().createReportWithUploadId( - createReportMessage.uploadId!!, + uploadId!!, createReportMessage.dataStreamId!!, createReportMessage.dataStreamRoute!!, - createReportMessage.stageName!!, + stageName, createReportMessage.contentType!!, messageId, //createReportMessage.messageId is null messageStatus, //createReportMessage.status is null @@ -99,7 +101,7 @@ class ServiceBusProcessor { } catch (e: BadRequestException) { throw e } catch (e: Exception) { - println("Failed to process service bus message:${e.message}") + logger.error("Failed to process service bus message:${e.message}") } @@ -127,7 +129,7 @@ class ServiceBusProcessor { val reportSchemaVersionNode = jsonNode.get("report_schema_version") if (reportSchemaVersionNode == null || reportSchemaVersionNode.asText().isEmpty()) { reason ="Report rejected: `report_schema_version` field is missing or empty." - ProcessError(reason,invalidData,createReportMessage) + processError(reason,invalidData,createReportMessage) } else { val reportSchemaVersion = reportSchemaVersionNode.asText() @@ -136,15 +138,15 @@ class ServiceBusProcessor { val schemaFile = File(schemaFilePath) if (!schemaFile.exists()) { reason ="Report rejected: Schema file not found for base schema version $reportSchemaVersion." - ProcessError(reason,invalidData,createReportMessage) + processError(reason,invalidData,createReportMessage) } //Validate report schema version schema - ValidateSchema(jsonNode,schemaFile,objectMapper,invalidData,createReportMessage) + validateSchema(jsonNode,schemaFile,objectMapper,invalidData,createReportMessage) // Check if the content_type is JSON val contentTypeNode = jsonNode.get("content_type") if (contentTypeNode == null) { reason="Report rejected: `content_type` is not JSON or is missing." - ProcessError(reason,invalidData,createReportMessage) + processError(reason,invalidData,createReportMessage) } else{ if(!isJsonMimeType(contentTypeNode.asText())) @@ -157,7 +159,7 @@ class ServiceBusProcessor { val contentNode = jsonNode.get("content") if (contentNode == null) { reason="Report rejected: `content` is not JSON or is missing." - ProcessError(reason,invalidData,createReportMessage) + processError(reason,invalidData,createReportMessage) } // Check for `content_schema_name` and `content_schema_version` val contentSchemaNameNode = contentNode.get("content_schema_name") @@ -166,7 +168,7 @@ class ServiceBusProcessor { contentSchemaVersionNode == null || contentSchemaVersionNode.asText().isEmpty() ) { reason= "Report rejected: `content_schema_name` or `content_schema_version` is missing or empty." - ProcessError(reason,invalidData,createReportMessage) + processError(reason,invalidData,createReportMessage) } val contentSchemaName = contentSchemaNameNode.asText() @@ -177,15 +179,15 @@ class ServiceBusProcessor { val contentSchemaFile = File(contentSchemaFilePath) if (!contentSchemaFile .exists()) { reason ="Report rejected: Content schema file not found for content schema name $contentSchemaName and schema version $contentSchemaVersion." - ProcessError(reason,invalidData,createReportMessage) + processError(reason,invalidData,createReportMessage) } //Validate content schema - ValidateSchema(contentNode,contentSchemaFile,objectMapper,invalidData, createReportMessage) + validateSchema(contentNode,contentSchemaFile,objectMapper,invalidData, createReportMessage) } if (invalidData.isNotEmpty()) { reason ="Validation of the json schema failed" - ProcessError(reason,invalidData,createReportMessage) + processError(reason,invalidData,createReportMessage) } } catch (e: Exception) { @@ -200,8 +202,7 @@ class ServiceBusProcessor { * @param invalidData MutableList * @param createReportMessage CreateReportSBMessage */ - @JvmStatic - private fun SendToDeadLetter(invalidData:MutableList, createReportMessage: CreateReportSBMessage){ + private fun sendToDeadLetter(invalidData:MutableList, createReportMessage: CreateReportSBMessage){ if (invalidData.isNotEmpty()) { //This should not run for unit tests if (System.getProperty("isTestEnvironment") != "true") { @@ -226,11 +227,10 @@ class ServiceBusProcessor { * @param invalidData MutableList * @param createReportMessage CreateReportSBMessage */ - @JvmStatic - private fun ProcessError(reason:String, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { + private fun processError(reason:String, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { LOGGER.error(reason) invalidData.add(reason) - SendToDeadLetter(invalidData, createReportMessage) + sendToDeadLetter(invalidData, createReportMessage) } /** @@ -239,8 +239,7 @@ class ServiceBusProcessor { * @param objectMapper ObjectMapper */ - @JvmStatic - private fun ValidateSchema(jsonNode:JsonNode,schemaFile:File, objectMapper: ObjectMapper,invalidData:MutableList,createReportMessage: CreateReportSBMessage) { + private fun validateSchema(jsonNode:JsonNode, schemaFile:File, objectMapper: ObjectMapper, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { val schemaNode: JsonNode = objectMapper.readTree(schemaFile) val schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) val schema: JsonSchema = schemaFactory.getSchema(schemaNode) @@ -251,14 +250,13 @@ class ServiceBusProcessor { } else { val reason ="JSON is invalid against the content schema $schema.Errors:" schemaValidationMessages.forEach { invalidData.add(it.message) } - ProcessError(reason, invalidData,createReportMessage) + processError(reason, invalidData,createReportMessage) } } - @JvmStatic private fun isJsonMimeType(contentType: String): Boolean { return try { val mimeType = MimeType(contentType) - mimeType.primaryType == "application" && mimeType.subType == "json" + mimeType.primaryType == "json" || (mimeType.primaryType == "application" && mimeType.subType == "json") } catch (e: MimeTypeParseException) { false } From d3e4f551d812acc3c1647f6f7e28f7df32cec49d Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 15:15:29 -0400 Subject: [PATCH 60/91] Removed the compaion object block for sendToDeadLetter and other private functions --- .../ocio/processingstatusapi/plugins/ServiceBusProcessor.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 45e868f2..0317511f 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 @@ -196,8 +196,7 @@ class ServiceBusProcessor { } } - companion object { - /** + /** * Function to send the invalid data reasons to the deadLetter queue * @param invalidData MutableList * @param createReportMessage CreateReportSBMessage @@ -261,5 +260,5 @@ class ServiceBusProcessor { false } } - } + } \ No newline at end of file From a99bf48004c60681b2ae26836baa6dc35b58a2d3 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 15:24:21 -0400 Subject: [PATCH 61/91] Added function header for isJsonMimeType --- .../ocio/processingstatusapi/plugins/ServiceBusProcessor.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 0317511f..a67b40c4 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 @@ -252,6 +252,10 @@ class ServiceBusProcessor { processError(reason, invalidData,createReportMessage) } } + + /** + * Function to check whether the content type is json or application/json using MimeType + */ private fun isJsonMimeType(contentType: String): Boolean { return try { val mimeType = MimeType(contentType) From 89683da718d06a9db6284c3870867a242efc570f Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 15:24:39 -0400 Subject: [PATCH 62/91] Added function header for isJsonMimeType --- .../cdc/ocio/processingstatusapi/plugins/ServiceBusProcessor.kt | 1 + 1 file changed, 1 insertion(+) 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 a67b40c4..03911fba 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 @@ -255,6 +255,7 @@ class ServiceBusProcessor { /** * Function to check whether the content type is json or application/json using MimeType + * @param contentType String */ private fun isJsonMimeType(contentType: String): Boolean { return try { From 9579bd2c25b7606d8fc2ce41790c7aa45aa711a9 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 16:08:58 -0400 Subject: [PATCH 63/91] Fixed indentation --- .../plugins/ServiceBusProcessor.kt | 132 +++++++++--------- 1 file changed, 66 insertions(+), 66 deletions(-) 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 03911fba..ba3def15 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 @@ -116,7 +116,7 @@ class ServiceBusProcessor { var reason: String //TODO : this needs to be replaced with more robust source or a URL of some sorts val schemaDirectoryPath = System.getenv("SCHEMA_DIRECTORY_PATH") - // Convert the message body to a JSON string + // Convert the message body to a JSON string val messageBody = String(message.body.toBytes()) val objectMapper: ObjectMapper = jacksonObjectMapper() // Check for the presence of `report_schema_version` @@ -125,7 +125,7 @@ class ServiceBusProcessor { val createReportMessage: CreateReportSBMessage = gson.fromJson(messageBody, CreateReportSBMessage::class.java) //Convert to JSON val jsonNode: JsonNode =objectMapper.readTree(messageBody) - // Check for the presence of `report_schema_version` + // Check for the presence of `report_schema_version` val reportSchemaVersionNode = jsonNode.get("report_schema_version") if (reportSchemaVersionNode == null || reportSchemaVersionNode.asText().isEmpty()) { reason ="Report rejected: `report_schema_version` field is missing or empty." @@ -145,15 +145,15 @@ class ServiceBusProcessor { // Check if the content_type is JSON val contentTypeNode = jsonNode.get("content_type") if (contentTypeNode == null) { - reason="Report rejected: `content_type` is not JSON or is missing." + reason="Report rejected: `content_type` is not JSON or is missing." processError(reason,invalidData,createReportMessage) } else{ - if(!isJsonMimeType(contentTypeNode.asText())) - { - //don't need to go further down if the mimetype is other than json. i.e. xml or text etc.... + if(!isJsonMimeType(contentTypeNode.asText())) + { + //don't need to go further down if the mimetype is other than json. i.e. xml or text etc.... return - } + } } // Open the content as JSON val contentNode = jsonNode.get("content") @@ -182,8 +182,8 @@ class ServiceBusProcessor { processError(reason,invalidData,createReportMessage) } //Validate content schema - validateSchema(contentNode,contentSchemaFile,objectMapper,invalidData, createReportMessage) - } + validateSchema(contentNode,contentSchemaFile,objectMapper,invalidData, createReportMessage) + } if (invalidData.isNotEmpty()) { reason ="Validation of the json schema failed" @@ -196,74 +196,74 @@ class ServiceBusProcessor { } } - /** - * Function to send the invalid data reasons to the deadLetter queue - * @param invalidData MutableList - * @param createReportMessage CreateReportSBMessage - */ - private fun sendToDeadLetter(invalidData:MutableList, createReportMessage: CreateReportSBMessage){ - 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, - createReportMessage.dataStreamRoute, - createReportMessage.stageName, - createReportMessage.dispositionType, - createReportMessage.contentType, - createReportMessage.content, - invalidData - ) - } - throw BadRequestException(invalidData.joinToString(separator = ",")) + /** + * Function to send the invalid data reasons to the deadLetter queue + * @param invalidData MutableList + * @param createReportMessage CreateReportSBMessage + */ + private fun sendToDeadLetter(invalidData:MutableList, createReportMessage: CreateReportSBMessage){ + 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, + createReportMessage.dataStreamRoute, + createReportMessage.stageName, + createReportMessage.dispositionType, + createReportMessage.contentType, + createReportMessage.content, + invalidData + ) } + throw BadRequestException(invalidData.joinToString(separator = ",")) } - /** - * Function to process the error by logging it and adding to the invalidData list and sending it to deadletter - * @param reason String - * @param invalidData MutableList - * @param createReportMessage CreateReportSBMessage - */ - private fun processError(reason:String, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { - LOGGER.error(reason) - invalidData.add(reason) - sendToDeadLetter(invalidData, createReportMessage) - } + } + /** + * Function to process the error by logging it and adding to the invalidData list and sending it to deadletter + * @param reason String + * @param invalidData MutableList + * @param createReportMessage CreateReportSBMessage + */ + private fun processError(reason:String, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { + LOGGER.error(reason) + invalidData.add(reason) + sendToDeadLetter(invalidData, createReportMessage) + } - /** - * Function to validate the schema based on the schema file and the json contents passed into it - * @param schemaFile String - * @param objectMapper ObjectMapper + /** + * Function to validate the schema based on the schema file and the json contents passed into it + * @param schemaFile String + * @param objectMapper ObjectMapper - */ - private fun validateSchema(jsonNode:JsonNode, schemaFile:File, objectMapper: ObjectMapper, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { - val schemaNode: JsonNode = objectMapper.readTree(schemaFile) - val schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) - val schema: JsonSchema = schemaFactory.getSchema(schemaNode) - val schemaValidationMessages: Set = schema.validate(jsonNode) + */ + private fun validateSchema(jsonNode:JsonNode, schemaFile:File, objectMapper: ObjectMapper, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { + val schemaNode: JsonNode = objectMapper.readTree(schemaFile) + val schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) + val schema: JsonSchema = schemaFactory.getSchema(schemaNode) + val schemaValidationMessages: Set = schema.validate(jsonNode) - if (schemaValidationMessages.isEmpty()) { - LOGGER.info("JSON is valid against the content schema $schema.") - } else { - val reason ="JSON is invalid against the content schema $schema.Errors:" - schemaValidationMessages.forEach { invalidData.add(it.message) } - processError(reason, invalidData,createReportMessage) - } + if (schemaValidationMessages.isEmpty()) { + LOGGER.info("JSON is valid against the content schema $schema.") + } else { + val reason ="JSON is invalid against the content schema $schema.Errors:" + schemaValidationMessages.forEach { invalidData.add(it.message) } + processError(reason, invalidData,createReportMessage) } + } /** * Function to check whether the content type is json or application/json using MimeType * @param contentType String */ - private fun isJsonMimeType(contentType: String): Boolean { - return try { - val mimeType = MimeType(contentType) - mimeType.primaryType == "json" || (mimeType.primaryType == "application" && mimeType.subType == "json") - } catch (e: MimeTypeParseException) { - false - } + private fun isJsonMimeType(contentType: String): Boolean { + return try { + val mimeType = MimeType(contentType) + mimeType.primaryType == "json" || (mimeType.primaryType == "application" && mimeType.subType == "json") + } catch (e: MimeTypeParseException) { + false } + } } \ No newline at end of file From 8b1743c4abbceb48ce30fd55b06bb0d570b9442b Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 18:59:07 -0400 Subject: [PATCH 64/91] Moved schema folder under resources and made the corresponding coded changes --- pstatus-report-sink-ktor/build.gradle | 2 +- .../plugins/ServiceBusProcessor.kt | 112 ++++++------ .../resources/schema/base.0.0.1.schema.json | 160 ++++++++++++++++++ .../schema/base.1.0.0.schema.json | 0 .../schema/blob-file-copy.1.0.0.schema.json | 0 .../buzz-file-capture.1.0.0.schema.json | 0 .../schema/hl7v2-debatch.1.0.0.schema.json | 0 ...v2-json-lake-transformer.1.0.0.schema.json | 0 ...ake-segments-transformer.1.0.0.schema.json | 0 .../schema/hl7v2-redact.1.0.0.schema.json | 0 ...7v2-structure-validation.1.0.0.schema.json | 0 .../schema/metadata-verify.1.0.0.schema.json | 0 .../schema/upload-status.1.0.0.schema.json | 0 .../test/ReportSchemaValidationTests.kt | 6 +- 14 files changed, 226 insertions(+), 54 deletions(-) create mode 100644 pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/base.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/blob-file-copy.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/buzz-file-capture.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/hl7v2-debatch.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/hl7v2-json-lake-transformer.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/hl7v2-redact.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/hl7v2-structure-validation.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/metadata-verify.1.0.0.schema.json (100%) rename pstatus-report-sink-ktor/src/main/{kotlin/gov/cdc/ocio/processingstatusapi/plugins => resources}/schema/upload-status.1.0.0.schema.json (100%) diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index d3744a38..af48b00b 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -94,7 +94,7 @@ test { events "passed", "skipped", "failed" } //Change this to "true" if we want to execute unit tests - systemProperty("isTestEnvironment", "false") + systemProperty("isTestEnvironment", "true") // Set the test classpath, if required } 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 ba3def15..9bec4fdf 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 @@ -115,10 +115,12 @@ class ServiceBusProcessor { val invalidData = mutableListOf() var reason: String //TODO : this needs to be replaced with more robust source or a URL of some sorts - val schemaDirectoryPath = System.getenv("SCHEMA_DIRECTORY_PATH") + val schemaDirectoryPath = "/schema" // Convert the message body to a JSON string val messageBody = String(message.body.toBytes()) val objectMapper: ObjectMapper = jacksonObjectMapper() + var reportSchemaVersion = "0.0.1" // for backward compatibility - this schema will load if report_schema_version is not found + // Check for the presence of `report_schema_version` try { @@ -128,62 +130,68 @@ class ServiceBusProcessor { // Check for the presence of `report_schema_version` val reportSchemaVersionNode = jsonNode.get("report_schema_version") if (reportSchemaVersionNode == null || reportSchemaVersionNode.asText().isEmpty()) { - reason ="Report rejected: `report_schema_version` field is missing or empty." - processError(reason,invalidData,createReportMessage) + LOGGER.info("Report schema version node missing. Backward compatibility mode enabled ") + } else { + reportSchemaVersion = reportSchemaVersionNode.asText() + } + val fileName ="base.$reportSchemaVersion.schema.json" + val schemaFilePath = javaClass.getResource( "$schemaDirectoryPath/$fileName") + ?: throw IllegalArgumentException("File not found: $fileName") - val reportSchemaVersion = reportSchemaVersionNode.asText() - val schemaFilePath = "$schemaDirectoryPath/base.$reportSchemaVersion.schema.json" - // Attempt to load the schema - val schemaFile = File(schemaFilePath) - if (!schemaFile.exists()) { - reason ="Report rejected: Schema file not found for base schema version $reportSchemaVersion." - processError(reason,invalidData,createReportMessage) - } - //Validate report schema version schema - validateSchema(jsonNode,schemaFile,objectMapper,invalidData,createReportMessage) - // Check if the content_type is JSON - val contentTypeNode = jsonNode.get("content_type") - if (contentTypeNode == null) { - reason="Report rejected: `content_type` is not JSON or is missing." - processError(reason,invalidData,createReportMessage) - } - else{ - if(!isJsonMimeType(contentTypeNode.asText())) - { - //don't need to go further down if the mimetype is other than json. i.e. xml or text etc.... - return - } - } - // Open the content as JSON - val contentNode = jsonNode.get("content") - if (contentNode == null) { - reason="Report rejected: `content` is not JSON or is missing." - processError(reason,invalidData,createReportMessage) - } - // Check for `content_schema_name` and `content_schema_version` - val contentSchemaNameNode = contentNode.get("content_schema_name") - val contentSchemaVersionNode = contentNode.get("content_schema_version") - if (contentSchemaNameNode == null || contentSchemaNameNode.asText().isEmpty() || - contentSchemaVersionNode == null || contentSchemaVersionNode.asText().isEmpty() - ) { - reason= "Report rejected: `content_schema_name` or `content_schema_version` is missing or empty." - processError(reason,invalidData,createReportMessage) + // Attempt to load the schema + val schemaFile = File(schemaFilePath.toURI()) + if (!schemaFile.exists()) { + reason ="Report rejected: Schema file not found for base schema version $reportSchemaVersion." + processError(reason,invalidData,createReportMessage) + } + //Validate report schema version schema + validateSchema(jsonNode,schemaFile,objectMapper,invalidData,createReportMessage) + // Check if the content_type is JSON + val contentTypeNode = jsonNode.get("content_type") + if (contentTypeNode == null) { + reason="Report rejected: `content_type` is not JSON or is missing." + processError(reason,invalidData,createReportMessage) + } + else{ + if(!isJsonMimeType(contentTypeNode.asText())) + { + //don't need to go further down if the mimetype is other than json. i.e. xml or text etc.... + return } + } + // Open the content as JSON + val contentNode = jsonNode.get("content") + if (contentNode == null) { + reason="Report rejected: `content` is not JSON or is missing." + processError(reason,invalidData,createReportMessage) + } + // Check for `content_schema_name` and `content_schema_version` + val contentSchemaNameNode = contentNode.get("content_schema_name") + val contentSchemaVersionNode = contentNode.get("content_schema_version") + if (contentSchemaNameNode == null || contentSchemaNameNode.asText().isEmpty() || + contentSchemaVersionNode == null || contentSchemaVersionNode.asText().isEmpty() + ) { + reason= "Report rejected: `content_schema_name` or `content_schema_version` is missing or empty." + processError(reason,invalidData,createReportMessage) + } + //ContentSchema validation + val contentSchemaName = contentSchemaNameNode.asText() + val contentSchemaVersion = contentSchemaVersionNode.asText() - val contentSchemaName = contentSchemaNameNode.asText() - val contentSchemaVersion = contentSchemaVersionNode.asText() - //TODO Will this be from the same source??? - val contentSchemaFilePath = "$schemaDirectoryPath/$contentSchemaName.$contentSchemaVersion.schema.json" - // Attempt to load the schema - val contentSchemaFile = File(contentSchemaFilePath) - if (!contentSchemaFile .exists()) { - reason ="Report rejected: Content schema file not found for content schema name $contentSchemaName and schema version $contentSchemaVersion." - processError(reason,invalidData,createReportMessage) - } - //Validate content schema - validateSchema(contentNode,contentSchemaFile,objectMapper,invalidData, createReportMessage) + //TODO Will this be from the same source??? + val contentSchemaFileName ="$contentSchemaName.$contentSchemaVersion.schema.json" + val contentSchemaFilePath =javaClass.getResource( "$schemaDirectoryPath/$contentSchemaFileName") + ?: throw IllegalArgumentException("File not found: $contentSchemaFileName") + + // Attempt to load the schema + val contentSchemaFile = File(contentSchemaFilePath.toURI()) + if (!contentSchemaFile .exists()) { + reason ="Report rejected: Content schema file not found for content schema name $contentSchemaName and schema version $contentSchemaVersion." + processError(reason,invalidData,createReportMessage) } + //Validate content schema + validateSchema(contentNode,contentSchemaFile,objectMapper,invalidData, createReportMessage) if (invalidData.isNotEmpty()) { reason ="Validation of the json schema failed" diff --git a/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json new file mode 100644 index 00000000..a9aa3a30 --- /dev/null +++ b/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://github.com/cdcent/data-exchange-messages/reports/base", + "title": "Base Report", + "type": "object", + "required": ["upload_id", "data_stream_id", "data_stream_route", "content_type"], + "if": { + "properties": { + "content_type": { + "const": "json" + } + } + }, + "then": { + "properties": { + "content": { + "$ref": "#/$defs/jsonContent" + } + } + }, + "properties": { + "report_schema_version": { + "type": "string" + }, + "upload_id": { + "$ref": "#/$defs/uuid", + "description": "Unique upload identifier associated with this report." + }, + "user_id": { + "type": "string", + "description": "User or system id that uploaded the file, not the provider of this report." + }, + "data_stream_id": { + "type": "string" + }, + "data_stream_route": { + "type": "string" + }, + "jurisdiction": { + "type": "string" + }, + "sender_id": { + "type": "string" + }, + "data_producer_id": { + "type": "string" + }, + "dex_ingest_timestamp": { + "$ref": "#/$defs/timestamp" + }, + "message_metadata": { + "$ref": "#/$defs/messageMetadata", + "description": "Metadata associated with the message this report belong to." + }, + "stage_info": { + "$ref": "#/$defs/stageInfo", + "description": "Describes the stage that is providing this report." + }, + "tags": { + "$ref": "#/$defs/keyValueMap", + "description": "Optional tag(s) associated with this report." + }, + "data": { + "$ref": "#/$defs/keyValueMap", + "description": "Optional data associated with this report." + }, + "content_type": { + "type": "string" + } + }, + "$defs": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "messageMetadata": { + "type": "object", + "properties": { + "message_uuid": { + "type": "string", + "description": "Unique identifier for the message associated with this report. Null if not applicable." + }, + "message_hash": { + "type": "string", + "description": "MD5 hash of the message content." + }, + "aggregation": { + "type": "string", + "enum": ["SINGLE", "BATCH"], + "description": "Enumeration: [SINGLE, BATCH]." + }, + "message_index": { + "type": "integer", + "description": "Index of the message; e.g. row if csv." + } + } + }, + "stageInfo": { + "service": { + "type": "string", + "description": "Name of the service associated with this report." + }, + "stage": { + "type": "string", + "description": "Action the stage was conducting when providing this report." + }, + "version": { + "type": "string", + "description": "Version of the stage providing this report" + }, + "status": { + "type": "string", + "enum": ["SUCCESS", "FAILURE"] + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "WARNING", + "ERROR" + ] + }, + "message": { + "type": "string" + } + } + } + }, + "start_processing_time": { + "$ref": "#/$defs/timestamp" + }, + "end_processing_time": { + "$ref": "#/$defs/timestamp" + } + }, + "keyValueMap": { + "type" : "object", + "existingJavaType" : "java.util.Map" + }, + "jsonContent": { + "type": "object", + "properties": { + "content_schema_name": { + "type": "string" + }, + "content_schema_version": { + "type": "string" + } + } + } + } +} diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/base.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/base.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/base.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/base.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/blob-file-copy.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/blob-file-copy.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/blob-file-copy.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/buzz-file-capture.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/buzz-file-capture.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/buzz-file-capture.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-debatch.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-debatch.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-debatch.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-debatch.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-json-lake-transformer.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-json-lake-transformer.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-json-lake-transformer.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-lake-segments-transformer.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-redact.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-redact.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-redact.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-structure-validation.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/hl7v2-structure-validation.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/hl7v2-structure-validation.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/metadata-verify.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/metadata-verify.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/metadata-verify.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/upload-status.1.0.0.schema.json similarity index 100% rename from pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/schema/upload-status.1.0.0.schema.json rename to pstatus-report-sink-ktor/src/main/resources/schema/upload-status.1.0.0.schema.json diff --git a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt index 8bb585e8..d3ddd4aa 100644 --- a/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt +++ b/pstatus-report-sink-ktor/src/test/kotlin/test/ReportSchemaValidationTests.kt @@ -134,7 +134,11 @@ class ReportSchemaValidationTests { var exceptionThrown = false try { ServiceBusProcessor().withMessage(serviceBusReceivedMessage) - } catch(ex: BadRequestException) { + } + catch (ex :IllegalArgumentException){ + exceptionThrown = ex.localizedMessage.contains("File not found: hl7v2-debatch.2.0.0.schema.json") + } + catch(ex: BadRequestException) { exceptionThrown = ex.localizedMessage.contains("Report rejected: Content schema file not found for content schema name hl7v2-debatch and schema version 2.0.0.") } Assert.assertTrue(exceptionThrown) From 4a02388c8e466e1546c5befd77ebdf53e0103233 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 19:08:31 -0400 Subject: [PATCH 65/91] Removed most of the properties for simplicity? We only need data_stream_id, data_stream_route, upload_id, content and content_type. --- .../resources/schema/base.0.0.1.schema.json | 130 +----------------- 1 file changed, 4 insertions(+), 126 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json index a9aa3a30..8f1f084b 100644 --- a/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json +++ b/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json @@ -4,148 +4,26 @@ "title": "Base Report", "type": "object", "required": ["upload_id", "data_stream_id", "data_stream_route", "content_type"], - "if": { - "properties": { - "content_type": { - "const": "json" - } - } - }, - "then": { - "properties": { - "content": { - "$ref": "#/$defs/jsonContent" - } - } - }, "properties": { - "report_schema_version": { - "type": "string" - }, "upload_id": { "$ref": "#/$defs/uuid", "description": "Unique upload identifier associated with this report." }, - "user_id": { - "type": "string", - "description": "User or system id that uploaded the file, not the provider of this report." - }, + "data_stream_id": { "type": "string" }, "data_stream_route": { "type": "string" }, - "jurisdiction": { - "type": "string" - }, - "sender_id": { - "type": "string" - }, - "data_producer_id": { - "type": "string" - }, - "dex_ingest_timestamp": { - "$ref": "#/$defs/timestamp" - }, - "message_metadata": { - "$ref": "#/$defs/messageMetadata", - "description": "Metadata associated with the message this report belong to." - }, - "stage_info": { - "$ref": "#/$defs/stageInfo", - "description": "Describes the stage that is providing this report." - }, - "tags": { - "$ref": "#/$defs/keyValueMap", - "description": "Optional tag(s) associated with this report." - }, - "data": { - "$ref": "#/$defs/keyValueMap", - "description": "Optional data associated with this report." - }, + + "content_type": { "type": "string" } }, "$defs": { - "uuid": { - "type": "string", - "format": "uuid" - }, - "timestamp": { - "type": "string", - "format": "date-time" - }, - "messageMetadata": { - "type": "object", - "properties": { - "message_uuid": { - "type": "string", - "description": "Unique identifier for the message associated with this report. Null if not applicable." - }, - "message_hash": { - "type": "string", - "description": "MD5 hash of the message content." - }, - "aggregation": { - "type": "string", - "enum": ["SINGLE", "BATCH"], - "description": "Enumeration: [SINGLE, BATCH]." - }, - "message_index": { - "type": "integer", - "description": "Index of the message; e.g. row if csv." - } - } - }, - "stageInfo": { - "service": { - "type": "string", - "description": "Name of the service associated with this report." - }, - "stage": { - "type": "string", - "description": "Action the stage was conducting when providing this report." - }, - "version": { - "type": "string", - "description": "Version of the stage providing this report" - }, - "status": { - "type": "string", - "enum": ["SUCCESS", "FAILURE"] - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "level": { - "type": "string", - "enum": [ - "WARNING", - "ERROR" - ] - }, - "message": { - "type": "string" - } - } - } - }, - "start_processing_time": { - "$ref": "#/$defs/timestamp" - }, - "end_processing_time": { - "$ref": "#/$defs/timestamp" - } - }, - "keyValueMap": { - "type" : "object", - "existingJavaType" : "java.util.Map" - }, - "jsonContent": { + "jsonContent": { "type": "object", "properties": { "content_schema_name": { From 0d7c13dd35643fdc6db10d34e63dd711d0e07598 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 19:09:04 -0400 Subject: [PATCH 66/91] Indented --- .../src/main/resources/schema/base.0.0.1.schema.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json index 8f1f084b..08fdc09e 100644 --- a/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json +++ b/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json @@ -17,13 +17,12 @@ "type": "string" }, - "content_type": { "type": "string" } }, "$defs": { - "jsonContent": { + "jsonContent": { "type": "object", "properties": { "content_schema_name": { From 8fd8c410186bec239d1c6fed1831f1a8c6a4d30c Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Thu, 18 Jul 2024 22:43:11 -0400 Subject: [PATCH 67/91] Updated base.0.0.1.schema.json --- .../resources/schema/base.0.0.1.schema.json | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json b/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json index 08fdc09e..7f0e510f 100644 --- a/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json +++ b/pstatus-report-sink-ktor/src/main/resources/schema/base.0.0.1.schema.json @@ -4,31 +4,47 @@ "title": "Base Report", "type": "object", "required": ["upload_id", "data_stream_id", "data_stream_route", "content_type"], + "if": { + "properties": { + "content_type": { + "const": "json" + } + } + }, + "then": { + "properties": { + "content": { + "$ref": "#/$defs/jsonContent" + } + } + }, "properties": { "upload_id": { "$ref": "#/$defs/uuid", "description": "Unique upload identifier associated with this report." }, - "data_stream_id": { "type": "string" }, "data_stream_route": { "type": "string" }, - "content_type": { "type": "string" } }, "$defs": { + "uuid": { + "type": "string", + "format": "uuid" + }, "jsonContent": { "type": "object", "properties": { - "content_schema_name": { + "schema_name": { "type": "string" }, - "content_schema_version": { + "schema_version": { "type": "string" } } From 3d73fb7bf542acb632930456bb16148cdce31dc1 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Fri, 19 Jul 2024 16:25:47 -0400 Subject: [PATCH 68/91] When a Report is sent to deadletter we'd like to know what validations were used. So, added a new property, "validationSchemas" that is an array of Strings containing the filename of the schema(s) used for validation. --- .../ocio/processingstatusapi/ReportManager.kt | 4 +- .../models/DeadLetterReport.kt | 37 +++------------- .../ocio/processingstatusapi/models/Report.kt | 2 +- .../plugins/ServiceBusProcessor.kt | 44 ++++++++++--------- 4 files changed, 34 insertions(+), 53 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 26a6f663..a78751df 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 @@ -216,7 +216,8 @@ class ReportManager: KoinComponent { dispositionType: DispositionType, contentType: String?, content: Any?, - deadLetterReasons: List + deadLetterReasons: List, + validationSchemaFileNames:List ): String { val deadLetterReportId = UUID.randomUUID().toString() @@ -230,6 +231,7 @@ class ReportManager: KoinComponent { this.dispositionType= dispositionType.toString() this.contentType = contentType this.deadLetterReasons= deadLetterReasons + this.validationSchemas= validationSchemaFileNames if (contentType?.lowercase() == "json" && !isNullOrEmpty(content) && !isBase64Encoded(content.toString())) { val typeObject = object : TypeToken?>() {}.type val jsonMap: Map = gson.fromJson(Gson().toJson(content, MutableMap::class.java).toString(), typeObject) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt index 0de9b983..18918218 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt @@ -18,37 +18,12 @@ import java.util.* * @property content String? * @property deadLetterReasons List */ -data class ReportDeadLetter( + class ReportDeadLetter : Report() { - var id : String? = null, + @SerializedName("disposition_type") + var dispositionType: String? = null - @SerializedName("upload_id") - var uploadId: String? = null, + var deadLetterReasons: List? = null - @SerializedName("report_id") - var reportId: String? = null, - - @SerializedName("data_stream_id") - var dataStreamId: String? = null, - - @SerializedName("data_stream_route") - var dataStreamRoute: String? = null, - - @SerializedName("stage_name") - var stageName: String? = null, - - @SerializedName("disposition_type") - var dispositionType: String? = null, - - - @SerializedName("content_type") - var contentType : String? = null, - - var content: Any? = null, - - val dexIngestDateTime: LocalDateTime = LocalDateTime.now(), - - val timestamp: Date = Date(), - - var deadLetterReasons:List? = null -) + var validationSchemas: List? = null +} 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 84a96e4b..bd61e51e 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 @@ -18,7 +18,7 @@ import java.util.* * @property content String? * @property timestamp Date */ -data class Report( +open class Report( var id : String? = null, 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 9bec4fdf..ea7edbbd 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 @@ -113,6 +113,7 @@ class ServiceBusProcessor { */ private fun validateJsonSchema(message: ServiceBusReceivedMessage){ val invalidData = mutableListOf() + val schemaFileNames = mutableListOf() var reason: String //TODO : this needs to be replaced with more robust source or a URL of some sorts val schemaDirectoryPath = "/schema" @@ -143,15 +144,15 @@ class ServiceBusProcessor { val schemaFile = File(schemaFilePath.toURI()) if (!schemaFile.exists()) { reason ="Report rejected: Schema file not found for base schema version $reportSchemaVersion." - processError(reason,invalidData,createReportMessage) + processError(fileName,reason,invalidData,schemaFileNames,createReportMessage) } //Validate report schema version schema - validateSchema(jsonNode,schemaFile,objectMapper,invalidData,createReportMessage) + validateSchema(fileName,jsonNode,schemaFile,objectMapper,invalidData,schemaFileNames,createReportMessage) // Check if the content_type is JSON val contentTypeNode = jsonNode.get("content_type") if (contentTypeNode == null) { reason="Report rejected: `content_type` is not JSON or is missing." - processError(reason,invalidData,createReportMessage) + processError(fileName,reason,invalidData,schemaFileNames,createReportMessage) } else{ if(!isJsonMimeType(contentTypeNode.asText())) @@ -164,7 +165,7 @@ class ServiceBusProcessor { val contentNode = jsonNode.get("content") if (contentNode == null) { reason="Report rejected: `content` is not JSON or is missing." - processError(reason,invalidData,createReportMessage) + processError(fileName,reason,invalidData, schemaFileNames,createReportMessage) } // Check for `content_schema_name` and `content_schema_version` val contentSchemaNameNode = contentNode.get("content_schema_name") @@ -173,7 +174,7 @@ class ServiceBusProcessor { contentSchemaVersionNode == null || contentSchemaVersionNode.asText().isEmpty() ) { reason= "Report rejected: `content_schema_name` or `content_schema_version` is missing or empty." - processError(reason,invalidData,createReportMessage) + processError(fileName,reason,invalidData, schemaFileNames,createReportMessage) } //ContentSchema validation val contentSchemaName = contentSchemaNameNode.asText() @@ -188,15 +189,12 @@ class ServiceBusProcessor { val contentSchemaFile = File(contentSchemaFilePath.toURI()) if (!contentSchemaFile .exists()) { reason ="Report rejected: Content schema file not found for content schema name $contentSchemaName and schema version $contentSchemaVersion." - processError(reason,invalidData,createReportMessage) + processError(contentSchemaFileName,reason,invalidData,schemaFileNames,createReportMessage) } //Validate content schema - validateSchema(contentNode,contentSchemaFile,objectMapper,invalidData, createReportMessage) + validateSchema(contentSchemaFileName,contentNode,contentSchemaFile,objectMapper,invalidData, schemaFileNames, createReportMessage) + - if (invalidData.isNotEmpty()) { - reason ="Validation of the json schema failed" - processError(reason,invalidData,createReportMessage) - } } catch (e: Exception) { LOGGER.error("Report rejected: Malformed JSON or error processing the report - ${e.message}") @@ -209,7 +207,7 @@ class ServiceBusProcessor { * @param invalidData MutableList * @param createReportMessage CreateReportSBMessage */ - private fun sendToDeadLetter(invalidData:MutableList, createReportMessage: CreateReportSBMessage){ + private fun sendToDeadLetter(invalidData:MutableList, validationSchemaFileNames:MutableList, createReportMessage: CreateReportSBMessage){ if (invalidData.isNotEmpty()) { //This should not run for unit tests if (System.getProperty("isTestEnvironment") != "true") { @@ -222,7 +220,8 @@ class ServiceBusProcessor { createReportMessage.dispositionType, createReportMessage.contentType, createReportMessage.content, - invalidData + invalidData, + validationSchemaFileNames ) } throw BadRequestException(invalidData.joinToString(separator = ",")) @@ -234,10 +233,14 @@ class ServiceBusProcessor { * @param invalidData MutableList * @param createReportMessage CreateReportSBMessage */ - private fun processError(reason:String, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { - LOGGER.error(reason) - invalidData.add(reason) - sendToDeadLetter(invalidData, createReportMessage) + private fun processError(schemaFileName:String, reason:String, invalidData:MutableList, validationSchemaFileNames:MutableList, + createReportMessage: CreateReportSBMessage) { + + validationSchemaFileNames.add(schemaFileName) + val updatedReason = reason + "Filename(s) used for validation: " + validationSchemaFileNames.joinToString(separator = ",") + LOGGER.error(updatedReason) + invalidData.add(updatedReason) + sendToDeadLetter(invalidData, validationSchemaFileNames, createReportMessage) } /** @@ -246,7 +249,8 @@ class ServiceBusProcessor { * @param objectMapper ObjectMapper */ - private fun validateSchema(jsonNode:JsonNode, schemaFile:File, objectMapper: ObjectMapper, invalidData:MutableList, createReportMessage: CreateReportSBMessage) { + private fun validateSchema(schemaFileName: String, jsonNode:JsonNode, schemaFile:File, objectMapper: ObjectMapper, invalidData:MutableList, + validationSchemaFileNames: MutableList, createReportMessage: CreateReportSBMessage) { val schemaNode: JsonNode = objectMapper.readTree(schemaFile) val schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) val schema: JsonSchema = schemaFactory.getSchema(schemaNode) @@ -255,9 +259,9 @@ class ServiceBusProcessor { if (schemaValidationMessages.isEmpty()) { LOGGER.info("JSON is valid against the content schema $schema.") } else { - val reason ="JSON is invalid against the content schema $schema.Errors:" + val reason ="JSON is invalid against the content schema $schemaFileName." schemaValidationMessages.forEach { invalidData.add(it.message) } - processError(reason, invalidData,createReportMessage) + processError(schemaFileName,reason, invalidData,validationSchemaFileNames,createReportMessage) } } From 77c1d3cf0021153713abe917e532e22df5562034 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Tue, 23 Jul 2024 12:37:10 -0500 Subject: [PATCH 69/91] DASB-582 - Added optional sort ordering to the query for getting raw reports (#132) * Added optional sort ordering to the query for getting raw reports * Updated the graphql descriptions for the fields. --------- Co-authored-by: Matt B Krystof --- .../loaders/ReportLoader.kt | 31 +++++++++++++++++-- .../processingstatusapi/models/SortOrder.kt | 9 ++++++ .../queries/ReportQueryService.kt | 18 +++++++++-- 3 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/SortOrder.kt diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt index 3745a8e7..bfc75c66 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt @@ -1,9 +1,11 @@ package gov.cdc.ocio.processingstatusapi.loaders import com.azure.cosmos.models.CosmosQueryRequestOptions +import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.models.Report import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao import gov.cdc.ocio.processingstatusapi.models.DataStream +import gov.cdc.ocio.processingstatusapi.models.SortOrder import graphql.schema.DataFetchingEnvironment import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* @@ -21,9 +23,15 @@ class ReportLoader: CosmosLoader() { * * @param dataFetchingEnvironment DataFetchingEnvironment * @param uploadId String + * @param reportsSortedBy String? + * @param sortOrder SortOrder? * @return List */ - fun getByUploadId(dataFetchingEnvironment: DataFetchingEnvironment, uploadId: String): List { + fun getByUploadId(dataFetchingEnvironment: DataFetchingEnvironment, + uploadId: String, + reportsSortedBy: String?, + sortOrder: SortOrder? + ): List { // Obtain the data streams available to the user from the data fetching env. val authContext = dataFetchingEnvironment.graphQlContext.get("AuthContext") var dataStreams: List? = null @@ -32,10 +40,27 @@ class ReportLoader: CosmosLoader() { dataStreams = principal?.payload?.getClaim("dataStreams")?.asList(DataStream::class.java) } - val reportsSqlQuery = "select * from $reportsContainerName r where r.uploadId = '$uploadId'" + val reportsSqlQuery = StringBuilder() + reportsSqlQuery.append("select * from $reportsContainerName r where r.uploadId = '$uploadId'") + when (reportsSortedBy) { + "timestamp" -> { + val sortOrderVal = when (sortOrder) { + SortOrder.Ascending -> "asc" + SortOrder.Descending -> "desc" + else -> "asc" // default + } + reportsSqlQuery.append(" order by r.timestamp $sortOrderVal") + } + null -> { + // nothing to sort by + } + else -> { + throw BadRequestException("Reports can not be sorted by '$reportsSortedBy'") + } + } val reportItems = reportsContainer?.queryItems( - reportsSqlQuery, CosmosQueryRequestOptions(), + reportsSqlQuery.toString(), CosmosQueryRequestOptions(), ReportDao::class.java ) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/SortOrder.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/SortOrder.kt new file mode 100644 index 00000000..a8a96687 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/SortOrder.kt @@ -0,0 +1,9 @@ +package gov.cdc.ocio.processingstatusapi.models + +/** + * Enumeration of the possible sort orders for queries. + */ +enum class SortOrder { + Ascending, + Descending +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportQueryService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportQueryService.kt index b3e39b16..90fbba51 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportQueryService.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/queries/ReportQueryService.kt @@ -5,6 +5,7 @@ import com.expediagroup.graphql.server.operations.Query import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDataLoader import gov.cdc.ocio.processingstatusapi.models.Report import gov.cdc.ocio.processingstatusapi.loaders.ReportLoader +import gov.cdc.ocio.processingstatusapi.models.SortOrder import graphql.schema.DataFetchingEnvironment import java.util.concurrent.CompletableFuture @@ -15,12 +16,25 @@ class ReportQueryService : Query { * * @param dataFetchingEnvironment DataFetchingEnvironment * @param uploadId String + * @param reportsSortedBy String? + * @param sortOrder SortOrder? * @return List */ - @GraphQLDescription("Return all the reports associated with the provided uploadId") + @GraphQLDescription("Return all the reports associated with the provided upload ID.") @Suppress("unused") fun getReports(dataFetchingEnvironment: DataFetchingEnvironment, - uploadId: String) = ReportLoader().getByUploadId(dataFetchingEnvironment, uploadId) + @GraphQLDescription("Upload ID to retrieve all the reports for.") + uploadId: String, + @GraphQLDescription("Optional field to specify the field reports should be sorted by. Available fields for sorting are: [`timestamp`].") + reportsSortedBy: String?, + @GraphQLDescription("Optional sort order. When `reportsSortedBy` is provided, the available options are `Ascending` or `Descending`, which defaults to `Ascending` if not provided.") + sortOrder: SortOrder?) = ReportLoader() + .getByUploadId( + dataFetchingEnvironment, + uploadId, + reportsSortedBy, + sortOrder + ) /** * Searches for reports with the given search parameters. From 0d0bc83204305499541b16e5c8514c55d63a7a29 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Tue, 23 Jul 2024 16:39:17 -0400 Subject: [PATCH 70/91] Added a new sys env var to add a switch to disable the validation Added overloaded methods for sendToDeadLetter and CreateReport Added a new property DeadLetterReason in DeadLetterReport.kt --- .../ocio/processingstatusapi/ReportManager.kt | 44 +++++++++++------ .../models/DeadLetterReport.kt | 5 +- .../plugins/ServiceBusProcessor.kt | 49 ++++++++++++++++--- 3 files changed, 74 insertions(+), 24 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 a78751df..7786a695 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 @@ -11,11 +11,9 @@ import gov.cdc.ocio.processingstatusapi.cosmos.CosmosDeadLetterRepository import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException import gov.cdc.ocio.processingstatusapi.exceptions.BadStateException -import gov.cdc.ocio.processingstatusapi.exceptions.InvalidSchemaDefException import gov.cdc.ocio.processingstatusapi.models.DispositionType import gov.cdc.ocio.processingstatusapi.models.Report import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter -import gov.cdc.ocio.processingstatusapi.models.reports.SchemaDefinition import gov.cdc.ocio.processingstatusapi.models.reports.Source import mu.KotlinLogging import org.koin.core.component.KoinComponent @@ -195,6 +193,7 @@ class ReportManager: KoinComponent { } return createReportItem(uploadId,stageReportId,stageReport) } + /** * Creates a dead-letter report if there is a malformed data or missing required fields * @@ -210,15 +209,15 @@ class ReportManager: KoinComponent { @Throws(BadStateException::class) fun createDeadLetterReport(uploadId: String?, - dataStreamId: String?, - dataStreamRoute: String?, - stageName: String?, - dispositionType: DispositionType, - contentType: String?, - content: Any?, - deadLetterReasons: List, - validationSchemaFileNames:List - ): String { + dataStreamId: String?, + dataStreamRoute: String?, + stageName: String?, + dispositionType: DispositionType, + contentType: String?, + content: Any?, + deadLetterReasons: List, + validationSchemaFileNames:List + ): String { val deadLetterReportId = UUID.randomUUID().toString() val deadLetterReport = ReportDeadLetter().apply { @@ -239,7 +238,25 @@ class ReportManager: KoinComponent { } else this.content = content } - return createReportItem(uploadId,deadLetterReportId,deadLetterReport) + return createReportItem(uploadId,deadLetterReportId,deadLetterReport) + } + + /** + * Creates a dead-letter report if there is a malformed JSON. This is called if DISABLE_VALIDATION is true + * + * @param deadLetterReason String + * @return String + * @throws BadStateException + */ + + @Throws(BadStateException::class) + fun createDeadLetterReport(deadLetterReason: String): String { + val deadLetterReportId = UUID.randomUUID().toString() + val deadLetterReport = ReportDeadLetter().apply { + this.id = deadLetterReportId + this.deadLetterReason = deadLetterReason + } + return createReportItem(null,deadLetterReportId,deadLetterReport) } /** @@ -361,9 +378,6 @@ class ReportManager: KoinComponent { throw BadStateException("Failed to create dead-letterReport reportId = ${responseReportId}, uploadId = $uploadId") } - - - companion object { const val DEFAULT_RETRY_INTERVAL_MILLIS = 500L const val MAX_RETRY_ATTEMPTS = 100 diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt index 18918218..f38bc0bd 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt @@ -2,8 +2,7 @@ package gov.cdc.ocio.processingstatusapi.models import com.google.gson.annotations.SerializedName -import java.time.LocalDateTime -import java.util.* + /** * Dead-LetterReport when there is missing fields or malformed data. @@ -25,5 +24,7 @@ import java.util.* var deadLetterReasons: List? = null + var deadLetterReason: String? = null + var validationSchemas: List? = null } 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 ea7edbbd..2a492072 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 @@ -58,7 +58,17 @@ class ServiceBusProcessor { sbMessage = sbMessage.replace("event_type", "data_stream_route") } logger.info { "After Message received = $sbMessage" } - validateJsonSchema(message) + val disableValidation = System.getenv("DISABLE_VALIDATION")?.toBoolean() ?: false + + if(disableValidation){ + val isValid = isJsonValid(sbMessage) + if(!isValid){ + sendToDeadLetter("Validation failed.The message is not in JSON format.") + } + } + else { + validateJsonSchema(message) + } createReport(sbMessageId, sbMessageStatus, gson.fromJson(sbMessage, CreateReportSBMessage::class.java)) } catch (e: BadRequestException) { logger.error("Validation failed: ${e.message}") @@ -136,8 +146,8 @@ class ServiceBusProcessor { } else { reportSchemaVersion = reportSchemaVersionNode.asText() } - val fileName ="base.$reportSchemaVersion.schema.json" - val schemaFilePath = javaClass.getResource( "$schemaDirectoryPath/$fileName") + val fileName ="base.$reportSchemaVersion.schema.json" + val schemaFilePath = javaClass.getResource( "$schemaDirectoryPath/$fileName") ?: throw IllegalArgumentException("File not found: $fileName") // Attempt to load the schema @@ -182,8 +192,8 @@ class ServiceBusProcessor { //TODO Will this be from the same source??? val contentSchemaFileName ="$contentSchemaName.$contentSchemaVersion.schema.json" - val contentSchemaFilePath =javaClass.getResource( "$schemaDirectoryPath/$contentSchemaFileName") - ?: throw IllegalArgumentException("File not found: $contentSchemaFileName") + val contentSchemaFilePath =javaClass.getResource( "$schemaDirectoryPath/$contentSchemaFileName") + ?: throw IllegalArgumentException("File not found: $contentSchemaFileName") // Attempt to load the schema val contentSchemaFile = File(contentSchemaFilePath.toURI()) @@ -194,8 +204,6 @@ class ServiceBusProcessor { //Validate content schema validateSchema(contentSchemaFileName,contentNode,contentSchemaFile,objectMapper,invalidData, schemaFileNames, createReportMessage) - - } catch (e: Exception) { LOGGER.error("Report rejected: Malformed JSON or error processing the report - ${e.message}") throw e @@ -227,6 +235,20 @@ class ServiceBusProcessor { throw BadRequestException(invalidData.joinToString(separator = ",")) } } + + /** + * Function to send the invalid data reasons to the deadLetter queue + * @param reason String + */ + private fun sendToDeadLetter(reason:String){ + //This should not run for unit tests + if (System.getProperty("isTestEnvironment") != "true") { + // Write the content of the dead-letter reports to CosmosDb + ReportManager().createDeadLetterReport(reason) + throw BadRequestException(reason) + } + } + /** * Function to process the error by logging it and adding to the invalidData list and sending it to deadletter * @param reason String @@ -278,4 +300,17 @@ class ServiceBusProcessor { } } + /** + * Function to check whether the message is in JSON format or not + */ + private fun isJsonValid(jsonString: String): Boolean { + return try { + val mapper = jacksonObjectMapper() + mapper.readTree(jsonString) + true + } catch (e: Exception) { + false + } + } + } \ No newline at end of file From 9f0cf7f3d81e668f2b20549f1522fe45b0d128bc Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Tue, 23 Jul 2024 17:04:18 -0400 Subject: [PATCH 71/91] Updated the version --- pstatus-report-sink-ktor/src/main/resources/application.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index a7167772..c05a38e5 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -8,7 +8,7 @@ ktor { modules = [ gov.cdc.ocio.processingstatusapi.ApplicationKt.module ] } - version = "0.0.2" + version = "0.0.3" } azure { From 20cd51d191951e1b138891687717dc1248099b9a Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Tue, 23 Jul 2024 17:10:37 -0400 Subject: [PATCH 72/91] listOf(deadLetterReason) and got rid of the deadLetterReason. --- .../kotlin/gov/cdc/ocio/processingstatusapi/ReportManager.kt | 2 +- .../gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt | 2 -- 2 files changed, 1 insertion(+), 3 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 7786a695..84e68953 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 @@ -254,7 +254,7 @@ class ReportManager: KoinComponent { val deadLetterReportId = UUID.randomUUID().toString() val deadLetterReport = ReportDeadLetter().apply { this.id = deadLetterReportId - this.deadLetterReason = deadLetterReason + this.deadLetterReasons = listOf(deadLetterReason) } return createReportItem(null,deadLetterReportId,deadLetterReport) } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt index f38bc0bd..5016dcef 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/DeadLetterReport.kt @@ -24,7 +24,5 @@ import com.google.gson.annotations.SerializedName var deadLetterReasons: List? = null - var deadLetterReason: String? = null - var validationSchemas: List? = null } From d1019443b08277532bee9b2b85ef573168664280 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 24 Jul 2024 11:20:58 -0400 Subject: [PATCH 73/91] Removed the code that sends to deadletter queue only need to store in cosmosdb as per discussion with Matt on 07/24/24 --- pstatus-report-sink-ktor/build.gradle | 2 +- .../gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt | 5 ----- .../plugins/sample_reports/Report-valid.json | 3 ++- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/pstatus-report-sink-ktor/build.gradle b/pstatus-report-sink-ktor/build.gradle index af48b00b..d3744a38 100644 --- a/pstatus-report-sink-ktor/build.gradle +++ b/pstatus-report-sink-ktor/build.gradle @@ -94,7 +94,7 @@ test { events "passed", "skipped", "failed" } //Change this to "true" if we want to execute unit tests - systemProperty("isTestEnvironment", "true") + systemProperty("isTestEnvironment", "false") // Set the test classpath, if required } diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt index 1c8a1492..48a87990 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/ServiceBus.kt @@ -128,11 +128,6 @@ private fun processMessage(context: ServiceBusReceivedMessageContext) { //This will handle all missing required fields, invalid schema definition and malformed json all under the BadRequest exception and writes to dead-letter queue or topics depending on the context catch (e: BadRequestException) { LOGGER.warn("Unable to parse the message: {}", e.localizedMessage) - val deadLetterOptions = DeadLetterOptions() - .setDeadLetterReason("Validation failed") - .setDeadLetterErrorDescription(e.message) - context.deadLetter(deadLetterOptions) - LOGGER.info("Message sent to the dead-letter queue.") } catch (e: Exception) { LOGGER.warn("Failed to process service bus message: {}", e.localizedMessage) diff --git a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-valid.json b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-valid.json index 68d6441b..54975357 100644 --- a/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-valid.json +++ b/pstatus-report-sink-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/sample_reports/Report-valid.json @@ -3,11 +3,12 @@ "user_id": "test-event1", "data_stream_id": "celr", "data_stream_route": "hl7_out_recdeb", - "jurisdiction": "SMOKE", + "jurisdiction": "INTEGRATION", "sender_id": "APHL", "data_producer_id": "smoke-test-data-producer", "dex_ingest_timestamp": "2024-07-10T15:40:01Z", "status": "SUCCESS", + "stageName": "dex-upload", "messageMetadata": { "type": "object", "properties": { From 264e324ec25a5b25a2bfbe62a60d845969ac7dc1 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 24 Jul 2024 14:16:01 -0400 Subject: [PATCH 74/91] updated version --- pstatus-report-sink-ktor/src/main/resources/application.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index a7167772..0f386c6a 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -8,7 +8,7 @@ ktor { modules = [ gov.cdc.ocio.processingstatusapi.ApplicationKt.module ] } - version = "0.0.2" + version = "0.0.4" } azure { From bfd90835b2dae600f3d6cfe88c181f49cc18cf58 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 24 Jul 2024 14:19:44 -0400 Subject: [PATCH 75/91] updated version --- pstatus-report-sink-ktor/src/main/resources/application.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index a7167772..0f386c6a 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -8,7 +8,7 @@ ktor { modules = [ gov.cdc.ocio.processingstatusapi.ApplicationKt.module ] } - version = "0.0.2" + version = "0.0.4" } azure { From a7deff9c36fa49f9bbc53694b63bfdd49a410e8d Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Wed, 24 Jul 2024 14:45:36 -0400 Subject: [PATCH 76/91] changed the CD pipeline info --- .github/workflows/deploy-rc.yml | 2 +- .github/workflows/graphql-service.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-rc.yml b/.github/workflows/deploy-rc.yml index 4be179c2..dd542a87 100644 --- a/.github/workflows/deploy-rc.yml +++ b/.github/workflows/deploy-rc.yml @@ -10,6 +10,6 @@ jobs: staging: uses: ./.github/workflows/remote-cd-trigger-template.yml with: - WORKFLOW: build-and-deploy-rc.yml + WORKFLOW: aks-deployment-pstatus-graphql-stg.yml REF: '${{ github.ref_name }}' # Resolves to the tag that is pushed secrets: inherit \ No newline at end of file diff --git a/.github/workflows/graphql-service.yml b/.github/workflows/graphql-service.yml index aefa5807..37669bff 100644 --- a/.github/workflows/graphql-service.yml +++ b/.github/workflows/graphql-service.yml @@ -18,6 +18,6 @@ jobs: remote-trigger: uses: ./.github/workflows/remote-cd-trigger-template.yml with: - WORKFLOW: graphql-service.yml + WORKFLOW: aks-deployment-pstatus-graphql-dev-shared.yml REF: ${{ inputs.REF }} secrets: inherit \ No newline at end of file From dbd769676eac800f1e738deec2b9edb7f6e31d0b Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Wed, 24 Jul 2024 14:54:03 -0400 Subject: [PATCH 77/91] CI pipeline --- .github/workflows/remote-cd-trigger-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml index ba2df354..6dabb9b8 100644 --- a/.github/workflows/remote-cd-trigger-template.yml +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -25,7 +25,7 @@ jobs: - name: Generate Token run: | sudo gem install jwt - echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem + echo "${{ secrets.CDC_COE_BOTFREY_PEM_LATEST }}" > app-private-key.pem chmod +x ./get-github-app-access-token.sh; . ./get-github-app-access-token.sh; echo "access_token=${TOKEN}" >> "$GITHUB_ENV" From 9c3c3b0ddb72c3a030217baa722e93a6a87fcd19 Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Wed, 24 Jul 2024 14:58:50 -0400 Subject: [PATCH 78/91] CI pipeline --- .github/workflows/remote-cd-trigger-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml index 6dabb9b8..ba2df354 100644 --- a/.github/workflows/remote-cd-trigger-template.yml +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -25,7 +25,7 @@ jobs: - name: Generate Token run: | sudo gem install jwt - echo "${{ secrets.CDC_COE_BOTFREY_PEM_LATEST }}" > app-private-key.pem + echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem chmod +x ./get-github-app-access-token.sh; . ./get-github-app-access-token.sh; echo "access_token=${TOKEN}" >> "$GITHUB_ENV" From dc27be9cb41b4b141374a4fe45c994a77bf44b45 Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 24 Jul 2024 15:26:19 -0400 Subject: [PATCH 79/91] fixed issue with DeadLetterReport type not reachable due to ReportDeadLetter inheriting from Report so always the parent class was resolved. --- .../cdc/ocio/processingstatusapi/ReportManager.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 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 84e68953..4ba1a496 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 @@ -308,30 +308,32 @@ class ReportManager: KoinComponent { try { //use when here to determing whether report type is StageReport or DeadLetterReport when (reportType) { - is Report -> { - val response = cosmosRepository.reportsContainer?.createItem( + is ReportDeadLetter -> { + val response = cosmosDeadLetterRepository.reportsDeadLetterContainer?.createItem( reportType, PartitionKey(uploadId), CosmosItemRequestOptions()) isValidResponse = response!=null + reportTypeName ="dead-letter report" responseReportId = response?.item?.reportId ?: "0" statusCode = response?.statusCode recommendedDuration = response?.responseHeaders?.get("x-ms-retry-after-ms") - } - is ReportDeadLetter -> { - val response = cosmosDeadLetterRepository.reportsDeadLetterContainer?.createItem( + + is Report -> { + val response = cosmosRepository.reportsContainer?.createItem( reportType, PartitionKey(uploadId), CosmosItemRequestOptions()) isValidResponse = response!=null - reportTypeName ="dead-letter report" responseReportId = response?.item?.reportId ?: "0" statusCode = response?.statusCode recommendedDuration = response?.responseHeaders?.get("x-ms-retry-after-ms") + } + } logger.info("Creating ${reportTypeName}, response http status code = ${statusCode}, attempt = ${attempts + 1}, uploadId = $uploadId") if(isValidResponse){ From da6d24a3eaea42ffb3f29d9ca24c8812e8569d3f Mon Sep 17 00:00:00 2001 From: Manu Kesava Date: Wed, 24 Jul 2024 15:51:39 -0400 Subject: [PATCH 80/91] updated version --- pstatus-report-sink-ktor/src/main/resources/application.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-report-sink-ktor/src/main/resources/application.conf b/pstatus-report-sink-ktor/src/main/resources/application.conf index 0f386c6a..7a16ce97 100644 --- a/pstatus-report-sink-ktor/src/main/resources/application.conf +++ b/pstatus-report-sink-ktor/src/main/resources/application.conf @@ -8,7 +8,7 @@ ktor { modules = [ gov.cdc.ocio.processingstatusapi.ApplicationKt.module ] } - version = "0.0.4" + version = "0.0.5" } azure { From c4e2acf4170ef2df2e5d20927edc86544a2a8d32 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Wed, 24 Jul 2024 15:14:30 -0500 Subject: [PATCH 81/91] Fixed reports deadletter container name (#138) Co-authored-by: Matt B Krystof --- .../ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt index 590f3502..989a2ac7 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt @@ -9,7 +9,7 @@ open class CosmosDeadLetterLoader: KoinComponent { private val cosmosRepository by inject() - protected val reportsDeadLetterContainerName = "ReportsDeadLetter" + protected val reportsDeadLetterContainerName = "Reports-DeadLetter" protected val reportsDeadLetterContainer = cosmosRepository.reportsDeadLetterContainer From 9d63f19ba73b9a082a48cd46ea95c8a48cb040b9 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Wed, 24 Jul 2024 16:43:37 -0500 Subject: [PATCH 82/91] Simplified queries by eliminating container names that aren't needed (#139) Co-authored-by: Matt B Krystof --- .../loaders/CosmosDeadLetterLoader.kt | 2 -- .../loaders/CosmosLoader.kt | 2 -- .../loaders/ReportCountsLoader.kt | 34 +++++++++---------- .../loaders/ReportDeadLetterLoader.kt | 8 ++--- .../loaders/ReportLoader.kt | 4 +-- .../loaders/UploadStatsLoader.kt | 12 +++---- .../loaders/UploadStatusLoader.kt | 20 +++++------ 7 files changed, 39 insertions(+), 43 deletions(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt index 989a2ac7..9fd67c18 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosDeadLetterLoader.kt @@ -9,8 +9,6 @@ open class CosmosDeadLetterLoader: KoinComponent { private val cosmosRepository by inject() - protected val reportsDeadLetterContainerName = "Reports-DeadLetter" - protected val reportsDeadLetterContainer = cosmosRepository.reportsDeadLetterContainer protected val logger = KotlinLogging.logger {} diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosLoader.kt index a9c64357..6683f674 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/CosmosLoader.kt @@ -9,8 +9,6 @@ open class CosmosLoader: KoinComponent { private val cosmosRepository by inject() - protected val reportsContainerName = "Reports" - protected val reportsContainer = cosmosRepository.reportsContainer protected val logger = KotlinLogging.logger {} diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportCountsLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportCountsLoader.kt index a0ae2320..b61e8ebf 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportCountsLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportCountsLoader.kt @@ -28,7 +28,7 @@ class ReportCountsLoader: CosmosLoader() { val reportsSqlQuery = ( "select " + "count(1) as counts, r.stageName, r.content.schema_name, r.content.schema_version " - + "from $reportsContainerName r where r.uploadId = '$uploadId' " + + "from r where r.uploadId = '$uploadId' " + "group by r.stageName, r.content.schema_name, r.content.schema_version" ) val reportItems = reportsContainer?.queryItems( @@ -39,7 +39,7 @@ class ReportCountsLoader: CosmosLoader() { // Order by timestamp (ascending) and grab the first one found, which will give us the earliest timestamp. val firstReportSqlQuery = ( - "select * from $reportsContainerName r where r.uploadId = '$uploadId' " + "select * from r where r.uploadId = '$uploadId' " + "order by r.timestamp asc offset 0 limit 1" ) @@ -92,7 +92,7 @@ class ReportCountsLoader: CosmosLoader() { "select " + "r.uploadId, r.stageName, SUM(r.content.stage.report.number_of_messages) as numberOfMessages, " + "SUM(r.content.stage.report.number_of_messages_not_propagated) as numberOfMessagesNotPropagated " - + "from $reportsContainerName r " + + "from r " + "where r.content.schema_name = '$hl7DebatchSchemaName' and r.uploadId in ($quotedUploadIds) " + "group by r.uploadId, r.stageName" ) @@ -114,7 +114,7 @@ class ReportCountsLoader: CosmosLoader() { + "r.uploadId, r.stageName, " + "count(contains(upper(r.content.summary.current_status), 'VALID_') ? 1 : undefined) as valid, " + "count(not contains(upper(r.content.summary.current_status), 'VALID_') ? 1 : undefined) as invalid " - + "from $reportsContainerName r " + + "from r " + "where r.content.schema_name = '$hl7ValidationSchemaName' and r.uploadId in ($quotedUploadIds) " + "group by r.uploadId, r.stageName" ) @@ -202,7 +202,7 @@ class ReportCountsLoader: CosmosLoader() { uploadIdCountSqlQuery.append( "select " + "value count(1) " - + "from (select distinct r.uploadId from $reportsContainerName r " + + "from (select distinct r.uploadId from r " + "where r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause)" ) @@ -224,7 +224,7 @@ class ReportCountsLoader: CosmosLoader() { uploadIdsSqlQuery.append( "select " + "distinct value r.uploadId " - + "from $reportsContainerName r " + + "from r " + "where r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and " + "$timeRangeWhereClause offset $offset limit $pageSizeAsInt" ) @@ -241,7 +241,7 @@ class ReportCountsLoader: CosmosLoader() { val reportsSqlQuery = ( "select " + "r.uploadId, r.content.schema_name, r.content.schema_version, MIN(r.timestamp) as timestamp, count(r.stageName) as counts, r.stageName " - + "from $reportsContainerName r where r.uploadId in ($quotedUploadIds) " + + "from r where r.uploadId in ($quotedUploadIds) " + "group by r.uploadId, r.stageName, r.content.schema_name, r.content.schema_version" ) val reportItems = reportsContainer?.queryItems( @@ -328,7 +328,7 @@ class ReportCountsLoader: CosmosLoader() { val reportsSqlQuery = ( "select " + "value count(not contains(upper(r.content.summary.current_status), 'VALID_') ? 1 : undefined) " - + "from $reportsContainerName r " + + "from r " + "where r.content.schema_name = '${HL7Validation.schemaDefinition.schemaName}' and " + "r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause" ) @@ -366,7 +366,7 @@ class ReportCountsLoader: CosmosLoader() { val numCompletedUploadingSqlQuery = ( "select " + "value count(1) " - + "from $reportsContainerName r " + + "from r " + "where r.content.schema_name = 'upload' and r.content['offset'] = r.content.size and " + "r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause" ) @@ -380,7 +380,7 @@ class ReportCountsLoader: CosmosLoader() { val numUploadingSqlQuery = ( "select " + "value count(1) " - + "from $reportsContainerName r " + + "from r " + "where r.content.schema_name = 'upload' and r.content['offset'] != r.content.size and " + "r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause" ) @@ -394,7 +394,7 @@ class ReportCountsLoader: CosmosLoader() { val numFailedSqlQuery = ( "select " + "value count(1) " - + "from $reportsContainerName r " + + "from r " + "where r.content.schema_name = 'dex-metadata-verify' and r.content.issues != null and " + "r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause" ) @@ -435,14 +435,14 @@ class ReportCountsLoader: CosmosLoader() { val directMessageQuery = ( "select value SUM(directCounts) " - + "from (select value SUM(r.content.stage.report.number_of_messages) from $reportsContainerName r " + + "from (select value SUM(r.content.stage.report.number_of_messages) from r " + "where r.content.schema_name = '${HL7Debatch.schemaDefinition.schemaName}' and " + "r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause) as directCounts" ) val indirectMessageQuery = ( "select value count(redactedCount) from ( " - + "select * from $reportsContainerName r where r.content.schema_name = '${HL7Redactor.schemaDefinition.schemaName}' and " + + "select * from r where r.content.schema_name = '${HL7Redactor.schemaDefinition.schemaName}' and " + "r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause) as redactedCount" ) @@ -488,7 +488,7 @@ class ReportCountsLoader: CosmosLoader() { val directInvalidMessageQuery = ( "select value SUM(directCounts) " - + " FROM (select value SUM(r.content.stage.report.number_of_messages) from $reportsContainerName r " + + " FROM (select value SUM(r.content.stage.report.number_of_messages) from r " + " where r.content.schema_name = '${HL7Redactor.schemaDefinition.schemaName}' and " + " r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause) as directCounts" ) @@ -496,14 +496,14 @@ class ReportCountsLoader: CosmosLoader() { val directStructureInvalidMessageQuery = ( "select " + "value count(not contains(upper(r.content.summary.current_status), 'VALID_') ? 1 : undefined) " - + "from $reportsContainerName r " + + "from r " + "where r.content.schema_name = '${HL7Validation.schemaDefinition.schemaName}' and " + "r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause" ) val indirectInvalidMessageQuery = ( "select value count(invalidCounts) from (" - + "select * from $reportsContainerName r where r.content.schema_name != 'HL7-JSON-LAKE-TRANSFORMER' and " + + "select * from r where r.content.schema_name != 'HL7-JSON-LAKE-TRANSFORMER' and " + "r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause) as invalidCounts" ) @@ -562,7 +562,7 @@ class ReportCountsLoader: CosmosLoader() { val rollupCountsQuery = ( "select " + "r.content.schema_name, r.content.schema_version, count(r.stageName) as counts, r.stageName " - + "from $reportsContainerName r where r.dataStreamId = '$dataStreamId' and " + + "from r where r.dataStreamId = '$dataStreamId' and " + "r.dataStreamRoute = '$dataStreamRoute' and $timeRangeWhereClause " + "group by r.stageName, r.content.schema_name, r.content.schema_version" ) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt index f889db4c..1ec321a7 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -19,7 +19,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { * @param uploadId String */ fun getByUploadId(uploadId: String): List { - val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.id = '$uploadId'" + val reportsSqlQuery = "select * from r where r.id = '$uploadId'" val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), @@ -44,7 +44,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed val timeRangeWhereClause = SqlClauseBuilder().buildSqlClauseForDateRange(daysInterval, getFormattedDateAsString(startDate), getFormattedDateAsString(endDate)) - val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + + val reportsSqlQuery = "select * from r where r.dataStreamId = '$dataStreamId' " + "and r.dataStreamRoute= '$dataStreamRoute' " + "and $timeRangeWhereClause" @@ -74,7 +74,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { val timeRangeWhereClause = SqlClauseBuilder().buildSqlClauseForDateRange(daysInterval, startDate, endDate) - val reportsSqlQuery = "select value count(1) from $reportsDeadLetterContainerName r where r.dataStreamId = '$dataStreamId' " + + val reportsSqlQuery = "select value count(1) from r where r.dataStreamId = '$dataStreamId' " + "and $timeRangeWhereClause " + if (dataStreamRoute!=null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" val reportItems = reportsDeadLetterContainer?.queryItems( @@ -95,7 +95,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { fun search(ids: List): List { val quotedIds = ids.joinToString("\",\"", "\"", "\"") - val reportsSqlQuery = "select * from $reportsDeadLetterContainerName r where r.id in ($quotedIds)" + val reportsSqlQuery = "select * from r where r.id in ($quotedIds)" val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt index bfc75c66..be4f7d94 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportLoader.kt @@ -41,7 +41,7 @@ class ReportLoader: CosmosLoader() { } val reportsSqlQuery = StringBuilder() - reportsSqlQuery.append("select * from $reportsContainerName r where r.uploadId = '$uploadId'") + reportsSqlQuery.append("select * from r where r.uploadId = '$uploadId'") when (reportsSortedBy) { "timestamp" -> { @@ -87,7 +87,7 @@ class ReportLoader: CosmosLoader() { fun search(ids: List): List { val quotedIds = ids.joinToString("\",\"", "\"", "\"") - val reportsSqlQuery = "select * from $reportsContainerName r where r.id in ($quotedIds)" + val reportsSqlQuery = "select * from r where r.id in ($quotedIds)" val reportItems = reportsContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatsLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatsLoader.kt index 1b44bd87..d9bdec12 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatsLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatsLoader.kt @@ -24,14 +24,14 @@ class UploadStatsLoader: CosmosLoader() { ) val numUniqueUploadsQuery = ( - "select r.uploadId from $reportsContainerName r " + "select r.uploadId from r " + "where r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and " + "$timeRangeWhereClause group by r.uploadId" ) val numUploadsWithStatusQuery = ( "select value count(1) " - + "from $reportsContainerName r " + + "from r " + "where r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and " + "r.content.schema_name = 'upload' and " + timeRangeWhereClause @@ -39,7 +39,7 @@ class UploadStatsLoader: CosmosLoader() { val badMetadataCountQuery = ( "select value count(1) " - + "from $reportsContainerName r " + + "from r " + "where r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and " + "r.content.schema_name = 'dex-metadata-verify' and " + "ARRAY_LENGTH(r.content.issues) > 0 and $timeRangeWhereClause" @@ -47,7 +47,7 @@ class UploadStatsLoader: CosmosLoader() { val inProgressUploadsCountQuery = ( "select value count(1) " - + "from $reportsContainerName r " + + "from r " + "where r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = 'dataStreamRoute' and " + "r.content.schema_name = 'upload' and " + "r.content['offset'] < r.content['size'] and $timeRangeWhereClause" @@ -55,7 +55,7 @@ class UploadStatsLoader: CosmosLoader() { val completedUploadsCountQuery = ( "select value count(1) " - + "from $reportsContainerName r " + + "from r " + "where r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = '$dataStreamRoute' and " + "r.content.schema_name = 'upload' and " + "r.content['offset'] = r.content['size'] and $timeRangeWhereClause" @@ -64,7 +64,7 @@ class UploadStatsLoader: CosmosLoader() { val duplicateFilenameCountQuery = ( "select * from " + "(select r.content.metadata.filename, count(1) as totalCount " - + "from $reportsContainerName r " + + "from r " + "where r.dataStreamId = '$dataStreamId' and r.dataStreamRoute = 'dataStreamRoute' and " + "r.content.schema_name = 'dex-metadata-verify' and " + "$timeRangeWhereClause " diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoader.kt index d0c25b63..deb78c93 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoader.kt @@ -51,25 +51,25 @@ class UploadStatusLoader: CosmosLoader() { val pageSizeAsInt = pageUtils.getPageSize(pageSize) val sqlQuery = StringBuilder() - sqlQuery.append("from $reportsContainerName t where t.dataStreamId = '$dataStreamId'") + sqlQuery.append("from r where r.dataStreamId = '$dataStreamId'") dataStreamRoute?.run { - sqlQuery.append(" and t.dataStreamRoute = '$dataStreamRoute'") + sqlQuery.append(" and r.dataStreamRoute = '$dataStreamRoute'") } dateStart?.run { val dateStartEpochSecs = DateUtils.getEpochFromDateString(dateStart, "date_start") - sqlQuery.append(" and t._ts >= $dateStartEpochSecs") + sqlQuery.append(" and r._ts >= $dateStartEpochSecs") } dateEnd?.run { val dateEndEpochSecs = DateUtils.getEpochFromDateString(dateEnd, "date_end") - sqlQuery.append(" and t._ts < $dateEndEpochSecs") + sqlQuery.append(" and r._ts < $dateEndEpochSecs") } - sqlQuery.append(" group by t.uploadId") + sqlQuery.append(" group by r.uploadId") // Query for getting counts in the structure of UploadCounts object. Note the MAX aggregate which is used to - // get the latest timestamp from t._ts. - val countQuery = "select count(1) as reportCounts, t.uploadId, MAX(t._ts) as latestTimestamp $sqlQuery" + // get the latest timestamp from r._ts. + val countQuery = "select count(1) as reportCounts, r.uploadId, MAX(r._ts) as latestTimestamp $sqlQuery" logger.info("upload status count query = $countQuery") var totalItems = 0 @@ -117,10 +117,10 @@ class UploadStatusLoader: CosmosLoader() { // } // } // } -// sqlQuery.append(" order by t.$sortField $sortOrderVal") +// sqlQuery.append(" order by r.$sortField $sortOrderVal") // } val offset = (pageNumberAsInt - 1) * pageSize - val dataSqlQuery = "select count(1) as reportCounts, t.uploadId, MAX(t._ts) as latestTimestamp $sqlQuery offset $offset limit $pageSizeAsInt" + val dataSqlQuery = "select count(1) as reportCounts, r.uploadId, MAX(r._ts) as latestTimestamp $sqlQuery offset $offset limit $pageSizeAsInt" logger.info("upload status data query = $dataSqlQuery") val results = reportsContainer?.queryItems( dataSqlQuery, CosmosQueryRequestOptions(), @@ -130,7 +130,7 @@ class UploadStatusLoader: CosmosLoader() { results?.forEach { report -> val uploadId = report.uploadId ?: throw BadStateException("Upload ID unexpectedly null") - val reportsSqlQuery = "select * from $reportsContainerName t where t.uploadId = '$uploadId'" + val reportsSqlQuery = "select * from r where r.uploadId = '$uploadId'" logger.info("get reports for upload query = $reportsSqlQuery") val reportsForUploadId = reportsContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), From 18a0ec7a75bb5b582c6b054849080460929dc140 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Wed, 24 Jul 2024 17:24:48 -0500 Subject: [PATCH 83/91] Fixed query for getting the deadletter reports by upload ID (#140) Co-authored-by: Matt B Krystof --- .../loaders/ReportDeadLetterLoader.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt index 1ec321a7..d0385ed4 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -16,10 +16,11 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { /** * Function that returns a list of DeadLetterReports based on uploadId + * * @param uploadId String */ fun getByUploadId(uploadId: String): List { - val reportsSqlQuery = "select * from r where r.id = '$uploadId'" + val reportsSqlQuery = "select * from r where r.uploadId = '$uploadId'" val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), @@ -34,6 +35,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { /** * Function which returns list of ReportDeadLetter based on the specified parameters + * * @param dataStreamId String * @param dataStreamRoute String * @param startDate String @@ -46,7 +48,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { val reportsSqlQuery = "select * from r where r.dataStreamId = '$dataStreamId' " + "and r.dataStreamRoute= '$dataStreamRoute' " + - "and $timeRangeWhereClause" + "and $timeRangeWhereClause" val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), @@ -75,7 +77,7 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { val timeRangeWhereClause = SqlClauseBuilder().buildSqlClauseForDateRange(daysInterval, startDate, endDate) val reportsSqlQuery = "select value count(1) from r where r.dataStreamId = '$dataStreamId' " + - "and $timeRangeWhereClause " + if (dataStreamRoute!=null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" + "and $timeRangeWhereClause " + if (dataStreamRoute!=null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), @@ -109,9 +111,10 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { /** * Function which converts the inputted date to expected date format + * * @param inputDate String */ - private fun getFormattedDateAsString(inputDate:String?):String?{ + private fun getFormattedDateAsString(inputDate:String?): String? { if (inputDate == null) return null val inputDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") val outputDateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'") From c388f9ac4998e9c7a8bb381ea0804074b8bc121c Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Wed, 24 Jul 2024 18:39:53 -0500 Subject: [PATCH 84/91] Fixed issues with the graphql deadletter queries (#141) Co-authored-by: Matt B Krystof --- .../loaders/ReportDeadLetterLoader.kt | 70 ++++++++++++------- .../ocio/processingstatusapi/models/Report.kt | 2 +- .../models/ReportDeadLetter.kt | 59 ++++++++++++++-- .../models/dao/ReportDao.kt | 11 +-- .../models/dao/ReportDeadLetterDao.kt | 38 ++++++++++ 5 files changed, 139 insertions(+), 41 deletions(-) create mode 100644 pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDeadLetterDao.kt diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt index d0385ed4..f1ffc340 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/loaders/ReportDeadLetterLoader.kt @@ -2,7 +2,7 @@ package gov.cdc.ocio.processingstatusapi.loaders import com.azure.cosmos.models.CosmosQueryRequestOptions import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter -import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao +import gov.cdc.ocio.processingstatusapi.models.dao.ReportDeadLetterDao import gov.cdc.ocio.processingstatusapi.utils.SqlClauseBuilder import java.text.SimpleDateFormat import java.util.* @@ -24,13 +24,13 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), - ReportDao::class.java + ReportDeadLetterDao::class.java ) - val reports = mutableListOf() - reportItems?.forEach { reports.add(it.toReport() as ReportDeadLetter) } + val deadLetterReports = mutableListOf() + reportItems?.forEach { deadLetterReports.add(it.toReportDeadLetter()) } - return reports + return deadLetterReports } /** @@ -41,25 +41,36 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { * @param startDate String * @param endDate String */ - fun getByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String, startDate:String?, endDate:String?, daysInterval: Int?): List { - val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed - val timeRangeWhereClause = SqlClauseBuilder().buildSqlClauseForDateRange(daysInterval, getFormattedDateAsString(startDate), getFormattedDateAsString(endDate)) + fun getByDataStreamByDateRange( + dataStreamId: String, + dataStreamRoute: String, + startDate: String?, + endDate: String?, + daysInterval: Int? + ): List { + + val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed + val timeRangeWhereClause = SqlClauseBuilder().buildSqlClauseForDateRange( + daysInterval, + getFormattedDateAsString(startDate), + getFormattedDateAsString(endDate) + ) val reportsSqlQuery = "select * from r where r.dataStreamId = '$dataStreamId' " + "and r.dataStreamRoute= '$dataStreamRoute' " + "and $timeRangeWhereClause" - val reportItems = reportsDeadLetterContainer?.queryItems( - reportsSqlQuery, CosmosQueryRequestOptions(), - ReportDao::class.java - ) + val reportItems = reportsDeadLetterContainer?.queryItems( + reportsSqlQuery, CosmosQueryRequestOptions(), + ReportDeadLetterDao::class.java + ) - val reports = mutableListOf() - reportItems?.forEach { reports.add(it.toReport() as ReportDeadLetter) } + val deadLetterReports = mutableListOf() + reportItems?.forEach { deadLetterReports.add(it.toReportDeadLetter()) } - return reports - } + return deadLetterReports + } /** * Function which returns count of ReportDeadLetter items based on the specified parameters. @@ -69,7 +80,14 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { * @param startDate String * @param endDate String */ - fun getCountByDataStreamByDateRange(dataStreamId: String, dataStreamRoute:String?, startDate:String?, endDate:String?, daysInterval:Int?): Int { + fun getCountByDataStreamByDateRange( + dataStreamId: String, + dataStreamRoute: String?, + startDate: String?, + endDate: String?, + daysInterval: Int? + ): Int { + val logger = KotlinLogging.logger {} val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) formatter.timeZone = TimeZone.getTimeZone("UTC") // Set time zone if needed @@ -77,9 +95,9 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { val timeRangeWhereClause = SqlClauseBuilder().buildSqlClauseForDateRange(daysInterval, startDate, endDate) val reportsSqlQuery = "select value count(1) from r where r.dataStreamId = '$dataStreamId' " + - "and $timeRangeWhereClause " + if (dataStreamRoute!=null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" + "and $timeRangeWhereClause " + if (dataStreamRoute != null) " and r.dataStreamRoute= '$dataStreamRoute'" else "" - val reportItems = reportsDeadLetterContainer?.queryItems( + val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), Int::class.java ) @@ -101,12 +119,12 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { val reportItems = reportsDeadLetterContainer?.queryItems( reportsSqlQuery, CosmosQueryRequestOptions(), - ReportDao::class.java + ReportDeadLetterDao::class.java ) - val reports = mutableListOf() - reportItems?.forEach { reports.add(it.toReport() as ReportDeadLetter) } + val deadLetterReports = mutableListOf() + reportItems?.forEach { deadLetterReports.add(it.toReportDeadLetter()) } - return reports + return deadLetterReports } /** @@ -114,10 +132,10 @@ class ReportDeadLetterLoader : CosmosDeadLetterLoader() { * * @param inputDate String */ - private fun getFormattedDateAsString(inputDate:String?): String? { + private fun getFormattedDateAsString(inputDate: String?): String? { if (inputDate == null) return null val inputDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") - val outputDateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'") + val outputDateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'") val date: Date = inputDateFormat.parse(inputDate) val outputDateString = outputDateFormat.format(date) return outputDateString diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt index 67892547..617df49b 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/Report.kt @@ -18,7 +18,7 @@ import java.time.OffsetDateTime * @property timestamp OffsetDateTime */ @GraphQLDescription("Contains Report content.") -open class Report( +data class Report( @GraphQLDescription("Identifier of the report recorded by the database") var id : String? = null, diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt index 8acb8d69..9ccb21f8 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportDeadLetter.kt @@ -1,19 +1,70 @@ package gov.cdc.ocio.processingstatusapi.models import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import java.time.OffsetDateTime + /** - * Report for a given stage. + * DeadLetter report definition. * + * @property id String? * @property uploadId String? * @property reportId String? * @property dataStreamId String? * @property dataStreamRoute String? + * @property stageName String? * @property contentType String? * @property messageId String? * @property status String? - * @property content String? - * @property timestamp OffsetDateTime + * @property content Map<*, *>? + * @property timestamp OffsetDateTime? + * @property dispositionType String? + * @property deadLetterReasons List? + * @property validationSchemas List? + * @constructor */ @GraphQLDescription("Contains Report DeadLetter content.") -class ReportDeadLetter: Report() \ No newline at end of file +data class ReportDeadLetter( + + @GraphQLDescription("Identifier of the report recorded by the database") + var id : String? = null, + + @GraphQLDescription("Upload identifier this report belongs to") + var uploadId: String? = null, + + @GraphQLDescription("Unique report identifier") + var reportId: String? = null, + + @GraphQLDescription("Data stream ID") + var dataStreamId: String? = null, + + @GraphQLDescription("Data stream route") + var dataStreamRoute: String? = null, + + @GraphQLDescription("Stage name this report is associated with") + var stageName: String? = null, + + @GraphQLDescription("Indicates the content type of the content; e.g. JSON, XML") + var contentType : String? = null, + + @GraphQLDescription("Message id this report belongs to; set to null if not applicable") + var messageId: String? = null, + + @GraphQLDescription("Status this report is indicating, such as success or failure") + var status : String? = null, + + @GraphQLDescription("Content of the report. If the report is JSON then the content will be shown as JSON. Otherwise, the content is a base64 encoded string.") + var content : Map<*, *>? = null, + + @GraphQLDescription("Datestamp the report was recorded in the database") + var timestamp: OffsetDateTime? = null, + + @GraphQLDescription("Disposition type of the report") + var dispositionType: String? = null, + + @GraphQLDescription("List of reasons the report was sent to dead-letter") + var deadLetterReasons: List? = null, + + @GraphQLDescription("Schemas used to validate the report") + var validationSchemas: List? = null +) \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDao.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDao.kt index be2af221..738592b0 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDao.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDao.kt @@ -1,7 +1,6 @@ package gov.cdc.ocio.processingstatusapi.models.dao import com.google.gson.Gson -import com.google.gson.annotations.SerializedName import gov.cdc.ocio.processingstatusapi.models.Report import java.time.ZoneOffset import java.util.* @@ -23,32 +22,24 @@ import java.util.* * @property contentAsString String? * @constructor */ -data class ReportDao( +open class ReportDao( var id : String? = null, - @SerializedName("upload_id") var uploadId: String? = null, - @SerializedName("report_id") var reportId: String? = null, - @SerializedName("data_stream_id") var dataStreamId: String? = null, - @SerializedName("data_stream_route") var dataStreamRoute: String? = null, - @SerializedName("stage_name") var stageName: String? = null, - @SerializedName("content_type") var contentType : String? = null, - @SerializedName("message_id") var messageId: String? = null, - @SerializedName("status") var status : String? = null, var timestamp: Date? = null, diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDeadLetterDao.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDeadLetterDao.kt new file mode 100644 index 00000000..f1d6d8d5 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/dao/ReportDeadLetterDao.kt @@ -0,0 +1,38 @@ +package gov.cdc.ocio.processingstatusapi.models.dao + +import gov.cdc.ocio.processingstatusapi.models.ReportDeadLetter +import java.time.ZoneOffset + + +/** + * Data access object for dead-letter reports, which is the structure returned from CosmosDB queries. + */ +data class ReportDeadLetterDao( + + var dispositionType: String? = null, + + var deadLetterReasons: List? = null, + + var validationSchemas: List? = null, + +) : ReportDao() { + /** + * Convenience function to convert a cosmos data object to a ReportDeadLetter object + */ + fun toReportDeadLetter() = ReportDeadLetter().apply { + this.id = this@ReportDeadLetterDao.id + this.uploadId = this@ReportDeadLetterDao.uploadId + this.reportId = this@ReportDeadLetterDao.reportId + this.dataStreamId = this@ReportDeadLetterDao.dataStreamId + this.dataStreamRoute = this@ReportDeadLetterDao.dataStreamRoute + this.messageId = this@ReportDeadLetterDao.messageId + this.status = this@ReportDeadLetterDao.status + this.timestamp = this@ReportDeadLetterDao.timestamp?.toInstant()?.atOffset(ZoneOffset.UTC) + this.contentType = this@ReportDeadLetterDao.contentType + this.content = this@ReportDeadLetterDao.content as? Map<*, *> + this.dispositionType = this@ReportDeadLetterDao.dispositionType + this.deadLetterReasons = this@ReportDeadLetterDao.deadLetterReasons + this.validationSchemas = this@ReportDeadLetterDao.validationSchemas + } + +} \ No newline at end of file From bca1e91c56e05dad1b06e33ff9707d0e73e0dc55 Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Fri, 26 Jul 2024 11:28:31 -0400 Subject: [PATCH 85/91] add the sh command --- .github/workflows/remote-cd-trigger-template.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml index ba2df354..2383306a 100644 --- a/.github/workflows/remote-cd-trigger-template.yml +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -24,6 +24,7 @@ jobs: repository: kave/github-app-token - name: Generate Token run: | + sudo tree -df sudo gem install jwt echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem chmod +x ./get-github-app-access-token.sh; From c0bc69d977dfb5c0768f2d29adfdd2bffa420b84 Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Fri, 26 Jul 2024 11:31:42 -0400 Subject: [PATCH 86/91] add the sh command --- .github/workflows/remote-cd-trigger-template.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml index 2383306a..b149384f 100644 --- a/.github/workflows/remote-cd-trigger-template.yml +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -27,6 +27,7 @@ jobs: sudo tree -df sudo gem install jwt echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem + sudo tree -df chmod +x ./get-github-app-access-token.sh; . ./get-github-app-access-token.sh; echo "access_token=${TOKEN}" >> "$GITHUB_ENV" From 24e27fc5cc521dc6ef2568dce5b26b7d7733cc53 Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Fri, 26 Jul 2024 11:38:01 -0400 Subject: [PATCH 87/91] add the sh command --- .github/workflows/remote-cd-trigger-template.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml index b149384f..f301b547 100644 --- a/.github/workflows/remote-cd-trigger-template.yml +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -24,10 +24,8 @@ jobs: repository: kave/github-app-token - name: Generate Token run: | - sudo tree -df sudo gem install jwt - echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem - sudo tree -df + echo ${{ secrets.CDC_COE_BOTFREY_PEM }} > app-private-key.pem chmod +x ./get-github-app-access-token.sh; . ./get-github-app-access-token.sh; echo "access_token=${TOKEN}" >> "$GITHUB_ENV" From 5af021365e51b693427fafbffb5e5bba452bb97a Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Fri, 26 Jul 2024 11:40:56 -0400 Subject: [PATCH 88/91] add the sh command --- .github/workflows/remote-cd-trigger-template.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml index f301b547..3c4c4fcb 100644 --- a/.github/workflows/remote-cd-trigger-template.yml +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -25,7 +25,8 @@ jobs: - name: Generate Token run: | sudo gem install jwt - echo ${{ secrets.CDC_COE_BOTFREY_PEM }} > app-private-key.pem + x=${{ secrets.CDC_COE_BOTFREY_PEM }} + echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem chmod +x ./get-github-app-access-token.sh; . ./get-github-app-access-token.sh; echo "access_token=${TOKEN}" >> "$GITHUB_ENV" From 7a6a75f9836c7df6e4aca27c7c7e912d73cb0606 Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Fri, 26 Jul 2024 11:42:16 -0400 Subject: [PATCH 89/91] add the sh command --- .github/workflows/remote-cd-trigger-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml index 3c4c4fcb..db8a11d1 100644 --- a/.github/workflows/remote-cd-trigger-template.yml +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -26,7 +26,7 @@ jobs: run: | sudo gem install jwt x=${{ secrets.CDC_COE_BOTFREY_PEM }} - echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem + echo $x > app-private-key.pem chmod +x ./get-github-app-access-token.sh; . ./get-github-app-access-token.sh; echo "access_token=${TOKEN}" >> "$GITHUB_ENV" From 4379ebd2042847c34a99ec842fe5fbb121ab7b82 Mon Sep 17 00:00:00 2001 From: Subbu Vemula Date: Fri, 26 Jul 2024 11:54:17 -0400 Subject: [PATCH 90/91] add the sh command --- .github/workflows/remote-cd-trigger-template.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/remote-cd-trigger-template.yml b/.github/workflows/remote-cd-trigger-template.yml index db8a11d1..ba2df354 100644 --- a/.github/workflows/remote-cd-trigger-template.yml +++ b/.github/workflows/remote-cd-trigger-template.yml @@ -25,8 +25,7 @@ jobs: - name: Generate Token run: | sudo gem install jwt - x=${{ secrets.CDC_COE_BOTFREY_PEM }} - echo $x > app-private-key.pem + echo "${{ secrets.CDC_COE_BOTFREY_PEM }}" > app-private-key.pem chmod +x ./get-github-app-access-token.sh; . ./get-github-app-access-token.sh; echo "access_token=${TOKEN}" >> "$GITHUB_ENV" From 86ef47c67c15ec84c4686fc3e4d2512cb6bbe8a3 Mon Sep 17 00:00:00 2001 From: Matt B Krystof Date: Fri, 26 Jul 2024 15:12:51 -0500 Subject: [PATCH 91/91] Updated the report sink readme (#143) Co-authored-by: Matt B Krystof --- pstatus-report-sink-ktor/README.md | 212 ++++++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 3 deletions(-) diff --git a/pstatus-report-sink-ktor/README.md b/pstatus-report-sink-ktor/README.md index 9f529c5c..c7b44e8e 100644 --- a/pstatus-report-sink-ktor/README.md +++ b/pstatus-report-sink-ktor/README.md @@ -1,15 +1,221 @@ # Overview +Reports are an essential component of the data observability aspect of the CDC Data Exchange (DEX). In DEX, data is ingested to the system typically through a file upload. As the upload progresses through the service line processing occurs. The processing in the service line is made up stages, which can be the upload, routing, data validation, data transformations, etc. Within each of those stages one or more actions may occur. Taking the example of upload, one action within the stage may be to first verify that all the required metadata associated with the uploaded file is provided and reject it if not. Other upload actions may include the file upload itself or the disposition of the upload for further downstream processing. Reports are provided by both services internal to DEX and downstream of DEX as data moves through CDC systems. Those services indicate the processing status of these stages through Reports. + +## Report Sinking This project is the processing status report sink. It listens for messages on an Azure Service bus queues and topics, validates the messages, and if validated persists them to CosmosDB. If the validation fails due to missing fields or malformed data, then the message is persisted in cosmosdb under a new dead-letter container and the message is also sent to the dead-letter queue under the configured topic subscription(if the message was processed using the topic listener) This is a microservice built using Ktor that can be built as a docker container image. -## Publish to CDC's imagehub +# Publish to CDC's ImageHub With one gradle command you can builds and publish the project's Docker container image to the external container registry, imagehub, which is a nexus repository. -To do this, we use Google [jib](https://cloud.google.com/java/getting-started/jib), which vastly simplies the build process as you don't need a docker daemon running in order to build the Docker container image. +To do this, we use Google [jib](https://cloud.google.com/java/getting-started/jib), which vastly simplifies the build process as you don't need a docker daemon running in order to build the Docker container image. Inside of build.gradle `jib` section are the authentication settings. These use environment variables for the username and password, which are `IMAGEHUB_USERNAME` and `IMAGEHUB_PASSWORD` respectively. ```commandline gradle jib ``` -The location of the deployment will be to the `docker-dev2` repository under the folder `/v2/dex/pstatus`. \ No newline at end of file +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 two ways - either through calls into the Processing Status (PS) API as GraphQL mutations or by way of an Azure Service Bus. There are pros and cons of each summarized below. + +| Azure Service Bus | GraphQL | +|---------------------|--------------------------| +| Fire and forget [1] | Confirmation of delivery | +| Fast | Slower | + +[1] Failed reports are sent to a Report deadletter 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. + +### GraphQL Mutations +GraphQL mutations are writes to a persisted object. In the case of PS API, reports are written to PS API as GraphQL mutations. + +For context, GraphQL does not require any special client and can be communicated to the same as REST endpoints. However, unlike REST there is only one endpoint with path `/graphql` and you POST to it. The main difference is in the request body of the POST. Below is an example of how a Report would be sent to PS API. + +**POST** `{{ps_api_base_url}}/graphql` + +Request body: +```graphql +mutation AddReport($report: Report!) { + addReport(report: $report) { + reportId + result + issues + } +} +``` +In this example, we are asking for the `reportId` of the added report be returned in the response. + +Response of accepted report: +```json +{ + "data": { + "addReport": { + "reportId": "47286e48-2a22-4e26-930e-c7b4115b0cf1", + "result": "SUCCESS", + "issues": null + } + } +} +``` +Response of rejected report: +```json +{ + "data": { + "addReport": { + "reportId": null, + "result": "FAILURE", + "issues": [ + "Missing required field, dex_ingest_datetime" + ] + } + } +} +``` +> NOTE: With GraphQL, every HTTP status code returned is a 200 unless the request is unauthorized or something fails on the server. Clients must inspect the `result` field to determine success. + +There will also be a mutation available to replace an existing report. + +### Azure Service Bus +Reports may be sent to the PS API Azure Service Bus (ASB) queue or topic. Below is an example code snippet in Kotlin. + +```kotlin +val report = MyDEXReport().apply { + // set the report fields +} + +val senderClient = ServiceBusClientBuilder() + .connectionString(sbConnString) + .sender() + .topicName(topicName) + .buildClient() + +// Send the report to the PS API report topic +senderClient.sendMessage(ServiceBusMessage(report)) +``` + +> Sending reports via the PS API ASB report **queue** is being deprecated. The PS API report queue will eventually be removed. Reports should be sent to the PS API report **topic** instead. + +In order to access the ASB from your DEX service there may need be a firewall rule put in place. If your service is running in Kubernetes then no firewall rule should be necessary. + +# 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. + +The following queries will provide all reports for the given upload ID, whether accepted or not (sent to deadletter): +```graphql +query GetReports($uploadId: String!) { + getReports(uploadId: $uploadId, + reportsSortedBy: "timestamp", + sortOrder: Ascending) { + id + uploadId + dataStreamId + dataStreamRoute + messageId + reportId + stageName + status + timestamp + content + contentType + } + getDeadLetterReportsByUploadId(uploadId: $uploadId) { + id + uploadId + dataStreamId + dataStreamRoute + dispositionType + messageId + reportId + stageName + status + timestamp + validationSchemas + deadLetterReasons + contentType + content + } +} +``` +A response with both a failed report a successful report may look like the following. + +```json +{ + "data": { + "getReports": [ + { + "id": "42b1a3a9-77d6-4436-87fd-a0f07de[SchemaHelper.kt](..%2F..%2F..%2F..%2F..%2FDownloads%2Fapollo-kotlin-main%2Flibraries%2Fapollo-tooling%2Fsrc%2Fmain%2Fkotlin%2Fcom%2Fapollographql%2Fapollo%2Ftooling%2FSchemaHelper.kt)8e861", + "uploadId": "e4361c73-348b-46f2-aad8-3043f8922f1d", + "dataStreamId": "dex-testing", + "dataStreamRoute": "test-event1", + "messageId": null, + "reportId": "42b1a3a9-77d6-4436-87fd-a0f07de8e861", + "stageName": null, + "status": null, + "timestamp": "2024-05-25T17:47:24.360Z", + "contentType": "application/json", + "content": { + "schema_version": "0.0.1", + "metadata": { + "meta_field1": "value1" + }, + "filename": "some_upload1.csv", + "schema_name": "dex-metadata-verify", + "issues": null + } + } + ], + "getDeadLetterReportsByUploadId": [ + { + "id": "392c0eed-4401-494a-8d38-f00a7431ca0f", + "uploadId": "e4361c73-348b-46f2-aad8-3043f8922f1d", + "dataStreamId": "dex-testing", + "dataStreamRoute": null, + "dispositionType": "ADD", + "messageId": null, + "reportId": "392c0eed-4401-494a-8d38-f00a7431ca0f", + "stageName": null, + "status": null, + "timestamp": "2024-07-24T22:08:08.568Z", + "deadLetterReasons": [ + "$.data_stream_route: is missing but it is required", + "JSON is invalid against the content schema base.1.0.0.schema.json.Filename(s) used for validation: base.1.0.0.schema.json" + ], + "validationSchemas": [ + "base.1.0.0.schema.json" + ], + "contentType": "application/json", + "content": { + "report": { + "ingested_file_path": "https://ocioedemessagesatst.blob.core.windows.net/hl7ingress/dex-routing/dex-smoke-test_319101ba2fd7835983f3257713819f7b", + "ingested_file_timestamp": "2024-07-10T15:40:09+00:00", + "ingested_file_size": 10240, + "received_filename": "dex-smoke-test", + "supporting_metadata": { + "meta_ext_source": "test-src", + "meta_ext_filestatus": "test-file-status", + "meta_ext_file_timestamp": "test-timestamp", + "system_provider": "DEX-ROUTING", + "meta_ext_uploadid": "test-upload-id", + "meta_ext_objectkey": "test-obj-key", + "reporting_jurisdiction": "unknown" + }, + "aggregation": "SINGLE", + "number_of_messages": 1, + "number_of_messages_not_propagated": 1, + "error_messages": [ + { + "message_uuid": "378d6660-903f-40b8-b48b-80f9d77aa69c", + "message_index": 1, + "error_message": "No valid message found." + } + ] + }, + "content_schema_name": "hl7v2-debatch", + "content_schema_version": "1.0.0" + } + } + ] + } +} +```