Skip to content
This repository was archived by the owner on Jul 28, 2025. It is now read-only.

Commit 2cd2fee

Browse files
feat: DTOSS-8116 - Created BS-Select post endpoint (#2)
Co-authored-by: Ian Nelson <[email protected]>
1 parent bc5d2a2 commit 2cd2fee

File tree

22 files changed

+733
-11
lines changed

22 files changed

+733
-11
lines changed

.github/actions/perform-static-analysis/action.yaml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,6 @@ runs:
5252
echo "${{ inputs.sonar_organisation_key }}"
5353
echo "${{ inputs.sonar_token }}"
5454
./.sonar/scanner/dotnet-sonarscanner begin /k:"${{ inputs.sonar_project_key }}" /o:"${{ inputs.sonar_organisation_key }}" /d:sonar.token="${{ inputs.sonar_token }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage.xml" /d:sonar.typescript.lcov.reportsPaths="src/web/coverage/lcov.info" /d:sonar.lang.patterns.ts=**/*.ts,**/*.tsx,**/*.cts,**/*.mts /d:sonar.lang.patterns.js=**/*.js,**/*.jsx,**/*.cjs,**/*.mjs,**/*.vue /d:sonar.javascript.enabled=false
55-
dotnet build src/api/ParticipantManager.API.sln
56-
./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/api/ServiceLayer.API.sln
57-
cd src/web
58-
npm ci
59-
npm run test:unit:coverage -- --coverageDirectory=coverage --coverageReporters=lcov
60-
sed -i 's|^SF:|SF:src/web/|g' coverage/lcov.info
61-
cd ../..
55+
dotnet build src/ServiceLayer.sln
56+
./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/ServiceLayer.sln
6257
./.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ inputs.sonar_token }}"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,6 @@ __pycache__/
285285
__azurite*.json
286286
__blobstorage__
287287
__queuestorage__
288+
289+
# Gitleaks report
290+
gitleaks-report.json

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Service Layer
1414
- [Configuration](#configuration)
1515
- [Usage](#usage)
1616
- [Testing](#testing)
17+
- [OpenAPI Specifications](#openapi-specifications)
18+
- [API Request Collection](#api-request-collection)
1719
- [Contacts](#contacts)
1820
- [Licence](#licence)
1921

@@ -64,6 +66,18 @@ The full test suite can be ran with `make test`.
6466
6567
Unit tests can be ran with `make test-unit`
6668
69+
## OpenAPI Specifications
70+
71+
The following OpenAPI Specification exist for Service Layer:
72+
73+
- Service Layer API - [Raw](https://raw.githubusercontent.com/NHSDigital/dtos-service-layer/refs/heads/main/api/openapi/openapi.yaml) / [Swagger Editor](https://editor.swagger.io/?url=https://raw.githubusercontent.com/NHSDigital/dtos-service-layer/refs/heads/main/api/openapi/openapi.yaml)
74+
75+
## API Request Collection
76+
77+
An API request collection for Service Layer exists in HAR format. It can be imported into Postman or Insomnia.
78+
79+
- [Collection folder](api/api-request-collection)
80+
6781
## Contacts
6882
6983
If you are on the NHS England Slack you can contact the team on #mays-team, otherwise you can open a GitHub issue.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"log": {
3+
"version": "1.2",
4+
"creator": {
5+
"name": "Insomnia REST Client",
6+
"version": "insomnia.desktop.app:v11.0.1"
7+
},
8+
"entries": [
9+
{
10+
"startedDateTime": "2025-04-04T09:29:36.496Z",
11+
"time": 0,
12+
"request": {
13+
"method": "POST",
14+
"url": "http://localhost:7001/api/bsselect/episodes/ingress",
15+
"httpVersion": "HTTP/1.1",
16+
"cookies": [],
17+
"headers": [
18+
{
19+
"name": "Content-Type",
20+
"value": "application/json"
21+
}
22+
],
23+
"queryString": [],
24+
"postData": {
25+
"mimeType": "application/json",
26+
"text": "{\n\t\"episode_id\": \"123\",\n\t\"nhs_number\": \"9990000000\",\n\t\"date_of_birth\": \"1970-01-01\",\n\t\"first_given_name\": \"Test\",\n\t\"family_name\": \"User\"\n}"
27+
},
28+
"headersSize": -1,
29+
"bodySize": -1
30+
},
31+
"response": {
32+
"status": 0,
33+
"statusText": "",
34+
"httpVersion": "HTTP/1.1",
35+
"cookies": [],
36+
"headers": [],
37+
"content": {
38+
"size": 0,
39+
"mimeType": ""
40+
},
41+
"redirectURL": "",
42+
"headersSize": -1,
43+
"bodySize": -1
44+
},
45+
"cache": {},
46+
"timings": {
47+
"blocked": -1,
48+
"dns": -1,
49+
"connect": -1,
50+
"send": 0,
51+
"wait": 0,
52+
"receive": 0,
53+
"ssl": -1
54+
},
55+
"comment": "BS Select Episode Ingress"
56+
}
57+
]
58+
}
59+
}

api/openapi/openapi.yaml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Service Layer API
4+
version: 1.0.0
5+
description: API used to ingest episodes from screening services into NSP
6+
7+
paths:
8+
9+
/bsselect/episodes/ingress:
10+
post:
11+
summary: BS Select Episode Ingress
12+
description: Validates the incoming BS Select episode and enqueues it for further processing within the NSP
13+
operationId: BS Select Episode Ingress
14+
tags:
15+
- Episodes
16+
requestBody:
17+
required: true
18+
content:
19+
application/json:
20+
schema:
21+
$ref: "#/components/schemas/BSSelectEpisode"
22+
responses:
23+
'200':
24+
description: Episode accepted
25+
content:
26+
application/json:
27+
schema:
28+
$ref: "#/components/schemas/BSSelectEpisode"
29+
'400':
30+
description: Bad request. Supplied episode payload invalid.
31+
content:
32+
text/plain:
33+
schema:
34+
type: string
35+
example: "nhs_number is required"
36+
'500':
37+
description: Internal server error. This indicates an unexpected failure in the service.
38+
39+
components:
40+
schemas:
41+
BSSelectEpisode:
42+
type: object
43+
required:
44+
- episode_id
45+
- nhs_number
46+
- date_of_birth
47+
- first_given_name
48+
- family_name
49+
properties:
50+
episode_id:
51+
type: string
52+
description: Unique identifier for the Episode
53+
nhs_number:
54+
type: string
55+
pattern: '^\d{10}$'
56+
description: NHS Number (exactly 10 digits)
57+
date_of_birth:
58+
type: string
59+
format: date
60+
description: Date of birth of the Participant
61+
first_given_name:
62+
type: string
63+
maxLength: 100
64+
description: First name of the Participant
65+
family_name:
66+
type: string
67+
maxLength: 100
68+
description: Surname of the Participant

compose.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
services:
2-
### ✅ API Backend (Azure Functions in .NET 9) ###
2+
### ✅ API (Azure Functions in .NET 9) ###
33
api:
44
container_name: "api"
55
build:
6-
context: ./src/api
6+
context: ./src
77
dockerfile: ServiceLayer.API/Dockerfile
88
platform: linux/amd64
99
restart: always
@@ -16,7 +16,6 @@ services:
1616
networks:
1717
- backend
1818

19-
2019
networks:
2120
backend:
2221
driver: bridge

scripts/config/sonar-scanner.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,5 @@ sonar.typescript.file.suffixes=.ts,.tsx
4141
sonar.dotnet.key=SonarAnalyzer.CSharp
4242

4343
# Run C# Scanner (Requires Build Before Analysis)
44-
sonar.dotnet.visualstudio.solution.file=ServiceLayer.API.sln
44+
sonar.dotnet.visualstudio.solution.file=ServiceLayer.sln
4545
sonar.dotnet.build=false # Set to true if you want Sonar to build before scanning

src/.gitkeep

Whitespace-only changes.

src/ServiceLayer.API/Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS installer-env
2+
WORKDIR /src/dotnet-function-app
3+
4+
COPY ./ServiceLayer.API/ServiceLayer.API.csproj .
5+
RUN dotnet restore
6+
7+
COPY ./ServiceLayer.API/ .
8+
9+
RUN dotnet publish -c Release -o /home/site/wwwroot
10+
11+
FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated9.0 AS production
12+
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
13+
AzureFunctionsJobHost__Logging__Console__IsEnabled=true \
14+
ASPNETCORE_ENVIRONMENT=Production
15+
16+
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
17+
USER appuser
18+
19+
COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"]
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using System.Globalization;
3+
using System.Text.Json;
4+
using Azure.Messaging;
5+
using Azure.Messaging.EventGrid;
6+
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.Azure.Functions.Worker;
8+
using Microsoft.Azure.Functions.Worker.Http;
9+
using Microsoft.Extensions.Logging;
10+
using ServiceLayer.API.Models;
11+
using ServiceLayer.API.Shared;
12+
13+
namespace ServiceLayer.API.Functions;
14+
15+
public class BSSelectFunctions(ILogger<BSSelectFunctions> logger, EventGridPublisherClient eventGridPublisherClient)
16+
{
17+
[Function("BSSelectIngressEpisode")]
18+
public async Task<IActionResult> IngressEpisode([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bsselect/episodes/ingress")] HttpRequestData req)
19+
{
20+
BSSelectEpisode? bssEpisodeEvent;
21+
22+
try
23+
{
24+
bssEpisodeEvent = await JsonSerializer.DeserializeAsync<BSSelectEpisode>(req.Body);
25+
26+
if (bssEpisodeEvent == null)
27+
{
28+
logger.LogError("Deserialization returned null");
29+
return new BadRequestObjectResult("Deserialization returned null");
30+
}
31+
32+
var validationContext = new ValidationContext(bssEpisodeEvent);
33+
34+
Validator.ValidateObject(bssEpisodeEvent, validationContext, true);
35+
}
36+
catch (Exception ex)
37+
{
38+
logger.LogError(ex, "An error occured when reading request body");
39+
return new BadRequestObjectResult(ex.Message);
40+
}
41+
42+
try
43+
{
44+
var createPathwayEnrolment = new CreatePathwayParticipantDto
45+
{
46+
PathwayTypeId = new Guid("11111111-1111-1111-1111-111111111113"),
47+
PathwayTypeName = "Breast Screening Routine",
48+
ScreeningName = "Breast Screening",
49+
NhsNumber = bssEpisodeEvent.NhsNumber!,
50+
DOB = DateOnly.Parse(bssEpisodeEvent.DateOfBirth!, CultureInfo.CurrentCulture),
51+
Name = $"{bssEpisodeEvent.FirstGivenName} {bssEpisodeEvent.FamilyName}",
52+
};
53+
54+
var cloudEvent = new CloudEvent(
55+
"ServiceLayer",
56+
"EpisodeEvent",
57+
createPathwayEnrolment
58+
);
59+
60+
var response = await eventGridPublisherClient.SendEventAsync(cloudEvent);
61+
62+
if (response.IsError)
63+
{
64+
logger.LogError(
65+
"Failed to send event to Event Grid.\nSource: {Source}\nType: {Type}\n Response status code: {Status}",
66+
cloudEvent.Source, cloudEvent.Type, response.Status);
67+
return new StatusCodeResult(500);
68+
}
69+
70+
return new OkResult();
71+
}
72+
catch (Exception ex)
73+
{
74+
logger.LogError(ex, "Failed to send event to Event Grid");
75+
return new StatusCodeResult(500);
76+
}
77+
}
78+
}

0 commit comments

Comments
 (0)