From 4a2f4ad6e3d47c80ed9de762ff0f48ac7e7bf638 Mon Sep 17 00:00:00 2001 From: Kilemonn Date: Thu, 24 Aug 2023 23:05:14 +1000 Subject: [PATCH 1/2] Added correlation ID filter and into success response. --- .../filter/CorrelationIdFilter.kt | 53 +++++++++++++++++++ .../rest/response/MessageResponse.kt | 6 ++- src/main/resources/logback.xml | 2 +- 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/au/kilemon/messagequeue/filter/CorrelationIdFilter.kt diff --git a/src/main/kotlin/au/kilemon/messagequeue/filter/CorrelationIdFilter.kt b/src/main/kotlin/au/kilemon/messagequeue/filter/CorrelationIdFilter.kt new file mode 100644 index 0000000..ac73241 --- /dev/null +++ b/src/main/kotlin/au/kilemon/messagequeue/filter/CorrelationIdFilter.kt @@ -0,0 +1,53 @@ +package au.kilemon.messagequeue.filter + +import au.kilemon.messagequeue.logging.HasLogger +import org.slf4j.Logger +import org.slf4j.MDC +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.util.* +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +@Order(1) +class CorrelationIdFilter: OncePerRequestFilter(), HasLogger +{ + companion object + { + const val CORRELATION_ID_HEADER = "X-Correlation-Id" + + const val CORRELATION_LOG_PARAMETER = "cId" + } + + override val LOG: Logger = initialiseLogger() + + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) + { + try + { + val correlationId: String + val providedId: String? = request.getHeader(CORRELATION_ID_HEADER) + if (providedId != null) + { + correlationId = providedId + LOG.trace("Using provided ID [{}] as correlation id.", correlationId) + } + else + { + correlationId = UUID.randomUUID().toString() + LOG.trace("Using generated UUID [{}] as correlation id.", correlationId) + } + + MDC.put(CORRELATION_LOG_PARAMETER, correlationId) + + filterChain.doFilter(request, response) + } + finally + { + MDC.clear() + } + } +} diff --git a/src/main/kotlin/au/kilemon/messagequeue/rest/response/MessageResponse.kt b/src/main/kotlin/au/kilemon/messagequeue/rest/response/MessageResponse.kt index c5d3c81..4eb9eeb 100644 --- a/src/main/kotlin/au/kilemon/messagequeue/rest/response/MessageResponse.kt +++ b/src/main/kotlin/au/kilemon/messagequeue/rest/response/MessageResponse.kt @@ -1,12 +1,14 @@ package au.kilemon.messagequeue.rest.response +import au.kilemon.messagequeue.filter.CorrelationIdFilter import au.kilemon.messagequeue.message.QueueMessage import com.fasterxml.jackson.annotation.JsonPropertyOrder +import org.slf4j.MDC /** * A response object which wraps the [QueueMessage], and exposes the `type` [String]. * * @author github.com/Kilemonn */ -@JsonPropertyOrder("queueType", "message") -data class MessageResponse(val message: QueueMessage, val queueType: String = message.type) +@JsonPropertyOrder("correlationId", "queueType", "message") +data class MessageResponse(val message: QueueMessage, val queueType: String = message.type, val correlationId: String? = MDC.get(CorrelationIdFilter.CORRELATION_LOG_PARAMETER)) diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 66732f8..f16f527 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -2,7 +2,7 @@ - + From ba097e7095759a5b2b44f6a928c0f696e21d0cce Mon Sep 17 00:00:00 2001 From: Kilemonn Date: Fri, 25 Aug 2023 23:28:55 +1000 Subject: [PATCH 2/2] Add tests for correlationid. Add correlationId to the error response payload. Update error messages to surround parameters with square brackets. --- .../filter/CorrelationIdFilter.kt | 48 ++++++--- .../rest/controller/MessageQueueController.kt | 52 ++++----- .../rest/controller/RestParameters.kt | 19 ++++ .../rest/response/ErrorResponse.kt | 13 +++ .../rest/response/MessageResponse.kt | 2 +- .../response/RestResponseExceptionHandler.kt | 22 ++++ src/main/resources/logback.xml | 2 +- .../filter/TestCorrelationIdFilter.kt | 50 +++++++++ .../controller/MessageQueueControllerTest.kt | 102 ++++++++++++++---- .../TestRestResponseExceptionHandler.kt | 44 ++++++++ 10 files changed, 289 insertions(+), 65 deletions(-) create mode 100644 src/main/kotlin/au/kilemon/messagequeue/rest/controller/RestParameters.kt create mode 100644 src/main/kotlin/au/kilemon/messagequeue/rest/response/ErrorResponse.kt create mode 100644 src/main/kotlin/au/kilemon/messagequeue/rest/response/RestResponseExceptionHandler.kt create mode 100644 src/test/kotlin/au/kilemon/messagequeue/filter/TestCorrelationIdFilter.kt create mode 100644 src/test/kotlin/au/kilemon/messagequeue/rest/response/TestRestResponseExceptionHandler.kt diff --git a/src/main/kotlin/au/kilemon/messagequeue/filter/CorrelationIdFilter.kt b/src/main/kotlin/au/kilemon/messagequeue/filter/CorrelationIdFilter.kt index ac73241..6647cf5 100644 --- a/src/main/kotlin/au/kilemon/messagequeue/filter/CorrelationIdFilter.kt +++ b/src/main/kotlin/au/kilemon/messagequeue/filter/CorrelationIdFilter.kt @@ -11,6 +11,13 @@ import javax.servlet.FilterChain import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse +/** + * A request filter that either takes the incoming provided [CORRELATION_ID_HEADER] and sets it into the [MDC] OR + * generates a new [UUID] that is used as the [CORRELATION_ID] for this request. + * This [CORRELATION_ID] is removed from the [MDC] when the request is returned to the caller. + * + * @author github.com/Kilemonn + */ @Component @Order(1) class CorrelationIdFilter: OncePerRequestFilter(), HasLogger @@ -19,7 +26,8 @@ class CorrelationIdFilter: OncePerRequestFilter(), HasLogger { const val CORRELATION_ID_HEADER = "X-Correlation-Id" - const val CORRELATION_LOG_PARAMETER = "cId" + // This is also used in the logback.xml as a parameter + const val CORRELATION_ID = "correlationId" } override val LOG: Logger = initialiseLogger() @@ -28,20 +36,7 @@ class CorrelationIdFilter: OncePerRequestFilter(), HasLogger { try { - val correlationId: String - val providedId: String? = request.getHeader(CORRELATION_ID_HEADER) - if (providedId != null) - { - correlationId = providedId - LOG.trace("Using provided ID [{}] as correlation id.", correlationId) - } - else - { - correlationId = UUID.randomUUID().toString() - LOG.trace("Using generated UUID [{}] as correlation id.", correlationId) - } - - MDC.put(CORRELATION_LOG_PARAMETER, correlationId) + setCorrelationId(request.getHeader(CORRELATION_ID_HEADER)) filterChain.doFilter(request, response) } @@ -50,4 +45,27 @@ class CorrelationIdFilter: OncePerRequestFilter(), HasLogger MDC.clear() } } + + /** + * Handle the setting of the [CORRELATION_ID] based on the [providedId] if it is not null it will be used, otherwise + * a new [UUID] will be generated and set into the [MDC]. + * + * @param providedId the correlation ID provided by the user, if it is null a new one will be generated + */ + fun setCorrelationId(providedId: String?) + { + val correlationId: String + if (providedId != null) + { + correlationId = providedId + LOG.trace("Using provided ID [{}] as correlation id.", correlationId) + } + else + { + correlationId = UUID.randomUUID().toString() + LOG.trace("Using generated UUID [{}] as correlation id.", correlationId) + } + + MDC.put(CORRELATION_ID, correlationId) + } } diff --git a/src/main/kotlin/au/kilemon/messagequeue/rest/controller/MessageQueueController.kt b/src/main/kotlin/au/kilemon/messagequeue/rest/controller/MessageQueueController.kt index 3f327e9..83ee25d 100644 --- a/src/main/kotlin/au/kilemon/messagequeue/rest/controller/MessageQueueController.kt +++ b/src/main/kotlin/au/kilemon/messagequeue/rest/controller/MessageQueueController.kt @@ -127,10 +127,10 @@ open class MessageQueueController : HasLogger */ @Hidden @Operation(summary = "Retrieve queue information for a specific sub queue.", description = "Retrieve information about the specified queueType within the queue, specifically information on the queue entries.") - @GetMapping("$ENDPOINT_TYPE/{queueType}", produces = [MediaType.APPLICATION_JSON_VALUE]) + @GetMapping("$ENDPOINT_TYPE/{${RestParameters.QUEUE_TYPE}}", produces = [MediaType.APPLICATION_JSON_VALUE]) @ApiResponse(responseCode = "200", description = "Successfully returns the information payload.") fun getQueueTypeInfo(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The queueType to retrieve information about.") - @PathVariable queueType: String): ResponseEntity + @PathVariable(name = RestParameters.QUEUE_TYPE) queueType: String): ResponseEntity { val queueForType = messageQueue.getQueueForType(queueType) LOG.debug("Returning size [{}] for queue with type [{}].", queueForType.size, queueType) @@ -152,10 +152,12 @@ open class MessageQueueController : HasLogger return try { messageQueue.performHealthCheck() + LOG.trace("Health check passed.") ResponseEntity.ok().build() } catch(ex: HealthCheckFailureException) { + LOG.error("Health check failed.") ResponseEntity.internalServerError().build() } } @@ -169,12 +171,12 @@ open class MessageQueueController : HasLogger * @return [MessageResponse] containing the found [QueueMessage] otherwise a [HttpStatus.NO_CONTENT] exception will be thrown */ @Operation(summary = "Retrieve a queue message by UUID.", description = "Retrieve a queue message regardless of its sub queue, directly by UUID.") - @GetMapping("$ENDPOINT_ENTRY/{uuid}", produces = [MediaType.APPLICATION_JSON_VALUE]) + @GetMapping("$ENDPOINT_ENTRY/{${RestParameters.UUID}}", produces = [MediaType.APPLICATION_JSON_VALUE]) @ApiResponses( ApiResponse(responseCode = "200", description = "Successfully returns the queue message matching the provided UUID."), ApiResponse(responseCode = "204", description = "No queue messages match the provided UUID.", content = [Content()]) // Add empty Content() to remove duplicate responses in swagger docs ) - fun getEntry(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The UUID of the queue message to retrieve.") @PathVariable uuid: String): ResponseEntity + fun getEntry(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The UUID of the queue message to retrieve.") @PathVariable(name = RestParameters.UUID) uuid: String): ResponseEntity { val entry = messageQueue.getMessageByUUID(uuid) if (entry.isPresent) @@ -222,7 +224,7 @@ open class MessageQueueController : HasLogger else { LOG.error("Failed to add entry with UUID [{}] to queue with type [{}]. AND the message does not already exist. This could be a memory limitation or an issue with the underlying collection.", queueMessage.uuid, queueMessage.type) - throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to add entry with UUID: ${queueMessage.uuid} to queue with type ${queueMessage.type}") + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to add entry with UUID: [${queueMessage.uuid}] to queue with type [${queueMessage.type}]") } } catch (ex: DuplicateMessageException) @@ -244,7 +246,7 @@ open class MessageQueueController : HasLogger @GetMapping(ENDPOINT_KEYS, produces = [MediaType.APPLICATION_JSON_VALUE]) @ApiResponse(responseCode = "200", description = "Successfully returns the list of keys.") fun getKeys(@Parameter(`in` = ParameterIn.QUERY, required = false, description = "Indicates whether to include keys that currently have zero entries (but have had entries previously). Is true by default.") - @RequestParam(required = false) includeEmpty: Boolean?): ResponseEntity> + @RequestParam(required = false, name = RestParameters.INCLUDE_EMPTY) includeEmpty: Boolean?): ResponseEntity> { return ResponseEntity.ok(messageQueue.keys(includeEmpty ?: true)) } @@ -259,8 +261,8 @@ open class MessageQueueController : HasLogger @Operation(summary = "Retrieve a limited or full version of the held messages.", description = "Retrieve queue message summaries for the held messages. This can be limited to a specific sub queue type and complete message detail to be included in the response if requested.") @GetMapping(ENDPOINT_ALL, produces = [MediaType.APPLICATION_JSON_VALUE]) @ApiResponse(responseCode = "200", description = "Successfully returns the list of summary entries for either the whole multi-queue or the sub queue.") - fun getAll(@Parameter(`in` = ParameterIn.QUERY, required = false, description = "Indicates whether the response messages should contain all message details including the underlying payload. By default details are hidden.") @RequestParam(required = false) detailed: Boolean = false, - @Parameter(`in` = ParameterIn.QUERY, required = false, description = "The sub queue type to search, if not provide all messages in the whole multi-queue will be returned.") @RequestParam(required = false) queueType: String?): ResponseEntity>> + fun getAll(@Parameter(`in` = ParameterIn.QUERY, required = false, description = "Indicates whether the response messages should contain all message details including the underlying payload. By default details are hidden.") @RequestParam(required = false, name = RestParameters.DETAILED) detailed: Boolean = false, + @Parameter(`in` = ParameterIn.QUERY, required = false, description = "The sub queue type to search, if not provide all messages in the whole multi-queue will be returned.") @RequestParam(required = false, name = RestParameters.QUEUE_TYPE) queueType: String?): ResponseEntity>> { val responseMap = HashMap>() if ( !queueType.isNullOrBlank()) @@ -294,8 +296,8 @@ open class MessageQueueController : HasLogger @Operation(summary = "Retrieve all owned queue messages based on the provided user identifier.", description = "Retrieve all owned messages for the provided assignee identifier for the provided sub queue type.") @GetMapping(ENDPOINT_OWNED, produces = [MediaType.APPLICATION_JSON_VALUE]) @ApiResponse(responseCode = "200", description = "Successfully returns the list of owned queue messages in the sub queue for the provided assignee identifier.") - fun getOwned(@Parameter(`in` = ParameterIn.QUERY, required = true, description = "The identifier that must match the message's `assigned` property in order to be returned.") @RequestParam(required = true) assignedTo: String, - @Parameter(`in` = ParameterIn.QUERY, required = true, description = "The sub queue to search for the assigned messages.") @RequestParam(required = true) queueType: String): ResponseEntity> + fun getOwned(@Parameter(`in` = ParameterIn.QUERY, required = true, description = "The identifier that must match the message's `assigned` property in order to be returned.") @RequestParam(required = true, name = RestParameters.ASSIGNED_TO) assignedTo: String, + @Parameter(`in` = ParameterIn.QUERY, required = true, description = "The sub queue to search for the assigned messages.") @RequestParam(required = true, name = RestParameters.QUEUE_TYPE) queueType: String): ResponseEntity> { val assignedMessages: Queue = messageQueue.getAssignedMessagesForType(queueType, assignedTo) val ownedMessages = assignedMessages.stream().map { message -> MessageResponse(message) }.collect(Collectors.toList()) @@ -315,15 +317,15 @@ open class MessageQueueController : HasLogger * @return the [QueueMessage] object after it has been marked as `assigned`. Returns [HttpStatus.ACCEPTED] if the [QueueMessage] is already assigned to the current user, otherwise [HttpStatus.OK] if it was not `assigned` previously. */ @Operation(summary = "Assign an existing queue message to the provided identifier.", description = "Assign an existing queue message to the provided identifier. The message must already exist and not be assigned already to another identifier in order to be successfully assigned to the provided identifier.") - @PutMapping("$ENDPOINT_ASSIGN/{uuid}", produces = [MediaType.APPLICATION_JSON_VALUE]) + @PutMapping("$ENDPOINT_ASSIGN/{${RestParameters.UUID}}", produces = [MediaType.APPLICATION_JSON_VALUE]) @ApiResponses( ApiResponse(responseCode = "200", description = "Successfully assigned the message to the provided identifier. The message was not previously assigned."), ApiResponse(responseCode = "202", description = "The message was already assigned to the provided identifier."), ApiResponse(responseCode = "204", description = "No queue messages match the provided UUID.", content = [Content()]), ApiResponse(responseCode = "409", description = "The message is already assigned to another identifier.", content = [Content()]) ) - fun assignMessage(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The queue message UUID to assign.") @PathVariable(required = true) uuid: String, - @Parameter(`in` = ParameterIn.QUERY, required = true, description = "The identifier to assign the queue message to.") @RequestParam(required = true) assignedTo: String): ResponseEntity + fun assignMessage(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The queue message UUID to assign.") @PathVariable(required = true, name = RestParameters.UUID) uuid: String, + @Parameter(`in` = ParameterIn.QUERY, required = true, description = "The identifier to assign the queue message to.") @RequestParam(required = true, name = RestParameters.ASSIGNED_TO) assignedTo: String): ResponseEntity { val message = messageQueue.getMessageByUUID(uuid) if (message.isPresent) @@ -340,7 +342,7 @@ open class MessageQueueController : HasLogger else { LOG.error("Message with uuid [{}] in queue with type [{}] is already assigned to the identifier [{}]. Attempting to assign to identifier [{}].", messageToAssign.uuid, messageToAssign.type, messageToAssign.assignedTo, assignedTo) - throw ResponseStatusException(HttpStatus.CONFLICT, "The message with UUID: $uuid and ${messageToAssign.type} is already assigned to the identifier ${messageToAssign.assignedTo}.") + throw ResponseStatusException(HttpStatus.CONFLICT, "The message with UUID: [$uuid] and [${messageToAssign.type}] is already assigned to the identifier [${messageToAssign.assignedTo}].") } } @@ -368,8 +370,8 @@ open class MessageQueueController : HasLogger ApiResponse(responseCode = "200", description = "Successfully returns the next message in queue after assigning it to the provided `assignedTo` identifier."), ApiResponse(responseCode = "204", description = "No messages are available.", content = [Content()]) ) - fun getNext(@Parameter(`in` = ParameterIn.QUERY, required = true, description = "The sub queue identifier to query the next available message from.") @RequestParam(required = true) queueType: String, - @Parameter(`in` = ParameterIn.QUERY, required = true, description = "The identifier to assign the next available message to if one exists.") @RequestParam(required = true) assignedTo: String): ResponseEntity + fun getNext(@Parameter(`in` = ParameterIn.QUERY, required = true, description = "The sub queue identifier to query the next available message from.") @RequestParam(required = true, name = RestParameters.QUEUE_TYPE) queueType: String, + @Parameter(`in` = ParameterIn.QUERY, required = true, description = "The identifier to assign the next available message to if one exists.") @RequestParam(required = true, name = RestParameters.ASSIGNED_TO) assignedTo: String): ResponseEntity { val queueForType: Queue = messageQueue.getUnassignedMessagesForType(queueType) return if (queueForType.iterator().hasNext()) @@ -399,15 +401,15 @@ open class MessageQueueController : HasLogger * @return the [QueueMessage] object after it has been `released`. Returns [HttpStatus.ACCEPTED] if the [QueueMessage] is already `released`, otherwise [HttpStatus.OK] if it was `released` successfully. */ @Operation(summary = "Release the message assigned to the provided identifier.", description = "Release an assigned message so it can be assigned to another identifier.") - @PutMapping("$ENDPOINT_RELEASE/{uuid}", produces = [MediaType.APPLICATION_JSON_VALUE]) + @PutMapping("$ENDPOINT_RELEASE/{${RestParameters.UUID}}", produces = [MediaType.APPLICATION_JSON_VALUE]) @ApiResponses( ApiResponse(responseCode = "200", description = "Successfully released the message. The message was previously assigned."), ApiResponse(responseCode = "202", description = "The message is not currently assigned."), ApiResponse(responseCode = "204", description = "No queue messages match the provided UUID.", content = [Content()]), ApiResponse(responseCode = "409", description = "The identifier was provided and the message is assigned to another identifier so it cannot be released.", content = [Content()]) ) - fun releaseMessage(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The UUID of the message to release.") @PathVariable(required = true) uuid: String, - @Parameter(`in` = ParameterIn.QUERY, required = false, description = "If provided, the message will only be released if the current assigned identifier matches this value.") @RequestParam(required = false) assignedTo: String?): ResponseEntity + fun releaseMessage(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The UUID of the message to release.") @PathVariable(required = true, name = RestParameters.UUID) uuid: String, + @Parameter(`in` = ParameterIn.QUERY, required = false, description = "If provided, the message will only be released if the current assigned identifier matches this value.") @RequestParam(required = false, name = RestParameters.ASSIGNED_TO) assignedTo: String?): ResponseEntity { val message = messageQueue.getMessageByUUID(uuid) if (message.isPresent) @@ -422,7 +424,7 @@ open class MessageQueueController : HasLogger if (!assignedTo.isNullOrBlank() && messageToRelease.assignedTo != assignedTo) { - val errorMessage = "The message with UUID: $uuid and ${messageToRelease.type} cannot be released because it is already assigned to identifier ${messageToRelease.assignedTo} and the provided identifier was $assignedTo." + val errorMessage = "The message with UUID: [$uuid] and [${messageToRelease.type}] cannot be released because it is already assigned to identifier [${messageToRelease.assignedTo}] and the provided identifier was [$assignedTo]." LOG.error(errorMessage) throw ResponseStatusException(HttpStatus.CONFLICT, errorMessage) } @@ -450,14 +452,14 @@ open class MessageQueueController : HasLogger * @return [HttpStatus.NO_CONTENT] */ @Operation(summary = "Remove a queue message by UUID.", description = "Remove a queue message by UUID. If `assignedTo` is provided, the message must be currently assigned to that identifier for it to be removed.") - @DeleteMapping("$ENDPOINT_ENTRY/{uuid}") + @DeleteMapping("$ENDPOINT_ENTRY/{${RestParameters.UUID}}") @ApiResponses( ApiResponse(responseCode = "200", description = "Successfully removed the message."), ApiResponse(responseCode = "204", description = "No queue messages match the provided UUID.", content = [Content()]), ApiResponse(responseCode = "403", description = "The provided identifier does not match the message's current assignee so it cannot be removed.", content = [Content()]) ) - fun removeMessage(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The UUID of the message to remove.") @PathVariable(required = true) uuid: String, - @Parameter(`in` = ParameterIn.QUERY, required = false, description = "If provided, the message will only be removed if it is assigned to an identifier that matches this value.") @RequestParam(required = false) assignedTo: String?): ResponseEntity + fun removeMessage(@Parameter(`in` = ParameterIn.PATH, required = true, description = "The UUID of the message to remove.") @PathVariable(required = true, name = RestParameters.UUID) uuid: String, + @Parameter(`in` = ParameterIn.QUERY, required = false, description = "If provided, the message will only be removed if it is assigned to an identifier that matches this value.") @RequestParam(required = false, name = RestParameters.ASSIGNED_TO) assignedTo: String?): ResponseEntity { val message = messageQueue.getMessageByUUID(uuid) if (message.isPresent) @@ -465,7 +467,7 @@ open class MessageQueueController : HasLogger val messageToRemove = message.get() if ( !assignedTo.isNullOrBlank() && messageToRemove.assignedTo != assignedTo) { - val errorMessage = "Unable to remove message with UUID $uuid in Queue ${messageToRemove.type} because the provided assignee identifier: [$assignedTo] does not match the message's assignee identifier`: ${messageToRemove.assignedTo}" + val errorMessage = "Unable to remove message with UUID [$uuid] in Queue [${messageToRemove.type}] because the provided assignee identifier: [$assignedTo] does not match the message's assignee identifier: [${messageToRemove.assignedTo}]" LOG.error(errorMessage) throw ResponseStatusException(HttpStatus.FORBIDDEN, errorMessage) } @@ -493,7 +495,7 @@ open class MessageQueueController : HasLogger @Operation(summary = "Retrieve all unique owner identifiers for either a specified sub-queue or all sub-queues.", description = "Retrieve all owner identifier mapped to a list of the sub-queue identifiers that they are assigned any messages in.") @GetMapping(ENDPOINT_OWNERS, produces = [MediaType.APPLICATION_JSON_VALUE]) @ApiResponse(responseCode = "200", description = "Successfully returns the map of owner identifiers mapped to all the sub-queues that they have one or more assigned messages in.") - fun getOwners(@Parameter(`in` = ParameterIn.QUERY, required = false, description = "The sub queue to search for the owner identifiers.") @RequestParam(required = false) queueType: String?): ResponseEntity>> + fun getOwners(@Parameter(`in` = ParameterIn.QUERY, required = false, description = "The sub queue to search for the owner identifiers.") @RequestParam(required = false, name = RestParameters.QUEUE_TYPE) queueType: String?): ResponseEntity>> { return ResponseEntity.ok(messageQueue.getOwnersAndKeysMap(queueType)) } diff --git a/src/main/kotlin/au/kilemon/messagequeue/rest/controller/RestParameters.kt b/src/main/kotlin/au/kilemon/messagequeue/rest/controller/RestParameters.kt new file mode 100644 index 0000000..95f9fef --- /dev/null +++ b/src/main/kotlin/au/kilemon/messagequeue/rest/controller/RestParameters.kt @@ -0,0 +1,19 @@ +package au.kilemon.messagequeue.rest.controller + +/** + * A collection of constants used as REST parameters. + * + * @author github.com/Kilemonn + */ +object RestParameters +{ + const val ASSIGNED_TO = "assignedTo" + + const val QUEUE_TYPE = "queueType" + + const val DETAILED = "detailed" + + const val UUID = "uuid" + + const val INCLUDE_EMPTY = "includeEmpty" +} diff --git a/src/main/kotlin/au/kilemon/messagequeue/rest/response/ErrorResponse.kt b/src/main/kotlin/au/kilemon/messagequeue/rest/response/ErrorResponse.kt new file mode 100644 index 0000000..aa9c414 --- /dev/null +++ b/src/main/kotlin/au/kilemon/messagequeue/rest/response/ErrorResponse.kt @@ -0,0 +1,13 @@ +package au.kilemon.messagequeue.rest.response + +import au.kilemon.messagequeue.filter.CorrelationIdFilter +import com.fasterxml.jackson.annotation.JsonPropertyOrder +import org.slf4j.MDC + +/** + * An error response object returned when errors occur. + * + * @author github.com/Kilemonn + */ +@JsonPropertyOrder("correlationId", "message") +data class ErrorResponse(val message: String?, val correlationId: String? = MDC.get(CorrelationIdFilter.CORRELATION_ID)) diff --git a/src/main/kotlin/au/kilemon/messagequeue/rest/response/MessageResponse.kt b/src/main/kotlin/au/kilemon/messagequeue/rest/response/MessageResponse.kt index 4eb9eeb..a89ad9b 100644 --- a/src/main/kotlin/au/kilemon/messagequeue/rest/response/MessageResponse.kt +++ b/src/main/kotlin/au/kilemon/messagequeue/rest/response/MessageResponse.kt @@ -11,4 +11,4 @@ import org.slf4j.MDC * @author github.com/Kilemonn */ @JsonPropertyOrder("correlationId", "queueType", "message") -data class MessageResponse(val message: QueueMessage, val queueType: String = message.type, val correlationId: String? = MDC.get(CorrelationIdFilter.CORRELATION_LOG_PARAMETER)) +data class MessageResponse(val message: QueueMessage, val queueType: String = message.type, val correlationId: String? = MDC.get(CorrelationIdFilter.CORRELATION_ID)) diff --git a/src/main/kotlin/au/kilemon/messagequeue/rest/response/RestResponseExceptionHandler.kt b/src/main/kotlin/au/kilemon/messagequeue/rest/response/RestResponseExceptionHandler.kt new file mode 100644 index 0000000..1ed3e3e --- /dev/null +++ b/src/main/kotlin/au/kilemon/messagequeue/rest/response/RestResponseExceptionHandler.kt @@ -0,0 +1,22 @@ +package au.kilemon.messagequeue.rest.response + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler + +/** + * A response handler which maps the thrown [ResponseStatusException] into the [ErrorResponse]. + * + * @author github.com/Kilemonn + */ +@ControllerAdvice +class RestResponseExceptionHandler: ResponseEntityExceptionHandler() +{ + @ExceptionHandler(ResponseStatusException::class) + fun handleResponseStatusException(ex: ResponseStatusException): ResponseEntity + { + return ResponseEntity(ErrorResponse(ex.reason), ex.status) + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index f16f527..01e34a8 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -2,7 +2,7 @@ - + diff --git a/src/test/kotlin/au/kilemon/messagequeue/filter/TestCorrelationIdFilter.kt b/src/test/kotlin/au/kilemon/messagequeue/filter/TestCorrelationIdFilter.kt new file mode 100644 index 0000000..8d72269 --- /dev/null +++ b/src/test/kotlin/au/kilemon/messagequeue/filter/TestCorrelationIdFilter.kt @@ -0,0 +1,50 @@ +package au.kilemon.messagequeue.filter + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.slf4j.MDC +import java.util.* + +/** + * Test [CorrelationIdFilter] to make sure the [CorrelationIdFilter.CORRELATION_ID] is set correctly. + * + * @author github.com/Kilemonn + */ +class TestCorrelationIdFilter +{ + private val correlationIdFilter = CorrelationIdFilter() + + @BeforeEach + fun setUp() + { + MDC.clear() + } + + /** + * Ensure the provided `correlationId` is used when [CorrelationIdFilter.setCorrelationId] is called with a non-null + * argument. + */ + @Test + fun testSetCorrelationId_providedId() + { + val correlationId = "a-correlation-id-123456" + Assertions.assertNull(MDC.get(CorrelationIdFilter.CORRELATION_ID)) + + correlationIdFilter.setCorrelationId(correlationId) + Assertions.assertEquals(correlationId, MDC.get(CorrelationIdFilter.CORRELATION_ID)) + } + + /** + * Ensure that a [UUID] `correlationId` will be generated when [CorrelationIdFilter.setCorrelationId] is called + * with a `null` argument. + */ + @Test + fun testSetCorrelationId_generatedId() + { + Assertions.assertNull(MDC.get(CorrelationIdFilter.CORRELATION_ID)) + correlationIdFilter.setCorrelationId(null) + val generatedCorrelationId = MDC.get(CorrelationIdFilter.CORRELATION_ID) + Assertions.assertEquals(UUID.fromString(generatedCorrelationId).toString(), generatedCorrelationId) + } +} diff --git a/src/test/kotlin/au/kilemon/messagequeue/rest/controller/MessageQueueControllerTest.kt b/src/test/kotlin/au/kilemon/messagequeue/rest/controller/MessageQueueControllerTest.kt index 80ce119..1782f5d 100644 --- a/src/test/kotlin/au/kilemon/messagequeue/rest/controller/MessageQueueControllerTest.kt +++ b/src/test/kotlin/au/kilemon/messagequeue/rest/controller/MessageQueueControllerTest.kt @@ -1,6 +1,7 @@ package au.kilemon.messagequeue.rest.controller import au.kilemon.messagequeue.configuration.QueueConfiguration +import au.kilemon.messagequeue.filter.CorrelationIdFilter import au.kilemon.messagequeue.logging.LoggingConfiguration import au.kilemon.messagequeue.message.QueueMessage import au.kilemon.messagequeue.queue.MultiQueue @@ -167,7 +168,7 @@ class MessageQueueControllerTest @Test fun testCreateQueueEntry_withProvidedDefaults() { - val message = createQueueMessage(type = "testCreateQueueEntry_withProvidedDefaults", assignedTo = "assignedTo") + val message = createQueueMessage(type = "testCreateQueueEntry_withProvidedDefaults", assignedTo = "user-1") val mvcResult: MvcResult = mockMvc.perform(post(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ENTRY) .contentType(MediaType.APPLICATION_JSON_VALUE) @@ -280,7 +281,7 @@ class MessageQueueControllerTest } /** - * Test [MessageQueueController.getKeys] to ensure that all keys are returned. Specifically when entries are added and `includeEmpty` is set to `false`. + * Test [MessageQueueController.getKeys] to ensure that all keys are returned. Specifically when entries are added and [RestParameters.INCLUDE_EMPTY] is set to `false`. */ @Test fun testGetKeys_excludeEmpty() @@ -291,7 +292,7 @@ class MessageQueueControllerTest val mvcResult: MvcResult = mockMvc.perform(get(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_KEYS) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("includeEmpty", "false")) + .param(RestParameters.INCLUDE_EMPTY, "false")) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -317,7 +318,7 @@ class MessageQueueControllerTest val type = entries.first[0].type val detailed = true - val mvcResult: MvcResult = mockMvc.perform(get(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ALL + "?detailed=" + detailed) + val mvcResult: MvcResult = mockMvc.perform(get("${MessageQueueController.MESSAGE_QUEUE_BASE_PATH}/${MessageQueueController.ENDPOINT_ALL}?${RestParameters.DETAILED}=$detailed") .contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -348,7 +349,7 @@ class MessageQueueControllerTest val type = entries.first[0].type val mvcResult: MvcResult = mockMvc.perform(get(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ALL) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("queueType", type)) + .param(RestParameters.QUEUE_TYPE, type)) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -376,8 +377,8 @@ class MessageQueueControllerTest val mvcResult: MvcResult = mockMvc.perform(get(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_OWNED) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", assignedTo) - .param("queueType", entries.first[0].type)) + .param(RestParameters.ASSIGNED_TO, assignedTo) + .param(RestParameters.QUEUE_TYPE, entries.first[0].type)) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -402,8 +403,8 @@ class MessageQueueControllerTest val mvcResult: MvcResult = mockMvc.perform(get(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_OWNED) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", assignedTo) - .param("queueType", type)) + .param(RestParameters.ASSIGNED_TO, assignedTo) + .param(RestParameters.QUEUE_TYPE, type)) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -428,7 +429,7 @@ class MessageQueueControllerTest val assignedTo = "assigned" mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ASSIGN + "/" + uuid) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", assignedTo)) + .param(RestParameters.ASSIGNED_TO, assignedTo)) .andExpect(MockMvcResultMatchers.status().isNoContent) } @@ -445,7 +446,7 @@ class MessageQueueControllerTest val mvcResult: MvcResult = mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ASSIGN + "/" + message.uuid) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", assignedTo)) + .param(RestParameters.ASSIGNED_TO, assignedTo)) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -472,7 +473,7 @@ class MessageQueueControllerTest val mvcResult: MvcResult = mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ASSIGN + "/" + message.uuid) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", assignedTo)) + .param(RestParameters.ASSIGNED_TO, assignedTo)) .andExpect(MockMvcResultMatchers.status().isAccepted) .andReturn() @@ -505,7 +506,7 @@ class MessageQueueControllerTest val wrongAssignee = "wrong-assignee" mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ASSIGN + "/" + message.uuid) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", wrongAssignee)) + .param(RestParameters.ASSIGNED_TO, wrongAssignee)) .andExpect(MockMvcResultMatchers.status().isConflict) // Check the message is still assigned to the correct ID @@ -524,8 +525,8 @@ class MessageQueueControllerTest val type = "testGetNext_noNewMessages" mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_NEXT) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("queueType", type) - .param("assignedTo", assignedTo)) + .param(RestParameters.QUEUE_TYPE, type) + .param(RestParameters.ASSIGNED_TO, assignedTo)) .andExpect(MockMvcResultMatchers.status().isNoContent) Assertions.assertTrue(multiQueue.getQueueForType(type).isEmpty()) @@ -549,8 +550,8 @@ class MessageQueueControllerTest mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_NEXT) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("queueType", type) - .param("assignedTo", assignedTo)) + .param(RestParameters.QUEUE_TYPE, type) + .param(RestParameters.ASSIGNED_TO, assignedTo)) .andExpect(MockMvcResultMatchers.status().isNoContent) } @@ -574,8 +575,8 @@ class MessageQueueControllerTest val mvcResult: MvcResult = mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_NEXT) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("queueType", type) - .param("assignedTo", assignedTo)) + .param(RestParameters.QUEUE_TYPE, type) + .param(RestParameters.ASSIGNED_TO, assignedTo)) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -614,7 +615,7 @@ class MessageQueueControllerTest val mvcResult: MvcResult = mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_RELEASE + "/" + message.uuid) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", assignedTo)) + .param(RestParameters.ASSIGNED_TO, assignedTo)) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -693,7 +694,7 @@ class MessageQueueControllerTest val wrongAssignedTo = "wrong-assigned" mockMvc.perform(put(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_RELEASE + "/" + message.uuid) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", wrongAssignedTo)) + .param(RestParameters.ASSIGNED_TO, wrongAssignedTo)) .andExpect(MockMvcResultMatchers.status().isConflict) val assignedEntry = multiQueue.peekForType(message.type).get() @@ -760,7 +761,7 @@ class MessageQueueControllerTest val wrongAssignedTo = "wrong-assignee" mockMvc.perform(delete(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ENTRY + "/" + message.uuid) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("assignedTo", wrongAssignedTo)) + .param(RestParameters.ASSIGNED_TO, wrongAssignedTo)) .andExpect(MockMvcResultMatchers.status().isForbidden) val assignedEntry = multiQueue.peekForType(message.type).get() @@ -788,7 +789,7 @@ class MessageQueueControllerTest val mvcResult = mockMvc.perform(get(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_OWNERS) .contentType(MediaType.APPLICATION_JSON_VALUE) - .param("queueType", type)) + .param(RestParameters.QUEUE_TYPE, type)) .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() @@ -860,6 +861,61 @@ class MessageQueueControllerTest .andReturn() } + @Test + fun testCorrelationId_randomIdOnSuccess() + { + val message = createQueueMessage(type = "testCorrelationId_providedId") + + val mvcResult: MvcResult = mockMvc.perform(post(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ENTRY) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(gson.toJson(message))) + .andExpect(MockMvcResultMatchers.status().isCreated) + .andReturn() + + val messageResponse = gson.fromJson(mvcResult.response.contentAsString, MessageResponse::class.java) + Assertions.assertNotNull(messageResponse.correlationId) + Assertions.assertEquals(UUID.fromString(messageResponse.correlationId).toString(), messageResponse.correlationId) + } + + @Test + fun testCorrelationId_providedId() + { + val message = createQueueMessage(type = "testCorrelationId_providedId") + val correlationId = "my-correlation-id-123456" + + val mvcResult: MvcResult = mockMvc.perform(post(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ENTRY) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header(CorrelationIdFilter.CORRELATION_ID_HEADER, correlationId) + .content(gson.toJson(message))) + .andExpect(MockMvcResultMatchers.status().isCreated) + .andReturn() + + val messageResponse = gson.fromJson(mvcResult.response.contentAsString, MessageResponse::class.java) + Assertions.assertEquals(correlationId, messageResponse.correlationId) + } + + @Test + fun testCorrelationId_randomIdOnError() + { + val assignedTo = "assignee" + val message = createQueueMessage(type = "testCorrelationId_randomIdOnError", assignedTo = assignedTo) + Assertions.assertTrue(multiQueue.add(message)) + + val wrongAssignedTo = "wrong-assignee" + val mvcResult: MvcResult = mockMvc.perform(delete(MessageQueueController.MESSAGE_QUEUE_BASE_PATH + "/" + MessageQueueController.ENDPOINT_ENTRY + "/" + message.uuid) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .param(RestParameters.ASSIGNED_TO, wrongAssignedTo)) + .andExpect(MockMvcResultMatchers.status().isForbidden) + .andReturn() + + val messageResponse = gson.fromJson(mvcResult.response.contentAsString, Map::class.java) + Assertions.assertNotNull(messageResponse) + Assertions.assertTrue(messageResponse.containsKey(CorrelationIdFilter.CORRELATION_ID)) + val correlationId = messageResponse[CorrelationIdFilter.CORRELATION_ID] + Assertions.assertTrue(correlationId is String) + Assertions.assertEquals(correlationId, UUID.fromString(correlationId as String).toString()) + } + /** * A helper method which creates `4` [QueueMessage] objects and inserts them into the [MultiQueue]. * diff --git a/src/test/kotlin/au/kilemon/messagequeue/rest/response/TestRestResponseExceptionHandler.kt b/src/test/kotlin/au/kilemon/messagequeue/rest/response/TestRestResponseExceptionHandler.kt new file mode 100644 index 0000000..02e3bea --- /dev/null +++ b/src/test/kotlin/au/kilemon/messagequeue/rest/response/TestRestResponseExceptionHandler.kt @@ -0,0 +1,44 @@ +package au.kilemon.messagequeue.rest.response + +import au.kilemon.messagequeue.filter.CorrelationIdFilter +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.slf4j.MDC +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.util.* + +/** + * Test [RestResponseExceptionHandler] to make sure the [ResponseStatusException] is correctly transformed to [ErrorResponse]. + * + * @author github.com/Kilemonn + */ +class TestRestResponseExceptionHandler +{ + @BeforeEach + fun setUp() + { + MDC.clear() + } + + /** + * Ensure all the properties required to create an [ErrorResponse] are correctly extracted from the [ResponseStatusException]. + */ + @Test + fun testHandleResponseStatusException() + { + val correlationId = UUID.randomUUID().toString() + MDC.put(CorrelationIdFilter.CORRELATION_ID, correlationId) + val responseHandler = RestResponseExceptionHandler() + val message = "Bad error message" + val statusCode = HttpStatus.FORBIDDEN + val exception = ResponseStatusException(statusCode, message) + val response = responseHandler.handleResponseStatusException(exception) + + Assertions.assertEquals(statusCode, response.statusCode) + Assertions.assertNotNull(response.body) + Assertions.assertEquals(message, response.body!!.message) + Assertions.assertEquals(correlationId, response.body!!.correlationId) + } +}