Skip to content

Commit 6f14af0

Browse files
authored
[ESWE-1080] Expression-of-interest API (#596)
Publish Expression of Interest API
1 parent ce53735 commit 6f14af0

File tree

14 files changed

+438
-0
lines changed

14 files changed

+438
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.controllers.v1.person
2+
3+
import io.swagger.v3.oas.annotations.Operation
4+
import io.swagger.v3.oas.annotations.Parameter
5+
import io.swagger.v3.oas.annotations.media.Content
6+
import io.swagger.v3.oas.annotations.media.Schema
7+
import io.swagger.v3.oas.annotations.responses.ApiResponse
8+
import io.swagger.v3.oas.annotations.tags.Tag
9+
import org.springframework.beans.factory.annotation.Autowired
10+
import org.springframework.web.bind.annotation.PathVariable
11+
import org.springframework.web.bind.annotation.PutMapping
12+
import org.springframework.web.bind.annotation.RequestMapping
13+
import org.springframework.web.bind.annotation.RestController
14+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.Response
15+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.PutExpressionInterestService
16+
17+
@RestController
18+
@RequestMapping("/v1/persons")
19+
@Tag(name = "persons")
20+
class ExpressionInterestController(
21+
@Autowired val putExpressionInterestService: PutExpressionInterestService,
22+
) {
23+
@PutMapping("{hmppsId}/expression-of-interest/jobs/{jobid}")
24+
@Operation(
25+
summary = "Returns completed response",
26+
responses = [
27+
ApiResponse(
28+
responseCode = "200",
29+
useReturnTypeSchema = true,
30+
description = "Successfully submitted an expression of interest",
31+
),
32+
ApiResponse(
33+
responseCode = "400",
34+
description = "Bad Request",
35+
content = [Content(schema = Schema(ref = "#/components/schemas/BadRequest"))],
36+
),
37+
ApiResponse(
38+
responseCode = "403",
39+
description = "Access is forbidden",
40+
content = [Content(schema = Schema(ref = "#/components/schemas/ForbiddenResponse"))],
41+
),
42+
ApiResponse(
43+
responseCode = "404",
44+
content = [Content(schema = Schema(ref = "#/components/schemas/PersonNotFound"))],
45+
),
46+
],
47+
)
48+
fun submitExpressionOfInterest(
49+
@Parameter(description = "A HMPPS identifier", example = "A1234AA") @PathVariable hmppsId: String,
50+
@Parameter(description = "A job identifier") @PathVariable jobid: String,
51+
): Response<Unit> {
52+
putExpressionInterestService.sendExpressionOfInterest(hmppsId, jobid)
53+
54+
return Response(data = Unit, errors = emptyList())
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception
2+
3+
class MessageFailedException(
4+
msg: String,
5+
cause: Throwable? = null,
6+
) : RuntimeException(msg, cause)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps
2+
3+
data class ExpressionOfInterest(
4+
val jobId: String,
5+
val prisonNumber: String,
6+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps
2+
3+
data class HmppsMessage(
4+
val messageId: String,
5+
val eventType: HmppsMessageEventType,
6+
val description: String? = null,
7+
val messageAttributes: Map<String, String> = emptyMap(),
8+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps
2+
3+
import com.fasterxml.jackson.annotation.JsonValue
4+
5+
enum class HmppsMessageEventType(
6+
val type: String,
7+
@JsonValue val eventTypeCode: String,
8+
val description: String,
9+
) {
10+
EXPRESSION_OF_INTEREST_CREATED(
11+
type = "mjma-jobs-board.job.expression-of-interest.created",
12+
eventTypeCode = "ExpressionOfInterestCreated",
13+
description = "An expression of interest has been created",
14+
),
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.services
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import jakarta.validation.ValidationException
5+
import org.slf4j.Logger
6+
import org.slf4j.LoggerFactory
7+
import org.springframework.stereotype.Component
8+
import software.amazon.awssdk.services.sqs.model.SendMessageRequest
9+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.EntityNotFoundException
10+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.MessageFailedException
11+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.ExpressionOfInterest
12+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.HmppsMessage
13+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.HmppsMessageEventType
14+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.models.hmpps.UpstreamApiError
15+
import uk.gov.justice.hmpps.sqs.HmppsQueue
16+
import uk.gov.justice.hmpps.sqs.HmppsQueueService
17+
import uk.gov.justice.hmpps.sqs.eventTypeMessageAttributes
18+
import java.util.UUID
19+
20+
@Component
21+
class PutExpressionInterestService(
22+
private val getPersonService: GetPersonService,
23+
private val hmppsQueueService: HmppsQueueService,
24+
private val objectMapper: ObjectMapper,
25+
) {
26+
private val eoiQueue by lazy { hmppsQueueService.findByQueueId("jobsboardintegration") as HmppsQueue }
27+
private val eoiQueueSqsClient by lazy { eoiQueue.sqsClient }
28+
private val eoiQueueUrl by lazy { eoiQueue.queueUrl }
29+
30+
companion object {
31+
private val logger: Logger = LoggerFactory.getLogger(this::class.java)
32+
}
33+
34+
fun sendExpressionOfInterest(
35+
hmppsId: String,
36+
jobid: String,
37+
) {
38+
val personResponse = getPersonService.getNomisNumber(hmppsId = hmppsId)
39+
40+
if (personResponse.hasError(UpstreamApiError.Type.ENTITY_NOT_FOUND)) {
41+
logger.debug("ExpressionOfInterest: Could not find nomis number for hmppsId: $hmppsId")
42+
throw EntityNotFoundException("Could not find person with id: $hmppsId")
43+
}
44+
45+
if (personResponse.hasError(UpstreamApiError.Type.BAD_REQUEST)) {
46+
logger.debug("ExpressionOfInterest: Invalid hmppsId: $hmppsId")
47+
throw ValidationException("Invalid HMPPS ID: $hmppsId")
48+
}
49+
50+
val nomisNumber = personResponse.data?.nomisNumber ?: run { throw ValidationException("Invalid HMPPS ID: $hmppsId") }
51+
val expressionOfInterest = ExpressionOfInterest(jobid, nomisNumber)
52+
53+
val eventType = HmppsMessageEventType.EXPRESSION_OF_INTEREST_CREATED
54+
try {
55+
val hmppsMessage =
56+
objectMapper.writeValueAsString(
57+
HmppsMessage(
58+
messageId = UUID.randomUUID().toString(),
59+
eventType = eventType,
60+
messageAttributes = with(expressionOfInterest) { mapOf("jobId" to jobId, "prisonNumber" to prisonNumber) },
61+
),
62+
)
63+
64+
eoiQueueSqsClient.sendMessage(
65+
SendMessageRequest
66+
.builder()
67+
.queueUrl(eoiQueueUrl)
68+
.messageBody(hmppsMessage)
69+
.eventTypeMessageAttributes(eventType.type)
70+
.build(),
71+
)
72+
} catch (e: Exception) {
73+
throw MessageFailedException("Failed to send message to SQS", e)
74+
}
75+
}
76+
}

src/main/resources/application-dev.yml

+1
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ authorisation:
149149
- "/v1/persons/.*/plp-induction-schedule"
150150
- "/v1/persons/.*/plp-induction-schedule/history"
151151
- "/v1/persons/.*/plp-review-schedule"
152+
- "/v1/persons/[^/]+/expression-of-interest/jobs/[^/]+$"
152153
- "/v1/hmpps/id/by-nomis-number/[^/]*$"
153154
- "/v1/hmpps/id/nomis-number/by-hmpps-id/[^/]*$"
154155
filters:

src/main/resources/application-integration-test.yml

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ hmpps.sqs:
3838
queues:
3939
audit:
4040
queueName: "audit"
41+
jobsboardintegration:
42+
queueName: "jobsboard-integration"
4143

4244
authorisation:
4345
consumers:
@@ -73,6 +75,7 @@ authorisation:
7375
- "/v1/persons/.*/plp-induction-schedule"
7476
- "/v1/persons/.*/plp-induction-schedule/history"
7577
- "/v1/persons/.*/plp-review-schedule"
78+
- "/v1/persons/.*/expression-of-interest/jobs/[^/]*$"
7679
- "/v1/epf/person-details/.*/[^/]*$"
7780
- "/v1/hmpps/id/nomis-number/[^/]*$"
7881
- "/v1/hmpps/id/by-nomis-number/[^/]*$"

src/main/resources/application-local-docker.yml

+3
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ authorisation:
4747
- "/v1/persons/.*/plp-induction-schedule"
4848
- "/v1/persons/.*/plp-induction-schedule/history"
4949
- "/v1/persons/.*/plp-review-schedule"
50+
- "/v1/persons/.*/expression-of-interest/jobs/[^/]*$"
5051
- "/v1/epf/person-details/.*/[^/]*$"
5152
- "/v1/hmpps/id/nomis-number/[^/]*$"
5253
- "/v1/hmpps/id/by-nomis-number/[^/]*$"
@@ -80,3 +81,5 @@ hmpps.sqs:
8081
queues:
8182
audit:
8283
queueName: "audit"
84+
jobsboardintegration:
85+
queueName: "jobsboard-integration"

src/main/resources/application-local.yml

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ hmpps.sqs:
1919
queues:
2020
audit:
2121
queueName: "audit"
22+
jobsboardintegration:
23+
queueName: "jobsboard-integration"
2224

2325
authorisation:
2426
consumers:
@@ -53,6 +55,7 @@ authorisation:
5355
- "/v1/persons/.*/cell-location"
5456
- "/v1/persons/.*/plp-induction-schedule"
5557
- "/v1/persons/.*/plp-review-schedule"
58+
- "/v1/persons/[^/]+/expression-of-interest/jobs/[^/]+$"
5659
- "/v1/epf/person-details/.*/[^/]*$"
5760
- "/v1/hmpps/id/nomis-number/[^/]*$"
5861
- "/v1/hmpps/id/by-nomis-number/[^/]*$"

src/main/resources/application-test.yml

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ authorisation:
7474
- "/v1/persons/.*/plp-induction-schedule"
7575
- "/v1/persons/.*/plp-induction-schedule/history"
7676
- "/v1/persons/.*/plp-review-schedule"
77+
- "/v1/persons/.*/expression-of-interest/jobs/[^/]*$"
7778
- "/v1/hmpps/id/nomis-number/[^/]*$"
7879
- "/v1/hmpps/id/.*/nomis-number"
7980
- "/v1/hmpps/id/by-nomis-number/[^/]*$"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package uk.gov.justice.digital.hmpps.hmppsintegrationapi.controllers.v1.person
2+
3+
import io.kotest.core.spec.style.DescribeSpec
4+
import io.kotest.matchers.shouldBe
5+
import jakarta.validation.ValidationException
6+
import org.mockito.kotlin.any
7+
import org.mockito.kotlin.doNothing
8+
import org.mockito.kotlin.whenever
9+
import org.springframework.beans.factory.annotation.Autowired
10+
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
11+
import org.springframework.http.HttpStatus
12+
import org.springframework.test.context.ActiveProfiles
13+
import org.springframework.test.context.bean.override.mockito.MockitoBean
14+
import org.springframework.test.web.servlet.MockMvc
15+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.exception.EntityNotFoundException
16+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.helpers.IntegrationAPIMockMvc
17+
import uk.gov.justice.digital.hmpps.hmppsintegrationapi.services.PutExpressionInterestService
18+
19+
@WebMvcTest(controllers = [ExpressionInterestController::class])
20+
@ActiveProfiles("test")
21+
class ExpressionInterestControllerTest(
22+
@Autowired var springMockMvc: MockMvc,
23+
@MockitoBean val expressionOfInterestService: PutExpressionInterestService,
24+
) : DescribeSpec({
25+
val mockMvc = IntegrationAPIMockMvc(springMockMvc)
26+
val basePath = "/v1/persons"
27+
val validHmppsId = "AABCD1ABC"
28+
val invalidHmppsId = "INVALID_ID"
29+
val jobId = "5678"
30+
31+
describe("PUT $basePath/{hmppsId}/expression-of-interest/jobs/{jobId}") {
32+
it("should return 404 Not Found if ENTITY_NOT_FOUND error occurs") {
33+
validHmppsId.let { id ->
34+
whenever(expressionOfInterestService.sendExpressionOfInterest(id, jobId)).thenThrow(EntityNotFoundException("Could not find person with id: $id"))
35+
}
36+
37+
val result = mockMvc.performAuthorisedPut("$basePath/$validHmppsId/expression-of-interest/jobs/$jobId")
38+
result.response.status.shouldBe(HttpStatus.NOT_FOUND.value())
39+
}
40+
41+
it("should throw ValidationException if an invalid hmppsId is provided") {
42+
invalidHmppsId.let { id ->
43+
whenever(expressionOfInterestService.sendExpressionOfInterest(id, jobId)).thenThrow(ValidationException("Invalid HMPPS ID: $id"))
44+
}
45+
46+
val result = mockMvc.performAuthorisedPut("$basePath/$invalidHmppsId/expression-of-interest/jobs/$jobId")
47+
result.response.status.shouldBe(HttpStatus.BAD_REQUEST.value())
48+
}
49+
50+
it("should return 200 OK on successful expression of interest submission") {
51+
validHmppsId.let { id ->
52+
doNothing().whenever(expressionOfInterestService).sendExpressionOfInterest(id, jobId)
53+
}
54+
55+
val result = mockMvc.performAuthorisedPut("$basePath/$validHmppsId/expression-of-interest/jobs/$jobId")
56+
result.response.status.shouldBe(HttpStatus.OK.value())
57+
}
58+
59+
it("should return 500 Server Error if an exception occurs") {
60+
whenever(expressionOfInterestService.sendExpressionOfInterest(any(), any())).thenThrow(RuntimeException("Unexpected error"))
61+
62+
val result = mockMvc.performAuthorisedPut("$basePath/$validHmppsId/expression-of-interest/jobs/$jobId")
63+
result.response.status.shouldBe(HttpStatus.INTERNAL_SERVER_ERROR.value())
64+
}
65+
}
66+
})

src/test/kotlin/uk/gov/justice/digital/hmpps/hmppsintegrationapi/helpers/IntegrationAPIMockMvc.kt

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ class IntegrationAPIMockMvc(
1717
return mockMvc.perform(MockMvcRequestBuilders.get(path).header("subject-distinguished-name", subjectDistinguishedName)).andReturn()
1818
}
1919

20+
fun performAuthorisedPut(path: String): MvcResult {
21+
val subjectDistinguishedName = "C=GB,ST=London,L=London,O=Home Office,CN=automated-test-client"
22+
return mockMvc.perform(MockMvcRequestBuilders.put(path).header("subject-distinguished-name", subjectDistinguishedName)).andReturn()
23+
}
24+
2025
fun performAuthorisedWithCN(
2126
path: String,
2227
cn: String,

0 commit comments

Comments
 (0)