diff --git a/.github/workflows/graphql-service-ci.yml b/.github/workflows/graphql-service-ci.yml index 76e0975a..bc53f1b3 100644 --- a/.github/workflows/graphql-service-ci.yml +++ b/.github/workflows/graphql-service-ci.yml @@ -18,4 +18,4 @@ jobs: steps: - uses: actions/checkout@v4 - name: Run Gradle Test - run: ./gradlew test \ No newline at end of file + run: ./gradlew test diff --git a/pstatus-graphql-ktor/build.gradle b/pstatus-graphql-ktor/build.gradle index c104b58a..2ee4bf0f 100644 --- a/pstatus-graphql-ktor/build.gradle +++ b/pstatus-graphql-ktor/build.gradle @@ -73,10 +73,6 @@ dependencies { testImplementation "io.ktor:ktor-server-tests-jvm:$ktor_version" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" -// testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' -// testImplementation 'org.mockito:mockito-core:4.6.1' -// testImplementation 'org.mockito:mockito-junit-jupiter:4.6.1' - testImplementation "org.junit.jupiter:junit-jupiter-api:5.8.1" testImplementation "org.junit.jupiter:junit-jupiter-engine:5.8.1" testImplementation "org.mockito:mockito-core:4.5.1" @@ -108,4 +104,35 @@ jib { } } +test { + + // Discover and execute JUnit Platform-based (JUnit 5, JUnit Jupiter) tests + // JUnit 5 has the ability to execute JUnit 4 tests as well + useJUnitPlatform() + + //Change this to "true" if we want to execute unit tests + systemProperty("isTestEnvironment", "false") + + // Set the test classpath, if required +} + +sourceSets { + main { + java { + srcDir 'src/kotlin' + } + resources { + srcDir 'src/resources' + } + } + test { + java { + srcDir 'src/kotlin' + } + resources { + srcDir 'src/resources' + } + } +} + diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt index 6d8ee153..fa298d38 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/Application.kt @@ -26,6 +26,7 @@ fun KoinApplication.loadKoinModules(environment: ApplicationEnvironment): KoinAp // Create a CosmosDB config that can be dependency injected (for health checks) single(createdAtStart = true) { CosmosConfiguration(uri, authKey) } } + return modules(listOf(cosmosModule)) } @@ -34,15 +35,20 @@ fun main(args: Array) { } fun Application.module() { - graphQLModule() - configureRouting() + install(Koin) { loadKoinModules(environment) } + graphQLModule() + configureRouting() + install(ContentNegotiation) { jackson() } + + + // See https://opensource.expediagroup.com/graphql-kotlin/docs/schema-generator/writing-schemas/scalars RuntimeWiring.newRuntimeWiring().scalar(ExtendedScalars.Date) } diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportContentType.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportContentType.kt new file mode 100644 index 00000000..fdfb48e7 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/ReportContentType.kt @@ -0,0 +1,17 @@ +package gov.cdc.ocio.processingstatusapi.models + +/** + * Enumeration of the possible sort orders for queries. + */ +enum class ReportContentType (val type: String){ + JSON("application/json"), + JSON_SHORT("json"), + BASE64("base64"); + + companion object { + fun fromString(type: String): ReportContentType { + return values().find { it.type.equals(type, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported content type: $type") + } + } +} \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/DataInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/DataInput.kt new file mode 100644 index 00000000..794ac068 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/DataInput.kt @@ -0,0 +1,12 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription + +@GraphQLDescription("Input type for tags") +data class DataInput( + @GraphQLDescription("Tag key") + val key: String, + + @GraphQLDescription("Tag value") + val value: String +) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/IssueInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/IssueInput.kt new file mode 100644 index 00000000..bfaa435b --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/IssueInput.kt @@ -0,0 +1,12 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription + +@GraphQLDescription("Input type for issues") +data class IssueInput( + @GraphQLDescription("Issue code") + val code: String? = null, + + @GraphQLDescription("Issue description") + val description: String? = null +) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/MessageMetadataInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/MessageMetadataInput.kt new file mode 100644 index 00000000..a4ebf98a --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/MessageMetadataInput.kt @@ -0,0 +1,19 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import gov.cdc.ocio.processingstatusapi.models.submission.Aggregation + +@GraphQLDescription("Input type for message metadata") +data class MessageMetadataInput( + @GraphQLDescription("Unique Identifier for that message") + val messageUUID: String? = null, + + @GraphQLDescription("MessageHash value") + val messageHash: String? = null, + + @GraphQLDescription("Single or Batch message") + val aggregation: Aggregation? = null, + + @GraphQLDescription("Message Index") + val messageIndex: Int? = null +) \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/ReportInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/ReportInput.kt new file mode 100644 index 00000000..24facc27 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/ReportInput.kt @@ -0,0 +1,55 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import java.time.OffsetDateTime + +@GraphQLDescription("Input type for creating or updating a report") +data class ReportInput( + @GraphQLDescription("Identifier of the report recorded by the database") + val id: String? = null, + + @GraphQLDescription("Upload identifier this report belongs to") + val uploadId: String? = null, + + @GraphQLDescription("Unique report identifier") + val reportId: String? = null, + + @GraphQLDescription("Data stream ID") + val dataStreamId: String? = null, + + @GraphQLDescription("Data stream route") + val dataStreamRoute: String? = null, + + @GraphQLDescription("Date/time of when the upload was first ingested into the data-exchange") + val dexIngestDateTime: OffsetDateTime? = null, + + @GraphQLDescription("Message metadata") + val messageMetadata: MessageMetadataInput? = null, + + @GraphQLDescription("Stage info") + val stageInfo: StageInfoInput? = null, + + @GraphQLDescription("Tags") + val tags: List? = null, + + @GraphQLDescription("Data") + val data: List? = null, + + @GraphQLDescription("Indicates the content type of the content; e.g. JSON, XML") + val contentType: String? = null, + + @GraphQLDescription("Jurisdiction report belongs to; set to null if not applicable") + val jurisdiction: String? = null, + + @GraphQLDescription("Sender ID this report belongs to; set to null if not applicable") + val senderId: String? = null, + + @GraphQLDescription("Data Producer ID stated in the report; set to null if not applicable") + val dataProducerId: String? = null, + + @GraphQLDescription("Content of the report. If the report is JSON then the content will be a map, otherwise, it will be a string") + var content : String? = null, + + @GraphQLDescription("Timestamp when the report was recorded in the database") + val timestamp: OffsetDateTime? = null +) \ No newline at end of file diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/StageInfoInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/StageInfoInput.kt new file mode 100644 index 00000000..d18e93cb --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/StageInfoInput.kt @@ -0,0 +1,29 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import gov.cdc.ocio.processingstatusapi.models.submission.Status +import java.time.OffsetDateTime + +@GraphQLDescription("Input type for stage info") +data class StageInfoInput( + @GraphQLDescription("Service") + val service: String? = null, + + @GraphQLDescription("Stage name a.k.a action") + val action: String? = null, + + @GraphQLDescription("Version") + val version: String? = null, + + @GraphQLDescription("Status- SUCCESS OR FAILURE") + val status: Status? = null, + + @GraphQLDescription("Issues array") + val issues: List? = null, + + @GraphQLDescription("Start processing time") + val startProcessingTime: OffsetDateTime? = null, + + @GraphQLDescription("End processing time") + val endProcessingTime: OffsetDateTime? = null +) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/TagInput.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/TagInput.kt new file mode 100644 index 00000000..b62caa5a --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/models/reports/inputs/TagInput.kt @@ -0,0 +1,12 @@ +package gov.cdc.ocio.processingstatusapi.models.reports.inputs + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription + +@GraphQLDescription("Input type for tags") +data class TagInput( + @GraphQLDescription("Tag key") + val key: String, + + @GraphQLDescription("Tag value") + val value: String +) diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/ReportMutation.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/ReportMutation.kt new file mode 100644 index 00000000..547a14d3 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/mutations/ReportMutation.kt @@ -0,0 +1,51 @@ +package gov.cdc.ocio.processingstatusapi.mutations + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import com.expediagroup.graphql.server.operations.Mutation +import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException +import gov.cdc.ocio.processingstatusapi.exceptions.ContentException +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.ReportInput +import gov.cdc.ocio.processingstatusapi.services.ReportMutationService + +/** + * ReportMutationService class handles GraphQL mutations for report creation and replacement. + * + * This service provides a single mutation operation to either create a new report or replace an + * existing report in the system. It utilizes the ReportMutation class to perform the actual + * upsert operation based on the provided input and action. + * + * Annotations: + * - GraphQLDescription: Provides descriptions for the class and its methods for GraphQL documentation. + * + * Dependencies: + * - ReportInput: Represents the input model for report data. + */ +@GraphQLDescription("A Mutation Service to either create a new report or replace an existing report") +class ReportMutation() : Mutation { + + /** + * Upserts a report based on the provided input and action. + * + * This function serves as a GraphQL mutation to create a new report or replace an existing one. + * It delegates the actual upsert logic to the ReportMutation class. + * + * @param input The ReportInput containing details of the report to be created or replaced. + * @param action A string specifying the action to perform: "create" or "replace". + * @return The result of the upsert operation, handled by the ReportMutation class. + */ + @GraphQLDescription("Create upload") + @Suppress("unused") + @Throws(BadRequestException::class, ContentException::class, Exception::class) + fun upsertReport( + @GraphQLDescription( + "*Report Input* to be created or updated:\n" + ) + input: ReportInput, + @GraphQLDescription( + "*Action*: Can be one of the following values\n" + + "`create`: Create new report\n" + + "`replace`: Replace existing report\n" + ) + action: String + ) = ReportMutationService().upsertReport(input, action) +} diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt index b8279707..53d911d3 100644 --- a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/plugins/GraphQL.kt @@ -7,6 +7,7 @@ import com.expediagroup.graphql.server.ktor.* import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDataLoader import gov.cdc.ocio.processingstatusapi.dataloaders.ReportDeadLetterDataLoader import gov.cdc.ocio.processingstatusapi.mutations.NotificationsMutationService +import gov.cdc.ocio.processingstatusapi.mutations.ReportMutation import gov.cdc.ocio.processingstatusapi.queries.* import io.ktor.http.* import io.ktor.serialization.jackson.* @@ -80,6 +81,9 @@ fun Application.graphQLModule() { // install(CORS) { // anyHost() // } + +// val reportMutation by inject() // Inject ReportMutation from Koin + install(GraphQL) { schema { packages = listOf("gov.cdc.ocio.processingstatusapi") @@ -92,7 +96,8 @@ fun Application.graphQLModule() { ) mutations= listOf( - NotificationsMutationService() + NotificationsMutationService(), + ReportMutation() ) // subscriptions = listOf( // ErrorSubscriptionService() diff --git a/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/services/ReportMutationService.kt b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/services/ReportMutationService.kt new file mode 100644 index 00000000..31efca94 --- /dev/null +++ b/pstatus-graphql-ktor/src/main/kotlin/gov/cdc/ocio/processingstatusapi/services/ReportMutationService.kt @@ -0,0 +1,436 @@ +package gov.cdc.ocio.processingstatusapi.services + +import com.azure.cosmos.models.CosmosItemRequestOptions +import com.azure.cosmos.models.CosmosItemResponse +import com.azure.cosmos.models.PartitionKey +import com.azure.json.implementation.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException +import gov.cdc.ocio.processingstatusapi.exceptions.ContentException +import gov.cdc.ocio.processingstatusapi.loaders.CosmosLoader +import gov.cdc.ocio.processingstatusapi.models.ReportContentType +import gov.cdc.ocio.processingstatusapi.models.Report +import gov.cdc.ocio.processingstatusapi.models.reports.* +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.IssueInput +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.MessageMetadataInput +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.ReportInput +import gov.cdc.ocio.processingstatusapi.models.reports.inputs.StageInfoInput +import gov.cdc.ocio.processingstatusapi.models.submission.Issue +import gov.cdc.ocio.processingstatusapi.models.submission.Level +import gov.cdc.ocio.processingstatusapi.models.submission.MessageMetadata +import gov.cdc.ocio.processingstatusapi.models.submission.StageInfo +import java.util.* + +/** + * ReportMutation class handles the creation and replacement of reports in a Cosmos DB. + * + * This class extends the CosmosLoader and provides methods to upsert reports based on specified actions. + * + * Key functionalities include: + * - `upsertReport`: A public method to create or replace a report, validating the input action. + * - `performUpsert`: A private method that contains the core logic for creating or replacing reports. + * - `mapInputToReport`: Transforms a ReportInput object into a Report object. + * - Various mapping methods to convert input data into the appropriate report structures, including + * message metadata, stage info, and issues. + * - `parseContent`: A method to handle different content types (JSON and Base64) and convert them + * into usable formats. + * + */ +class ReportMutationService : CosmosLoader() { + + private val objectMapper = ObjectMapper() + + /** + * Upserts a report based on the provided input and action. + * + * This method either creates a new report or replaces an existing one based on the specified action. + * It validates the input and generates a new ID if the action is "create" and no ID is provided. + * If the action is "replace", it ensures that the report ID is provided and that the report exists. + * + * @param input The ReportInput containing details of the report to be created or replaced. + * @param action A string specifying the action to perform: "create" or "replace". + * @return The updated or newly created Report, or null if the operation fails. + * @throws BadRequestException If the action is invalid or if the ID is improperly provided. + * @throws ContentException If there is an error with the content format. + */ + @Throws(BadRequestException::class, ContentException::class, Exception::class) + fun upsertReport(input: ReportInput, action: String): Report? { + logger.info("ReportId, id = ${input.id}, action = $action") + + return try { + performUpsert(input, action) + } catch (e: BadRequestException) { + logger.error("Bad request while upserting report: ${e.message}", e) + throw e // Re-throwing the BadRequestException + } catch (e: ContentException) { + logger.error("Content error while upserting report: ${e.message}", e) + throw e // Re-throwing the ContentException + } catch (e: Exception) { + logger.error("Unexpected error while upserting report: ${e.message}", e) + throw ContentException("An unexpected error occurred: ${e.message}") // Re-throwing the Exception + } + } + + /** + * Executes the upsert operation for a report. + * + * Validates the action type and performs either a create or replace operation on the report. + * + * * Additionally, checks for the presence of required fields: `dataStreamId`, `dataStreamRoute`, + * * and both `stageInfo.service` and `stageInfo.action`. Throws a BadRequestException if any of + * * these fields are missing. + * + * Generates a new ID for the report if creating, and checks for existence when replacing. + * + * @param input The ReportInput containing details of the report to be created or replaced. + * @param action A string specifying the action to perform: "create" or "replace". + * @return The updated or newly created Report, or null if the operation fails. + * @throws BadRequestException If the action is invalid or the ID is improperly provided. + */ + @Throws(BadRequestException::class, ContentException::class) + private fun performUpsert(input: ReportInput, action: String): Report? { + // Validate action parameter + val upsertAction = UpsertAction.fromString(action) + + // Validate required fields for ReportInput + validateInput(input, upsertAction) + + return try { + val report = mapInputToReport(input) + + when (upsertAction) { + UpsertAction.CREATE -> createReport(report) + UpsertAction.REPLACE -> replaceReport(report) + } + } catch (e: BadRequestException) { + logger.error("Validation error during upsert: ${e.message}", e) + throw e // Re-throwing the BadRequestException + } catch (e: ContentException) { + logger.error("Content error during upsert: ${e.message}", e) + throw e // Re-throwing the ContentException + } catch (e: Exception) { + logger.error("Unexpected error during upsert: ${e.message}", e) + throw ContentException("Failed to perform upsert: ${e.message}") + } + } + + /** + * Maps the given ReportInput to a Report object. + * + * This method extracts the necessary fields from the input and constructs a Report instance. + * It also parses the content based on its type. + * + * @param input The ReportInput containing the details to map to a Report. + * @return A Report object populated with data from the input. + */ + @Throws(ContentException::class) + private fun mapInputToReport(input: ReportInput): Report { + + return try { + + // Parse the content based on its type + val parsedContent = input.content?.let { parseContent(it, input.contentType) } as? Map<*, *>? + + // Set id and reportId to be the same + val reportId = input.id ?: generateNewId() // Generate a new ID if not provided + + + Report( + id = reportId, + uploadId = input.uploadId, + reportId = reportId, //Set reportId to be the same as id + dataStreamId = input.dataStreamId, + dataStreamRoute = input.dataStreamRoute, + dexIngestDateTime = input.dexIngestDateTime, + messageMetadata = input.messageMetadata?.let { mapInputToMessageMetadata(it) }, + stageInfo = input.stageInfo?.let { mapInputToStageInfo(it) }, + tags = input.tags?.associate { it.key to it.value }, + data = input.data?.associate { it.key to it.value }, + contentType = input.contentType, + jurisdiction = input.jurisdiction, + senderId = input.senderId, + dataProducerId = input.dataProducerId, + content = parsedContent, + timestamp = input.timestamp + ) + } catch (e: JsonProcessingException) { + logger.error("JSON processing error mapping input to report: ${e.message}", e) + throw ContentException("Failed to map input to report: ${e.message}") + } catch (e: Exception) { + logger.error("Error mapping input to report: ${e.message}", e) + throw ContentException("Failed to map input to report: ${e.message}") + } + } + + /** + * Maps the given MessageMetadataInput to a MessageMetadata object. + * + * Extracts fields from the input and creates a MessageMetadata instance. + * + * @param input The MessageMetadataInput to map. + * @return A MessageMetadata object populated with data from the input. + */ + private fun mapInputToMessageMetadata(input: MessageMetadataInput): MessageMetadata { + return MessageMetadata( + messageUUID = input.messageUUID, + messageHash = input.messageHash, + aggregation = input.aggregation, + messageIndex = input.messageIndex + ) + } + + /** + * Maps the given StageInfoInput to a StageInfo object. + * + * Extracts fields from the input and creates a StageInfo instance, including issues. + * + * @param input The StageInfoInput to map. + * @return A StageInfo object populated with data from the input. + */ + private fun mapInputToStageInfo(input: StageInfoInput): StageInfo { + return StageInfo( + service = input.service, + action = input.action, + version = input.version, + status = input.status, + issues = input.issues?.map { mapInputToIssue(it) }, + startProcessingTime = input.startProcessingTime, + endProcessingTime = input.endProcessingTime + ) + } + + /** + * Maps the given IssueInput to an Issue object. + * + * Extracts the level and message from the input and creates an Issue instance. + * + * @param input The IssueInput to map. + * @return An Issue object populated with data from the input. + */ + private fun mapInputToIssue(input: IssueInput): Issue { + return Issue( + level = input.code?.let { Level.valueOf(it) }, + message = input.description + ) + } + + /** + * Generates a new unique identifier for a report. + * + * This method creates a UUID string to be used as a unique ID when creating a new report. + * + * @return A unique string identifier. + */ + private fun generateNewId(): String { + // Generate a new unique ID + return java.util.UUID.randomUUID().toString() + } + + /** + * Parses the given content based on the specified content type. + * + * Supports JSON and Base64 content types, converting them into a Map structure. + * + * @param content The content string to parse. + * @param contentType The type of content (e.g., "application/json" or "base64"). + * @return A parsed representation of the content as a Map. + * @throws ContentException If the content format is invalid or unsupported. + */ + @Throws(ContentException::class) + private fun parseContent(content: String, contentType: String?): Any { + + val validContentType = contentType?.let { ReportContentType.fromString(it) } + + return when (validContentType) { + ReportContentType.JSON, ReportContentType.JSON_SHORT -> { + // Parse JSON content into a Map + try { + objectMapper.readValue>(content) + } catch (e: JsonProcessingException) { + logger.error("Invalid JSON format: ${e.message}") + throw ContentException("Invalid JSON format: ${e.message}") + } + } + ReportContentType.BASE64 -> { + try { + // Decode base64 content into a Map, if expected + val decodedBytes = Base64.getDecoder().decode(content) + val decodedString = String(decodedBytes) + // If the decoded base64 string is in JSON format, parse it + objectMapper.readValue>(decodedString) + } catch (e: IllegalArgumentException) { + logger.error("Invalid Base64 string: ${e.message}") + throw ContentException("Invalid Base64 format: ${e.message}") + } catch (e: JsonProcessingException) { + logger.error("Invalid JSON format after base64 decode: ${e.message}") + throw ContentException("Invalid JSON format after base64 decode: ${e.message}") + } + } + else -> { + throw ContentException("Unsupported content type: $contentType") + } + } + } + + + /** + * Validates the input for a report based on the specified action. + * + * This function checks the validity of the provided `input` object based on the + * specified `action` (CREATE or REPLACE). It ensures that the required fields + * are present and valid. Specifically, for the CREATE action, it checks that + * no ID is provided, while for the REPLACE action, it verifies that an ID is + * supplied. Additionally, it validates the presence of `dataStreamId`, + * `dataStreamRoute`, and checks the properties of `stageInfo`. + * + * @param input The ReportInput object to be validated. It must contain the necessary + * fields based on the action specified. + * @param action The UpsertAction indicating the type of operation (CREATE or REPLACE). + * @throws BadRequestException If the input is invalid, such as: + * - For CREATE: ID is provided. + * - For REPLACE: ID is missing. + * - If any of the required fields are missing: dataStreamId, dataStreamRoute, + * or stageInfo (including service and action). + */ + @Throws (BadRequestException::class) + private fun validateInput(input: ReportInput, action: UpsertAction) { + when (action) { + UpsertAction.CREATE -> { + if (!input.id.isNullOrBlank()) { + throw BadRequestException("ID should not be provided for create action.Provided ID: ${input.id}") + } + } + UpsertAction.REPLACE -> { + if (input.id.isNullOrBlank()) { + throw BadRequestException("ID must be provided for replace action.") + } + // Ensure reportId matches id if both are provided + if (!input.reportId.isNullOrBlank() && input.id != input.reportId) { + throw BadRequestException("ID and reportId must be the same for replace action.") + } + } + } + + // Validate dataStreamId and dataStreamRoute fields + if (input.dataStreamId.isNullOrBlank() || input.dataStreamRoute.isNullOrBlank()) { + throw BadRequestException("Missing required fields: dataStreamId and dataStreamRoute must be present.") + } + + // Check if stageInfo is null + if (input.stageInfo == null) { + throw BadRequestException("Missing required field: stageInfo must be present.") + } + + // Check properties of stageInfo + if (input.stageInfo.service.isNullOrBlank() || input.stageInfo.action.isNullOrBlank()) { + throw BadRequestException("Missing required fields in stageInfo: service and action must be present.") + } + } + + /** + * Creates a new report in the database. + * + * This function generates a new ID for the report, validates the provided upload ID, + * and attempts to create the report in the Cosmos DB container. If the report ID + * is provided or if the upload ID is missing, a BadRequestException is thrown. + * If there is an error during the creation process, a ContentException is thrown. + * + * @param report The report object to be created. Must not have an existing ID and must include a valid upload ID. + * @return The created Report object, or null if the creation fails. + * @throws BadRequestException If the report ID is provided or if the upload ID is missing. + * @throws ContentException If there is an error during the report creation process. + */ + @Throws(BadRequestException::class, ContentException:: class) + private fun createReport(report: Report): Report? { + + if (!report.id.isNullOrBlank()) { + throw BadRequestException("ID should not be provided for create action.") + } + + // Validate uploadId + if (report.uploadId.isNullOrBlank()) { + throw BadRequestException("Upload ID must be provided.") + } + + report.id = generateNewId() + report.reportId = report.id + val options = CosmosItemRequestOptions() + + return try { + val createResponse: CosmosItemResponse? = reportsContainer?.createItem(report, PartitionKey(report.uploadId), options) + + // Check if createResponse is null and throw an exception if it is + createResponse?.item ?: throw ContentException("Failed to create report: response was null.") + + } catch (e: Exception) { + logger.error(e.localizedMessage) + throw ContentException("Failed to create report: ${e.message}") + } + } + + + /** + * Replaces an existing report in the database. + * + * This function checks if the report ID is provided and validates the upload ID. + * It attempts to read the existing report from the Cosmos DB container and, + * if found, replaces it with the new report data. If the report ID is missing, + * or if the upload ID is not provided, a BadRequestException is thrown. If + * the report is not found for replacement, another BadRequestException is thrown. + * In case of any error during the database operations, an appropriate exception + * will be thrown. + * + * @param report The report object containing the new data. Must have a valid ID and upload ID. + * @return The updated Report object, or null if the replacement fails. + * @throws BadRequestException If the report ID is missing, the upload ID is missing, or the report is not found. + */ + @Throws(BadRequestException::class, ContentException::class) + private fun replaceReport(report: Report): Report? { + + if (report.id.isNullOrBlank()) { + throw BadRequestException("ID must be provided for replace action.") + } + + // Validate uploadId + if (report.uploadId.isNullOrBlank()) { + throw BadRequestException("Upload ID must be provided.") + } + + return try { + val readResponse: CosmosItemResponse = reportsContainer?.readItem( + report.id!!, + PartitionKey(report.uploadId!!), + Report::class.java + ) ?: throw ContentException("Report with ID ${report.id} not found for replacement.") + + val options = CosmosItemRequestOptions() + val replaceResponse: CosmosItemResponse? = reportsContainer?.replaceItem( + report, + report.id!!, + PartitionKey(report.uploadId!!), + options + ) + + // Check if replaceResponse is null and throw an exception if it is + replaceResponse?.item ?: throw ContentException("Failed to replace report: response was null.") + + } catch (e: Exception) { + logger.error(e.localizedMessage) + throw ContentException("Failed to replace report: ${e.message}") + } + } + +} + +enum class UpsertAction(val action: String) { + CREATE("create"), + REPLACE("replace"); + + companion object { + fun fromString(action: String): UpsertAction { + return entries.find { it.action.equals(action, ignoreCase = true) } + ?: throw BadRequestException("Invalid action specified: $action. Must be 'create' or 'replace'.") + } + } +} diff --git a/pstatus-graphql-ktor/src/test/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoaderTest.kt b/pstatus-graphql-ktor/src/test/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoaderTest.kt index 143c5974..b0542a5a 100644 --- a/pstatus-graphql-ktor/src/test/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoaderTest.kt +++ b/pstatus-graphql-ktor/src/test/kotlin/gov/cdc/ocio/processingstatusapi/loaders/UploadStatusLoaderTest.kt @@ -1,31 +1,22 @@ +package gov.cdc.ocio.processingstatusapi.loaders + import data.UploadsStatusDataGenerator import com.azure.cosmos.CosmosContainer import com.azure.cosmos.models.CosmosQueryRequestOptions -import com.azure.cosmos.util.CosmosPagedFlux import com.azure.cosmos.util.CosmosPagedIterable -import com.azure.cosmos.util.UtilBridgeInternal.createCosmosPagedIterable import gov.cdc.ocio.processingstatusapi.cosmos.CosmosRepository import gov.cdc.ocio.processingstatusapi.exceptions.BadRequestException -import gov.cdc.ocio.processingstatusapi.loaders.UploadStatusLoader import gov.cdc.ocio.processingstatusapi.models.dao.ReportDao -import gov.cdc.ocio.processingstatusapi.models.query.PageSummary import gov.cdc.ocio.processingstatusapi.models.query.UploadCounts -import gov.cdc.ocio.processingstatusapi.models.query.UploadsStatus -import gov.cdc.ocio.processingstatusapi.models.submission.MessageMetadata import io.mockk.* import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module import org.koin.test.KoinTest -import org.koin.test.get -import org.mockito.ArgumentMatchers.anyString -import org.mockito.Mockito.* import java.util.* import kotlin.test.assertEquals import kotlin.test.assertFailsWith