Skip to content
This repository was archived by the owner on Jul 28, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
28bdb0a
feat: Created an endpoint for BSSelect to post data to
alex-clayton-1 Apr 2, 2025
67bec63
test: Added unit tests for BSSelectFunctions
alex-clayton-1 Apr 3, 2025
4816b4f
refactor: Renamed method/function and modified error messages
alex-clayton-1 Apr 4, 2025
7994374
docs: Added OpenAPI spec and API request collection
alex-clayton-1 Apr 4, 2025
5129981
docs: Updated readme
alex-clayton-1 Apr 4, 2025
08a85b9
chore: Fix file formatting issues and made port number consistent
alex-clayton-1 Apr 4, 2025
bcf190f
style: Fixed file formatting issue
alex-clayton-1 Apr 4, 2025
9300393
fix solution name
ianfnelson Apr 4, 2025
8956787
fix solution name again
ianfnelson Apr 4, 2025
714b196
fix solution file name
ianfnelson Apr 4, 2025
97dd6ad
no web to be analysed
ianfnelson Apr 4, 2025
5fec2b3
refactor: Removed unused variable
alex-clayton-1 Apr 4, 2025
48e068c
feat: Introduced validation attribute for DateOnly to have better con…
alex-clayton-1 Apr 7, 2025
3595704
refactor: specify CultureInfo when parsing DateOnly and added Attribu…
alex-clayton-1 Apr 7, 2025
7f7d0fa
ci: Added Dockerfile
alex-clayton-1 Apr 7, 2025
67b9fd0
refactor: added gitleaks-report.json to ignore file and switched log …
alex-clayton-1 Apr 7, 2025
f9f0e50
tests: Refactored tests to improve maintainability
alex-clayton-1 Apr 7, 2025
e3c6736
test: Reused class level episode in remaining test methods
alex-clayton-1 Apr 10, 2025
dd3e61b
chore: Updated NuGet package versions
alex-clayton-1 Apr 10, 2025
08e1d21
test: Improved test to assert cloudevent contents
alex-clayton-1 Apr 10, 2025
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
9 changes: 2 additions & 7 deletions .github/actions/perform-static-analysis/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,6 @@ runs:
echo "${{ inputs.sonar_organisation_key }}"
echo "${{ inputs.sonar_token }}"
./.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
dotnet build src/api/ParticipantManager.API.sln
./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/api/ServiceLayer.API.sln
cd src/web
npm ci
npm run test:unit:coverage -- --coverageDirectory=coverage --coverageReporters=lcov
sed -i 's|^SF:|SF:src/web/|g' coverage/lcov.info
cd ../..
dotnet build src/ServiceLayer.sln
./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/ServiceLayer.sln
./.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ inputs.sonar_token }}"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,6 @@ __pycache__/
__azurite*.json
__blobstorage__
__queuestorage__

