Skip to content

Commit fab85b5

Browse files
wcdkjgithub-actions[bot]tomwatkins1994
authored
HMAI-101 Post transaction endpoint (#598)
* First pass at transaction post endpoint, includes service unit tests * First pass of transaction post controller tests * Wrote nomis gateway tests for new post transaction functionality * Fixing transaction post unit tests * Updated transaction controller integration tests with transaction post tests, updated prism mock schema, tidying * Refine retry http code conditions * Added retry exausted handling to post transactions * Commit changes made by code formatters * WIP accounting for 409 response from nomis, gateway tests * Recifying nomis gateway test to account for 409 * Remove wildcard imports * Linter corrections in exceptionHandler --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Tom Watkins <tom.watkins@justice.gov.uk>
1 parent 56a4dfd commit fab85b5

26 files changed

+796
-6
lines changed

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/config/HmppsIntegrationApiExceptionHandler.kt

+16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import jakarta.validation.ValidationException
55
import org.slf4j.LoggerFactory
66
import org.springframework.http.HttpStatus
77
import org.springframework.http.HttpStatus.BAD_REQUEST
8+
import org.springframework.http.HttpStatus.CONFLICT
89
import org.springframework.http.HttpStatus.FORBIDDEN
910
import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR
1011
import org.springframework.http.HttpStatus.NOT_FOUND
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler
1314
import org.springframework.web.bind.annotation.RestControllerAdvice
1415
import org.springframework.web.reactive.function.client.WebClientResponseException
1516
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.AuthenticationFailedException
17+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.ConflictFoundException
1618
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.EntityNotFoundException
1719

1820
@RestControllerAdvice
@@ -73,6 +75,20 @@ class HmppsIntegrationApiExceptionHandler {
7375
)
7476
}
7577

78+
@ExceptionHandler(ConflictFoundException::class)
79+
fun handleConflictException(e: ConflictFoundException): ResponseEntity<ErrorResponse> {
80+
logAndCapture("Conflict exception: {}", e)
81+
return ResponseEntity
82+
.status(CONFLICT)
83+
.body(
84+
ErrorResponse(
85+
status = CONFLICT,
86+
developerMessage = "Unable to complete request as this is a duplicate request",
87+
userMessage = e.message,
88+
),
89+
)
90+
}
91+
7692
@ExceptionHandler(WebClientResponseException::class)
7793
fun handleWebClientResponseException(e: WebClientResponseException): ResponseEntity<ErrorResponse?>? {
7894
logAndCapture("Upstream service down: {}", e)

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/config/OpenAPIConfig.kt

+9
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ class OpenAPIConfig {
7777
"developerMessage" to Schema<String>().type("string").example("Unable to complete request as an upstream service is not responding."),
7878
),
7979
),
80+
).addSchemas(
81+
"TransactionConflict",
82+
Schema<ErrorResponse>().description("Duplicate post - The client_unique_ref has been used before").properties(
83+
mapOf(
84+
"status" to Schema<Int>().type("number").example(409),
85+
"userMessage" to Schema<String>().type("string").example("Conflict"),
86+
"developerMessage" to Schema<String>().type("string").example("Duplicate post - The client_unique_ref has been used before"),
87+
),
88+
),
8089
)
8190
}
8291
}

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/config/RequestLogger.kt

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import jakarta.servlet.http.HttpServletResponse
55
import org.slf4j.LoggerFactory
66
import org.springframework.stereotype.Component
77
import org.springframework.web.servlet.HandlerInterceptor
8-
import java.util.stream.Collectors
98

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

37-
return "$requestIp \n $method \n $endpoint \n $body \n $requestURL"
37+
return "$requestIp \n $method \n $endpoint \n $requestURL"
3838
}
3939
}

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/controllers/v1/TransactionsController.kt

