Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HMAI-101 Post transaction endpoint #598

Merged
merged 14 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import jakarta.validation.ValidationException
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.http.HttpStatus.FORBIDDEN
import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR
import org.springframework.http.HttpStatus.NOT_FOUND
Expand All @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.reactive.function.client.WebClientResponseException
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.AuthenticationFailedException
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.ConflictFoundException
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.EntityNotFoundException

@RestControllerAdvice
Expand Down Expand Up @@ -73,6 +75,20 @@ class HmppsIntegrationApiExceptionHandler {
)
}

@ExceptionHandler(ConflictFoundException::class)
fun handleConflictException(e: ConflictFoundException): ResponseEntity<ErrorResponse> {
logAndCapture("Conflict exception: {}", e)
return ResponseEntity
.status(CONFLICT)
.body(
ErrorResponse(
status = CONFLICT,
developerMessage = "Unable to complete request as this is a duplicate request",
userMessage = e.message,
),
)
}

@ExceptionHandler(WebClientResponseException::class)
fun handleWebClientResponseException(e: WebClientResponseException): ResponseEntity<ErrorResponse?>? {
logAndCapture("Upstream service down: {}", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ class OpenAPIConfig {
"developerMessage" to Schema<String>().type("string").example("Unable to complete request as an upstream service is not responding."),
),
),
).addSchemas(
"TransactionConflict",
Schema<ErrorResponse>().description("Duplicate post - The client_unique_ref has been used before").properties(
mapOf(
"status" to Schema<Int>().type("number").example(409),
"userMessage" to Schema<String>().type("string").example("Conflict"),
"developerMessage" to Schema<String>().type("string").example("Duplicate post - The client_unique_ref has been used before"),
),
),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import java.util.stream.Collectors

// Intercepts incoming requests and logs them
@Component
Expand All @@ -32,8 +31,9 @@ class RequestLogger : HandlerInterceptor {
val method: String = "Method: " + request.method
val endpoint: String = "Request URI: " + request.requestURI // Could this expose authentication credentials?
val requestURL: String = "Full Request URL: " + request.requestURL
val body: String = ("Body: " + request.reader.lines().collect(Collectors.joining()))
// Unable to use reader here and read the request body into a controller method
// val body: String = ("Body: " + request.reader.lines().collect(Collectors.joining()))

return "$requestIp \n $method \n $endpoint \n $body \n $requestURL"
return "$requestIp \n $method \n $endpoint \n $requestURL"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,24 @@ import jakarta.validation.ValidationException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestAttribute
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.ConflictFoundException
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.EntityNotFoundException
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.DataResponse
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Transaction
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.TransactionCreateResponse
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.TransactionRequest
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Transactions
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApiError
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.roleconfig.ConsumerFilters
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.GetTransactionForPersonService
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.GetTransactionsForPersonService
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.PostTransactionForPersonService
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.internal.AuditService
import java.time.LocalDate

Expand All @@ -31,6 +37,7 @@ class TransactionsController(
@Autowired val auditService: AuditService,
@Autowired val getTransactionsForPersonService: GetTransactionsForPersonService,
@Autowired val getTransactionForPersonService: GetTransactionForPersonService,
@Autowired val postTransactionsForPersonService: PostTransactionForPersonService,
) {
@Operation(
summary = "Returns all transactions for a prisoner associated with an account code that they have at a prison.",
Expand Down Expand Up @@ -98,7 +105,7 @@ class TransactionsController(
}

if (response.hasError(UpstreamApiError.Type.BAD_REQUEST)) {
throw ValidationException("Either invalid HMPPS ID: $hmppsId at or incorrect prison: $prisonId")
throw ValidationException("Either invalid HMPPS ID: $hmppsId or incorrect prison: $prisonId")
}

auditService.createEvent("GET_TRANSACTIONS_FOR_PERSON", mapOf("hmppsId" to hmppsId, "prisonId" to prisonId, "fromDate" to fromDate, "toDate" to toDate))
Expand Down Expand Up @@ -157,10 +164,87 @@ class TransactionsController(
}

if (response.hasError(UpstreamApiError.Type.BAD_REQUEST)) {
throw ValidationException("Either invalid HMPPS ID: $hmppsId at or incorrect prison: $prisonId")
throw ValidationException("Either invalid HMPPS ID: $hmppsId or incorrect prison: $prisonId")
}

auditService.createEvent("GET_TRANSACTION_FOR_PERSON", mapOf("hmppsId" to hmppsId, "prisonId" to prisonId, "clientUniqueRef" to clientUniqueRef))
return DataResponse(response.data)
}

@Operation(
summary = "Post a transaction.",
description = "<b>Applicable filters</b>: <ul><li>prisons</li></ul>",
responses = [
ApiResponse(responseCode = "200", useReturnTypeSchema = true, description = "Successfully created a transaction."),
ApiResponse(
responseCode = "400",
description = "",
content = [
Content(
schema =
io.swagger.v3.oas.annotations.media
.Schema(ref = "#/components/schemas/BadRequest"),
),
],
),
ApiResponse(
responseCode = "404",
content = [
Content(
schema =
io.swagger.v3.oas.annotations.media
.Schema(ref = "#/components/schemas/PersonNotFound"),
),
],
),
ApiResponse(
responseCode = "409",
content = [
Content(
schema =
io.swagger.v3.oas.annotations.media
.Schema(ref = "#/components/schemas/TransactionConflict"),
),
],
),
ApiResponse(
responseCode = "500",
content = [
Content(
schema =
io.swagger.v3.oas.annotations.media
.Schema(ref = "#/components/schemas/InternalServerError"),
),
],
),
],
)
@PostMapping("/transactions")
fun postTransactions(
@PathVariable prisonId: String,
@PathVariable hmppsId: String,
@RequestAttribute filters: ConsumerFilters?,
@RequestBody transactionRequest: TransactionRequest,
): DataResponse<TransactionCreateResponse?> {
val response = postTransactionsForPersonService.execute(prisonId, hmppsId, transactionRequest, filters)

if (response.hasError(UpstreamApiError.Type.BAD_REQUEST)) {
throw ValidationException("Either invalid HMPPS ID: $hmppsId or incorrect prison: $prisonId or invalid request body: ${transactionRequest.toApiConformingMap()}")
}

if (response.hasError(UpstreamApiError.Type.FORBIDDEN)) {
throw ValidationException("The prisonId: $prisonId is not valid for your consumer profile. ${response.errors[0].description}")
}

if (response.hasError(UpstreamApiError.Type.ENTITY_NOT_FOUND)) {
throw EntityNotFoundException(" ${response.errors[0].description}")
}

if (response.hasError(UpstreamApiError.Type.CONFLICT)) {
throw ConflictFoundException("The transaction ${transactionRequest.clientTransactionId} has not been recorded as it is a duplicate.")
}

auditService.createEvent("CREATE_TRANSACTION", mapOf("hmppsId" to hmppsId, "prisonId" to prisonId, "transactionRequest" to transactionRequest.toApiConformingMap().toString()))
return DataResponse(response.data)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception

import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ResponseStatus

@ResponseStatus(HttpStatus.CONFLICT)
class ConflictFoundException(
message: String,
) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception

data class ResponseException(
override var message: String?,
var statusCode: Int,
) : RuntimeException(message)
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.ExchangeStrategies
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import reactor.core.publisher.Mono
import reactor.util.retry.Retry
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.ResponseException
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Response
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApi
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApiError
import java.time.Duration

@Suppress("ktlint:standard:property-naming")
class WebClientWrapper(
val baseUrl: String,
) {
val CREATE_TRANSACTION_RETRY_HTTP_CODES = listOf(500, 502, 503, 504, 522, 599, 499, 408, 301)
val MAX_RETRY_ATTEMPTS = 3L
val MIN_BACKOFF_DURATION = Duration.ofSeconds(3)

val client: WebClient =
WebClient
.builder()
Expand Down Expand Up @@ -57,6 +66,32 @@ class WebClientWrapper(
getErrorType(exception, upstreamApi, forbiddenAsError)
}

inline fun <reified T> requestWithRetry(
method: HttpMethod,
uri: String,
headers: Map<String, String>,
upstreamApi: UpstreamApi,
requestBody: Map<String, Any?>? = null,
forbiddenAsError: Boolean = false,
): WebClientWrapperResponse<T> =
try {
val responseData =
getResponseBodySpec(method, uri, headers, requestBody)
.retrieve()
.onStatus({ status -> status.value() in CREATE_TRANSACTION_RETRY_HTTP_CODES }) { response -> Mono.error(ResponseException(null, response.statusCode().value())) }
.bodyToMono(T::class.java)
.retryWhen(
Retry
.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF_DURATION)
.filter { throwable -> throwable is ResponseException }
.onRetryExhaustedThrow { _, retrySignal -> throw ResponseException("External Service failed to process after max retries", HttpStatus.SERVICE_UNAVAILABLE.value()) },
).block()!!

WebClientWrapperResponse.Success(responseData)
} catch (exception: WebClientResponseException) {
getErrorType(exception, upstreamApi, forbiddenAsError)
}

inline fun <reified T> requestList(
method: HttpMethod,
uri: String,
Expand Down Expand Up @@ -105,6 +140,7 @@ class WebClientWrapper(
val errorType =
when (exception.statusCode) {
HttpStatus.NOT_FOUND -> UpstreamApiError.Type.ENTITY_NOT_FOUND
HttpStatus.CONFLICT -> UpstreamApiError.Type.CONFLICT
HttpStatus.FORBIDDEN -> if (forbiddenAsError) UpstreamApiError.Type.FORBIDDEN else throw exception
else -> throw exception
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Sentence
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.SentenceAdjustment
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.SentenceKeyDates
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Transaction
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.TransactionRequest
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Transactions
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApi
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisAccounts
Expand All @@ -31,6 +32,7 @@ import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisReason
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisReferenceCode
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisSentence
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisSentenceSummary
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisTransactionResponse

@Component
class NomisGateway(
Expand Down Expand Up @@ -391,6 +393,33 @@ class NomisGateway(
}
}

fun postTransactionForPerson(
prisonId: String,
nomisNumber: String,
transactionRequest: TransactionRequest,
): Response<NomisTransactionResponse> {
val result =
webClient.requestWithRetry<NomisTransactionResponse>(
HttpMethod.POST,
"/api/v1/prison/$prisonId/offenders/$nomisNumber/transactions",
authenticationHeader(),
UpstreamApi.NOMIS,
requestBody = transactionRequest.toApiConformingMap(),
)
return when (result) {
is WebClientWrapperResponse.Success -> {
Response(data = result.data)
}
is WebClientWrapperResponse.Error,
-> {
Response(
data = NomisTransactionResponse(id = null, description = null),
errors = result.errors,
)
}
}
}

private fun authenticationHeader(): Map<String, String> {
val token = hmppsAuthGateway.getClientToken("NOMIS")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps

data class TransactionCreateResponse(
var transactionId: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps

data class TransactionRequest(
var type: String,
val description: String?,
val amount: Int,
val clientTransactionId: String,
val clientUniqueRef: String,
) {
fun toApiConformingMap(): Map<String, Any?> =
mapOf(
"type" to type,
"description" to description,
"amount" to amount,
"client_transaction_id" to clientTransactionId,
"client_unique_ref" to clientUniqueRef,
).filterValues { it != null }
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ data class UpstreamApiError(
BAD_REQUEST,
FORBIDDEN,
INTERNAL_SERVER_ERROR,
CONFLICT,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis

data class NomisTransactionResponse(
val id: String?,
val description: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -24173,7 +24173,7 @@
"type": "string",
"description": "Valid transaction type for the prison_id",
"example": "CANT",
"enum": ["CANT,REFND,PHONE,MRPR,MTDS,DTDS,CASHD,RELA,RELS"]
"enum": ["CANT", "REFND", "PHONE", "MRPR", "MTDS", "DTDS", "CASHD", "RELA", "RELS"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like swagger deals with this appropriately:

allowableValues
public abstract String allowableValues
Limits the acceptable values for this parameter.
There are three ways to describe the allowable values:

To set a list of values, provide a comma-separated list. For example: first, second, third.

},
"description": {
"maxLength": 240,
Expand Down
Loading
Loading