# Gitleaks report
gitleaks-report.json
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Service Layer
- [Configuration](#configuration)
- [Usage](#usage)
- [Testing](#testing)
- [OpenAPI Specifications](#openapi-specifications)
- [API Request Collection](#api-request-collection)
- [Contacts](#contacts)
- [Licence](#licence)

Expand Down Expand Up @@ -64,6 +66,18 @@ The full test suite can be ran with `make test`.

Unit tests can be ran with `make test-unit`

## OpenAPI Specifications

The following OpenAPI Specification exist for Service Layer:

- 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)

## API Request Collection

An API request collection for Service Layer exists in HAR format. It can be imported into Postman or Insomnia.

- [Collection folder](api/api-request-collection)

## Contacts

If you are on the NHS England Slack you can contact the team on #mays-team, otherwise you can open a GitHub issue.
Expand Down
59 changes: 59 additions & 0 deletions api/api-request-collection/servicelayer-api-2025-04-04.har
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"log": {
"version": "1.2",
"creator": {
"name": "Insomnia REST Client",
"version": "insomnia.desktop.app:v11.0.1"
},
"entries": [
{
"startedDateTime": "2025-04-04T09:29:36.496Z",
"time": 0,
"request": {
"method": "POST",
"url": "http://localhost:7001/api/bsselect/episodes/ingress",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
{
"name": "Content-Type",
"value": "application/json"
}
],
"queryString": [],
"postData": {
"mimeType": "application/json",
"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}"
},
"headersSize": -1,
"bodySize": -1
},
"response": {
"status": 0,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [],
"content": {
"size": 0,
"mimeType": ""
},
"redirectURL": "",
"headersSize": -1,
"bodySize": -1
},
"cache": {},
"timings": {
"blocked": -1,
"dns": -1,
"connect": -1,
"send": 0,
"wait": 0,
"receive": 0,
"ssl": -1
},
"comment": "BS Select Episode Ingress"
}
]
}
}
68 changes: 68 additions & 0 deletions api/openapi/openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
openapi: 3.0.3
info:
title: Service Layer API
version: 1.0.0
description: API used to ingest episodes from screening services into NSP

paths:

/bsselect/episodes/ingress:
post:
summary: BS Select Episode Ingress
description: Validates the incoming BS Select episode and enqueues it for further processing within the NSP
operationId: BS Select Episode Ingress
tags:
- Episodes
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BSSelectEpisode"
responses:
'200':
description: Episode accepted
content:
application/json:
schema:
$ref: "#/components/schemas/BSSelectEpisode"
'400':
description: Bad request. Supplied episode payload invalid.
content:
text/plain:
schema:
type: string
example: "nhs_number is required"
'500':
description: Internal server error. This indicates an unexpected failure in the service.

components:
schemas:
BSSelectEpisode:
type: object
required:
- episode_id
- nhs_number
- date_of_birth
- first_given_name
- family_name
properties:
episode_id:
type: string
description: Unique identifier for the Episode
nhs_number:
type: string
pattern: '^\d{10}$'
description: NHS Number (exactly 10 digits)
date_of_birth:
type: string
format: date
description: Date of birth of the Participant
first_given_name:
type: string
maxLength: 100
description: First name of the Participant
family_name:
type: string
maxLength: 100
description: Surname of the Participant
5 changes: 2 additions & 3 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
services:
### ✅ API Backend (Azure Functions in .NET 9) ###
### ✅ API (Azure Functions in .NET 9) ###
api:
container_name: "api"
build:
context: ./src/api
context: ./src
dockerfile: ServiceLayer.API/Dockerfile
platform: linux/amd64
restart: always
Expand All @@ -16,7 +16,6 @@ services:
networks:
- backend


networks:
backend:
driver: bridge
2 changes: 1 addition & 1 deletion scripts/config/sonar-scanner.properties
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ sonar.typescript.file.suffixes=.ts,.tsx
sonar.dotnet.key=SonarAnalyzer.CSharp

# Run C# Scanner (Requires Build Before Analysis)
sonar.dotnet.visualstudio.solution.file=ServiceLayer.API.sln
sonar.dotnet.visualstudio.solution.file=ServiceLayer.sln
sonar.dotnet.build=false # Set to true if you want Sonar to build before scanning
Empty file removed src/.gitkeep
Empty file.
19 changes: 19 additions & 0 deletions src/ServiceLayer.API/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS installer-env
WORKDIR /src/dotnet-function-app

COPY ./ServiceLayer.API/ServiceLayer.API.csproj .
RUN dotnet restore

COPY ./ServiceLayer.API/ .

RUN dotnet publish -c Release -o /home/site/wwwroot

FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated9.0 AS production
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
AzureFunctionsJobHost__Logging__Console__IsEnabled=true \
ASPNETCORE_ENVIRONMENT=Production

RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser

COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"]
78 changes: 78 additions & 0 deletions src/ServiceLayer.API/Functions/BSSelectFunctions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.Json;
using Azure.Messaging;
using Azure.Messaging.EventGrid;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using ServiceLayer.API.Models;
using ServiceLayer.API.Shared;

namespace ServiceLayer.API.Functions;

public class BSSelectFunctions(ILogger<BSSelectFunctions> logger, EventGridPublisherClient eventGridPublisherClient)
{
[Function("BSSelectIngressEpisode")]
public async Task<IActionResult> IngressEpisode([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bsselect/episodes/ingress")] HttpRequestData req)
{
BSSelectEpisode? bssEpisodeEvent;

try
{
bssEpisodeEvent = await JsonSerializer.DeserializeAsync<BSSelectEpisode>(req.Body);

if (bssEpisodeEvent == null)
{
logger.LogError("Deserialization returned null");
return new BadRequestObjectResult("Deserialization returned null");
}

var validationContext = new ValidationContext(bssEpisodeEvent);

Validator.ValidateObject(bssEpisodeEvent, validationContext, true);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occured when reading request body");
return new BadRequestObjectResult(ex.Message);
}

try
{
var createPathwayEnrolment = new CreatePathwayParticipantDto
{
PathwayTypeId = new Guid("11111111-1111-1111-1111-111111111113"),
PathwayTypeName = "Breast Screening Routine",
ScreeningName = "Breast Screening",
NhsNumber = bssEpisodeEvent.NhsNumber!,
DOB = DateOnly.Parse(bssEpisodeEvent.DateOfBirth!, CultureInfo.CurrentCulture),
Name = $"{bssEpisodeEvent.FirstGivenName} {bssEpisodeEvent.FamilyName}",
};

var cloudEvent = new CloudEvent(
"ServiceLayer",
"EpisodeEvent",
createPathwayEnrolment
);

var response = await eventGridPublisherClient.SendEventAsync(cloudEvent);

if (response.IsError)
{
logger.LogError(
"Failed to send event to Event Grid.\nSource: {Source}\nType: {Type}\n Response status code: {Status}",
cloudEvent.Source, cloudEvent.Type, response.Status);
return new StatusCodeResult(500);
}

return new OkResult();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send event to Event Grid");
return new StatusCodeResult(500);
}
}
}
30 changes: 30 additions & 0 deletions src/ServiceLayer.API/Models/BSSelectEpisode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using ServiceLayer.API.Shared;

namespace ServiceLayer.API.Models;

public class BSSelectEpisode
{
[JsonPropertyName("episode_id")]
[Required(ErrorMessage = "episode_id is required")]
public string? EpisodeId { get; set; }

[JsonPropertyName("nhs_number")]
[Required(ErrorMessage = "nhs_number is required")]
[RegularExpression(@"^\d{10}$", ErrorMessage = "nhs_number must be exactly 10 digits")]
public string? NhsNumber { get; set; }

[JsonPropertyName("date_of_birth")]
[Required(ErrorMessage = "date_of_birth is required")]
[ValidDateOnly(ErrorMessage = "date_of_birth is invalid")]
public string? DateOfBirth { get; set; }

[JsonPropertyName("first_given_name")]
[Required(ErrorMessage = "first_given_name is required")]
public string? FirstGivenName { get; set; }

[JsonPropertyName("family_name")]
[Required(ErrorMessage = "family_name is required")]
public string? FamilyName { get; set; }
}
29 changes: 29 additions & 0 deletions src/ServiceLayer.API/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Azure.Identity;
using Azure.Messaging.EventGrid;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var eventGridTopicUrl = Environment.GetEnvironmentVariable("EVENT_GRID_TOPIC_URL")
?? throw new InvalidOperationException($"Environment variable 'EVENT_GRID_TOPIC_URL' is not set or is empty.");
var eventGridTopicKey = Environment.GetEnvironmentVariable("EVENT_GRID_TOPIC_KEY")
?? throw new InvalidOperationException($"Environment variable 'EVENT_GRID_TOPIC_KEY' is not set or is empty.");

var host = new HostBuilder()
.ConfigureFunctionsWebApplication()
.ConfigureServices((context, services) =>
{
services.AddSingleton(sp =>
{
var endpoint = new Uri(eventGridTopicUrl);
if (context.HostingEnvironment.IsDevelopment())
{
var credentials = new Azure.AzureKeyCredential(eventGridTopicKey);
return new EventGridPublisherClient(endpoint, credentials);
}

return new EventGridPublisherClient(endpoint, new ManagedIdentityCredential());
});
})
.Build();

await host.RunAsync();
9 changes: 9 additions & 0 deletions src/ServiceLayer.API/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"profiles": {
"ServiceLayer.API": {
"commandName": "Project",
"commandLineArgs": "--port 7001",
"launchBrowser": false
}
}
}
Loading
Loading