+86-2
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,24 @@ import jakarta.validation.ValidationException
99
import org.springframework.beans.factory.annotation.Autowired
1010
import org.springframework.web.bind.annotation.GetMapping
1111
import org.springframework.web.bind.annotation.PathVariable
12+
import org.springframework.web.bind.annotation.PostMapping
1213
import org.springframework.web.bind.annotation.RequestAttribute
14+
import org.springframework.web.bind.annotation.RequestBody
1315
import org.springframework.web.bind.annotation.RequestMapping
1416
import org.springframework.web.bind.annotation.RequestParam
1517
import org.springframework.web.bind.annotation.RestController
18+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.ConflictFoundException
1619
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.EntityNotFoundException
1720
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.DataResponse
1821
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Transaction
22+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.TransactionCreateResponse
23+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.TransactionRequest
1924
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Transactions
2025
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApiError
2126
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.roleconfig.ConsumerFilters
2227
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.GetTransactionForPersonService
2328
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.GetTransactionsForPersonService
29+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.PostTransactionForPersonService
2430
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.internal.AuditService
2531
import java.time.LocalDate
2632

@@ -31,6 +37,7 @@ class TransactionsController(
3137
@Autowired val auditService: AuditService,
3238
@Autowired val getTransactionsForPersonService: GetTransactionsForPersonService,
3339
@Autowired val getTransactionForPersonService: GetTransactionForPersonService,
40+
@Autowired val postTransactionsForPersonService: PostTransactionForPersonService,
3441
) {
3542
@Operation(
3643
summary = "Returns all transactions for a prisoner associated with an account code that they have at a prison.",
@@ -98,7 +105,7 @@ class TransactionsController(
98105
}
99106

100107
if (response.hasError(UpstreamApiError.Type.BAD_REQUEST)) {
101-
throw ValidationException("Either invalid HMPPS ID: $hmppsId at or incorrect prison: $prisonId")
108+
throw ValidationException("Either invalid HMPPS ID: $hmppsId or incorrect prison: $prisonId")
102109
}
103110

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

159166
if (response.hasError(UpstreamApiError.Type.BAD_REQUEST)) {
160-
throw ValidationException("Either invalid HMPPS ID: $hmppsId at or incorrect prison: $prisonId")
167+
throw ValidationException("Either invalid HMPPS ID: $hmppsId or incorrect prison: $prisonId")
161168
}
162169

163170
auditService.createEvent("GET_TRANSACTION_FOR_PERSON", mapOf("hmppsId" to hmppsId, "prisonId" to prisonId, "clientUniqueRef" to clientUniqueRef))
164171
return DataResponse(response.data)
165172
}
173+
174+
@Operation(
175+
summary = "Post a transaction.",
176+
description = "<b>Applicable filters</b>: <ul><li>prisons</li></ul>",
177+
responses = [
178+
ApiResponse(responseCode = "200", useReturnTypeSchema = true, description = "Successfully created a transaction."),
179+
ApiResponse(
180+
responseCode = "400",
181+
description = "",
182+
content = [
183+
Content(
184+
schema =
185+
io.swagger.v3.oas.annotations.media
186+
.Schema(ref = "#/components/schemas/BadRequest"),
187+
),
188+
],
189+
),
190+
ApiResponse(
191+
responseCode = "404",
192+
content = [
193+
Content(
194+
schema =
195+
io.swagger.v3.oas.annotations.media
196+
.Schema(ref = "#/components/schemas/PersonNotFound"),
197+
),
198+
],
199+
),
200+
ApiResponse(
201+
responseCode = "409",
202+
content = [
203+
Content(
204+
schema =
205+
io.swagger.v3.oas.annotations.media
206+
.Schema(ref = "#/components/schemas/TransactionConflict"),
207+
),
208+
],
209+
),
210+
ApiResponse(
211+
responseCode = "500",
212+
content = [
213+
Content(
214+
schema =
215+
io.swagger.v3.oas.annotations.media
216+
.Schema(ref = "#/components/schemas/InternalServerError"),
217+
),
218+
],
219+
),
220+
],
221+
)
222+
@PostMapping("/transactions")
223+
fun postTransactions(
224+
@PathVariable prisonId: String,
225+
@PathVariable hmppsId: String,
226+
@RequestAttribute filters: ConsumerFilters?,
227+
@RequestBody transactionRequest: TransactionRequest,
228+
): DataResponse<TransactionCreateResponse?> {
229+
val response = postTransactionsForPersonService.execute(prisonId, hmppsId, transactionRequest, filters)
230+
231+
if (response.hasError(UpstreamApiError.Type.BAD_REQUEST)) {
232+
throw ValidationException("Either invalid HMPPS ID: $hmppsId or incorrect prison: $prisonId or invalid request body: ${transactionRequest.toApiConformingMap()}")
233+
}
234+
235+
if (response.hasError(UpstreamApiError.Type.FORBIDDEN)) {
236+
throw ValidationException("The prisonId: $prisonId is not valid for your consumer profile. ${response.errors[0].description}")
237+
}
238+
239+
if (response.hasError(UpstreamApiError.Type.ENTITY_NOT_FOUND)) {
240+
throw EntityNotFoundException(" ${response.errors[0].description}")
241+
}
242+
243+
if (response.hasError(UpstreamApiError.Type.CONFLICT)) {
244+
throw ConflictFoundException("The transaction ${transactionRequest.clientTransactionId} has not been recorded as it is a duplicate.")
245+
}
246+
247+
auditService.createEvent("CREATE_TRANSACTION", mapOf("hmppsId" to hmppsId, "prisonId" to prisonId, "transactionRequest" to transactionRequest.toApiConformingMap().toString()))
248+
return DataResponse(response.data)
249+
}
166250
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception
2+
3+
import org.springframework.http.HttpStatus
4+
import org.springframework.web.bind.annotation.ResponseStatus
5+
6+
@ResponseStatus(HttpStatus.CONFLICT)
7+
class ConflictFoundException(
8+
message: String,
9+
) : RuntimeException(message)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception
2+
3+
data class ResponseException(
4+
override var message: String?,
5+
var statusCode: Int,
6+
) : RuntimeException(message)

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/extensions/WebClientWrapper.kt

+36
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,22 @@ import org.springframework.web.reactive.function.BodyInserters
66
import org.springframework.web.reactive.function.client.ExchangeStrategies
77
import org.springframework.web.reactive.function.client.WebClient
88
import org.springframework.web.reactive.function.client.WebClientResponseException
9+
import reactor.core.publisher.Mono
10+
import reactor.util.retry.Retry
11+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.ResponseException
912
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Response
1013
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApi
1114
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApiError
15+
import java.time.Duration
1216

17+
@Suppress("ktlint:standard:property-naming")
1318
class WebClientWrapper(
1419
val baseUrl: String,
1520
) {
21+
val CREATE_TRANSACTION_RETRY_HTTP_CODES = listOf(500, 502, 503, 504, 522, 599, 499, 408, 301)
22+
val MAX_RETRY_ATTEMPTS = 3L
23+
val MIN_BACKOFF_DURATION = Duration.ofSeconds(3)
24+
1625
val client: WebClient =
1726
WebClient
1827
.builder()
@@ -57,6 +66,32 @@ class WebClientWrapper(
5766
getErrorType(exception, upstreamApi, forbiddenAsError)
5867
}
5968

69+
inline fun <reified T> requestWithRetry(
70+
method: HttpMethod,
71+
uri: String,
72+
headers: Map<String, String>,
73+
upstreamApi: UpstreamApi,
74+
requestBody: Map<String, Any?>? = null,
75+
forbiddenAsError: Boolean = false,
76+
): WebClientWrapperResponse<T> =
77+
try {
78+
val responseData =
79+
getResponseBodySpec(method, uri, headers, requestBody)
80+
.retrieve()
81+
.onStatus({ status -> status.value() in CREATE_TRANSACTION_RETRY_HTTP_CODES }) { response -> Mono.error(ResponseException(null, response.statusCode().value())) }
82+
.bodyToMono(T::class.java)
83+
.retryWhen(
84+
Retry
85+
.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF_DURATION)
86+
.filter { throwable -> throwable is ResponseException }
87+
.onRetryExhaustedThrow { _, retrySignal -> throw ResponseException("External Service failed to process after max retries", HttpStatus.SERVICE_UNAVAILABLE.value()) },
88+
).block()!!
89+
90+
WebClientWrapperResponse.Success(responseData)
91+
} catch (exception: WebClientResponseException) {
92+
getErrorType(exception, upstreamApi, forbiddenAsError)
93+
}
94+
6095
inline fun <reified T> requestList(
6196
method: HttpMethod,
6297
uri: String,
@@ -105,6 +140,7 @@ class WebClientWrapper(
105140
val errorType =
106141
when (exception.statusCode) {
107142
HttpStatus.NOT_FOUND -> UpstreamApiError.Type.ENTITY_NOT_FOUND
143+
HttpStatus.CONFLICT -> UpstreamApiError.Type.CONFLICT
108144
HttpStatus.FORBIDDEN -> if (forbiddenAsError) UpstreamApiError.Type.FORBIDDEN else throw exception
109145
else -> throw exception
110146
}

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/gateways/NomisGateway.kt

+29
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Sentence
1717
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.SentenceAdjustment
1818
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.SentenceKeyDates
1919
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Transaction
20+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.TransactionRequest
2021
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Transactions
2122
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApi
2223
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisAccounts
@@ -31,6 +32,7 @@ import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisReason
3132
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisReferenceCode
3233
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisSentence
3334
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisSentenceSummary
35+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis.NomisTransactionResponse
3436

3537
@Component
3638
class NomisGateway(
@@ -391,6 +393,33 @@ class NomisGateway(
391393
}
392394
}
393395

396+
fun postTransactionForPerson(
397+
prisonId: String,
398+
nomisNumber: String,
399+
transactionRequest: TransactionRequest,
400+
): Response<NomisTransactionResponse> {
401+
val result =
402+
webClient.requestWithRetry<NomisTransactionResponse>(
403+
HttpMethod.POST,
404+
"/api/v1/prison/$prisonId/offenders/$nomisNumber/transactions",
405+
authenticationHeader(),
406+
UpstreamApi.NOMIS,
407+
requestBody = transactionRequest.toApiConformingMap(),
408+
)
409+
return when (result) {
410+
is WebClientWrapperResponse.Success -> {
411+
Response(data = result.data)
412+
}
413+
is WebClientWrapperResponse.Error,
414+
-> {
415+
Response(
416+
data = NomisTransactionResponse(id = null, description = null),
417+
errors = result.errors,
418+
)
419+
}
420+
}
421+
}
422+
394423
private fun authenticationHeader(): Map<String, String> {
395424
val token = hmppsAuthGateway.getClientToken("NOMIS")
396425

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps
2+
3+
data class TransactionCreateResponse(
4+
var transactionId: String? = null,
5+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps
2+
3+
data class TransactionRequest(
4+
var type: String,
5+
val description: String?,
6+
val amount: Int,
7+
val clientTransactionId: String,
8+
val clientUniqueRef: String,
9+
) {
10+
fun toApiConformingMap(): Map<String, Any?> =
11+
mapOf(
12+
"type" to type,
13+
"description" to description,
14+
"amount" to amount,
15+
"client_transaction_id" to clientTransactionId,
16+
"client_unique_ref" to clientUniqueRef,
17+
).filterValues { it != null }
18+
}

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/models/hmpps/UpstreamApiError.kt

+1
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ data class UpstreamApiError(
1010
BAD_REQUEST,
1111
FORBIDDEN,
1212
INTERNAL_SERVER_ERROR,
13+
CONFLICT,
1314
}
1415
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.nomis
2+
3+
data class NomisTransactionResponse(
4+
val id: String?,
5+
val description: String?,
6+
)

src/main/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/prismMocks/prison-api.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -24173,7 +24173,7 @@
2417324173
"type": "string",
2417424174
"description": "Valid transaction type for the prison_id",
2417524175
"example": "CANT",
24176-
"enum": ["CANT,REFND,PHONE,MRPR,MTDS,DTDS,CASHD,RELA,RELS"]
24176+
"enum": ["CANT", "REFND", "PHONE", "MRPR", "MTDS", "DTDS", "CASHD", "RELA", "RELS"]
2417724177
},
2417824178
"description": {
2417924179
"maxLength": 240,

0 commit comments

Comments
 (0)