Skip to content

Commit a167adb

Browse files
Merge branch 'dev' into users/mtherien/leglink-912-change-config-location
2 parents 578cbae + c664e85 commit a167adb

71 files changed

Lines changed: 12495 additions & 255 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.docker/create-dbs.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'link-tenant') CREATE DA
77
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'link-validation') CREATE DATABASE [link-validation];
88
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'link-querydispatch') CREATE DATABASE [link-querydispatch];
99
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'link-account') CREATE DATABASE [link-account];
10-
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'link-submission') CREATE DATABASE [link-submission];
10+
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'link-submission') CREATE DATABASE [link-submission];
11+
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'link-mock-dmrp') CREATE DATABASE [link-mock-dmrp];

Azure_Pipelines/_deploy_all_services.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,14 @@ jobs:
168168
repoName: validation
169169
containerName: validation
170170
healthUrl: https://$(env)-validation.nhsnlink.org/health
171+
# Stand-in for the CDC DMRP API, deployed to the lower environments only. Health
172+
# answers even when the mock is disabled by configuration, so this check passes for a
173+
# deliberately dormant deployment rather than reporting it as an outage.
174+
mock-dmrp:
175+
serviceName: mock-dmrp
176+
repoName: mock-dmrp
177+
containerName: mock-dmrp
178+
healthUrl: https://$(env)-mock-dmrp.nhsnlink.org/health
171179

172180
steps:
173181
- task: KubeloginInstaller@0
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
resources:
2+
pipelines:
3+
- pipeline: pipeline-trigger
4+
source: Build_And_Push_All
5+
trigger: true
6+
7+
trigger:
8+
branches:
9+
include:
10+
- dev
11+
- release/*
12+
- linkathon/*
13+
paths:
14+
include:
15+
- DotNet/MockDmrpApi/*
16+
- DotNet/Shared/*
17+
exclude:
18+
- '*'
19+
20+
pr: none
21+
22+
pool:
23+
vmImage: 'ubuntu-latest'
24+
25+
variables:
26+
- group: link-cloud-variables
27+
- name: project
28+
value: 'DotNet/MockDmrpApi/MockDmrpApi.csproj'
29+
# ServiceTests holds this service's unit tests alongside every other service's. The filter
30+
# excludes the integration suite, which needs Docker and a database the agent does not have.
31+
- name: testProject
32+
value: 'DotNet/ServiceTests/ServiceTests.csproj'
33+
- name: testFilter
34+
value: 'FullyQualifiedName~MockDmrpApi&Category!=IntegrationTests'
35+
- name: registry-repo-Name
36+
value: 'link-mock-dmrp'
37+
- name: dockerPath
38+
value: '**/DotNet/MockDmrpApi/Dockerfile'
39+
- name: serviceName
40+
value: 'MockDmrpApi'
41+
- name: projectDir
42+
value: 'DotNet/MockDmrpApi'
43+
44+
steps:
45+
- task: DotNetCoreCLI@2
46+
displayName: Restore
47+
inputs:
48+
command: restore
49+
projects: '$(project)'
50+
51+
- task: Bash@3
52+
inputs:
53+
targetType: 'inline'
54+
script: |
55+
GIT_COMMIT=$(git rev-parse --short HEAD)
56+
echo "GIT_COMMIT: ${GIT_COMMIT}"
57+
echo "##vso[task.setvariable variable=GIT_COMMIT]${GIT_COMMIT}"
58+
59+
- task: PythonScript@0
60+
displayName: "Inject Service Info"
61+
inputs:
62+
scriptSource: 'filePath'
63+
scriptPath: '$(Build.SourcesDirectory)/Scripts/set_service_info.py'
64+
workingDirectory: '$(Build.SourcesDirectory)'
65+
arguments: './ "$(projectDir)" "$(GIT_COMMIT)" "$(Build.BuildNumber)"'
66+
67+
- task: PowerShell@2
68+
inputs:
69+
targetType: 'inline'
70+
script: |
71+
if ("$(Build.SourceBranch)" -like "*release/*") {
72+
$myTag1 = "release-$(Build.SourceBranchName)-$(GIT_COMMIT)"
73+
}
74+
else {
75+
$myTag1 = "$(Build.SourceBranchName)-$(GIT_COMMIT)"
76+
}
77+
Write-Host "##vso[task.setvariable variable=MyTag]$myTag1"
78+
Write-Host "Set MyTag to: $myTag1"
79+
80+
# Regenerates the API contract from Contracts/dmrp-openapi.yaml before compiling, so a
81+
# build here fails if the spec and the implementation have diverged.
82+
- task: DotNetCoreCLI@2
83+
displayName: Build
84+
inputs:
85+
command: build
86+
projects: '$(project)'
87+
88+
- task: DotNetCoreCLI@2
89+
inputs:
90+
command: 'test'
91+
projects: '$(testProject)'
92+
arguments: '--filter "$(testFilter)"'
93+
displayName: 'Run Tests'
94+
95+
# No `condition: always()` here, unlike most of the sibling CD pipelines. The step above
96+
# runs the test suite, and always() would publish `latest` from a commit whose tests had
97+
# just failed -- the tag every environment pulls by default. Omitting the condition leaves
98+
# the default succeeded(), so a red build stops here.
99+
- task: Docker@2
100+
displayName: "Build & Push Mock DMRP API Docker Image"
101+
inputs:
102+
containerRegistry: $(containerRegistry) # Variable Group
103+
repository: $(registry-repo-name)
104+
command: 'buildAndPush'
105+
Dockerfile: $(dockerPath)
106+
tags: |
107+
latest
108+
$(MyTag)
109+
buildContext: '$(Build.Repository.LocalPath)'
110+
111+
- task: PublishPipelineArtifact@1
112+
inputs:
113+
targetPath: '$(Build.ArtifactStagingDirectory)'
114+
artifact: 'manifest'

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
7474
<PackageVersion Include="Microsoft.Extensions.Primitives" Version="10.0.10" />
7575
<PackageVersion Include="Microsoft.Extensions.Telemetry" Version="10.1.0" />
76+
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.10.0" />
7677
<PackageVersion Include="Microsoft.Identity.Client" Version="4.84.1" />
7778
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.4.1" />
7879
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
@@ -82,6 +83,7 @@
8283
<PackageVersion Include="Moq" Version="4.20.72" />
8384
<PackageVersion Include="Moq.AutoMock" Version="4.0.3-ci0882" />
8485
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
86+
<PackageVersion Include="NSwag.MSBuild" Version="14.6.1" />
8587
<PackageVersion Include="nunit" Version="4.6.1" />
8688
<PackageVersion Include="NUnit3TestAdapter" Version="4.6.0" />
8789
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using System.Diagnostics;
2+
using System.Net;
3+
using LantanaGroup.Link.MockDmrpApi.Settings;
4+
5+
namespace LantanaGroup.Link.MockDmrpApi.Application.Extensions;
6+
7+
/// <summary>
8+
/// The RFC 9110 sections ASP.NET uses as problem-detail <c>type</c> values.
9+
/// </summary>
10+
/// <remarks>
11+
/// Named rather than inlined so the same status always carries the same type. ASP.NET already
12+
/// supplies these for the statuses it produces itself; a controller that passes a title and
13+
/// detail must supply the type too, or the response ends up with a hand-written title beside a
14+
/// missing type.
15+
/// </remarks>
16+
internal static class DmrpProblemTypes
17+
{
18+
public const string BadRequest = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
19+
public const string Unauthorized = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
20+
public const string NotFound = "https://tools.ietf.org/html/rfc9110#section-15.5.5";
21+
public const string Conflict = "https://tools.ietf.org/html/rfc9110#section-15.5.10";
22+
public const string ServiceUnavailable = "https://tools.ietf.org/html/rfc9110#section-15.6.4";
23+
}
24+
25+
/// <summary>
26+
/// Problem-detail shaping, following the pattern Terminology uses.
27+
/// </summary>
28+
/// <remarks>
29+
/// Three things this guarantees that <c>AddProblemDetails()</c> on its own does not:
30+
/// <list type="bullet">
31+
/// <item>A <c>traceId</c> on every problem response, so a report of "it returned 500" can be
32+
/// traced without asking the reporter to reproduce it.</item>
33+
/// <item>A <c>detail</c> on responses that would otherwise carry only a status code — a bare
34+
/// 404 tells a caller nothing about which of several lookups failed.</item>
35+
/// <item>No exception detail leaking outside development. A 500's detail is replaced wholesale
36+
/// rather than filtered, so an exception message cannot reach a caller by accident.</item>
37+
/// </list>
38+
/// <para>
39+
/// This applies to <b>both</b> surfaces, including the two contract endpoints. The service's own
40+
/// error shape is not something the real DMRP API has been observed to define, so matching Link's
41+
/// house style is the better default — but it is a divergence, and a consumer should not read
42+
/// this service's error bodies as evidence of what the real one returns.
43+
/// </para>
44+
/// </remarks>
45+
internal static class DmrpProblemDetailsExtensions
46+
{
47+
internal static IServiceCollection AddDmrpProblemDetails(
48+
this IServiceCollection services,
49+
IWebHostEnvironment environment,
50+
bool includeExceptionDetails = false)
51+
{
52+
services.AddProblemDetails(options =>
53+
{
54+
options.CustomizeProblemDetails = ctx =>
55+
{
56+
var statusCode = ctx.ProblemDetails.Status ?? ctx.HttpContext.Response.StatusCode;
57+
58+
if (statusCode == (int)HttpStatusCode.InternalServerError)
59+
{
60+
// Replaced, not appended: whatever the framework put here may quote an
61+
// exception, and a stand-in for a third-party API is still a service whose
62+
// internals stay internal.
63+
ctx.ProblemDetails.Detail =
64+
"An error occurred in our API. Please use the trace id when requesting assistance.";
65+
}
66+
else if (string.IsNullOrWhiteSpace(ctx.ProblemDetails.Detail))
67+
{
68+
ctx.ProblemDetails.Detail = statusCode switch
69+
{
70+
(int)HttpStatusCode.NotFound =>
71+
"The requested Mock DMRP resource was not found.",
72+
(int)HttpStatusCode.Unauthorized =>
73+
"A valid bearer token is required for this endpoint.",
74+
(int)HttpStatusCode.ServiceUnavailable =>
75+
"The Mock DMRP API is not available in this environment.",
76+
_ =>
77+
"The request could not be completed. Please use the trace id when requesting assistance."
78+
};
79+
}
80+
81+
if (!ctx.ProblemDetails.Extensions.ContainsKey("traceId"))
82+
{
83+
var traceId = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
84+
ctx.ProblemDetails.Extensions.Add(new KeyValuePair<string, object?>("traceId", traceId));
85+
}
86+
87+
if (environment.IsDevelopment() || includeExceptionDetails)
88+
{
89+
// Indexer rather than Add: Add throws on a key that is already present, and
90+
// nothing here owns this dictionary exclusively. Overwriting our own value
91+
// with the same value is harmless; throwing while building an error
92+
// response is not, since it replaces a useful 404 with an opaque 500.
93+
ctx.ProblemDetails.Extensions["API"] = DmrpApiConstants.ServiceName;
94+
}
95+
else
96+
{
97+
ctx.ProblemDetails.Extensions.Remove("exception");
98+
}
99+
};
100+
});
101+
102+
return services;
103+
}
104+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using System.Globalization;
2+
using LantanaGroup.Link.MockDmrpApi.Contracts.Generated;
3+
using LantanaGroup.Link.MockDmrpApi.Domain.Entities;
4+
5+
namespace LantanaGroup.Link.MockDmrpApi.Application.Mapping;
6+
7+
/// <summary>
8+
/// Projects stored entries into the generated contract type.
9+
/// </summary>
10+
/// <remarks>
11+
/// This is the seam that keeps the third-party contract out of the database. Everything
12+
/// below it deals in <see cref="ReportingPlanEntryEntity"/>; only this file and the two
13+
/// contract endpoints deal in generated types. When Contracts/dmrp-openapi.yaml is replaced
14+
/// -- which is expected, the current one being provisional -- the compile errors land here
15+
/// rather than in the service layer or a migration.
16+
/// <para>
17+
/// The support surface does not pass through here. It has its own models, because it is ours
18+
/// and has no reason to move when the third party's contract does.
19+
/// </para>
20+
/// </remarks>
21+
public static class EntryMapper
22+
{
23+
/// <summary>
24+
/// Projects a facility's entries into a reporting plan.
25+
/// </summary>
26+
/// <param name="nhsnOrgId">The facility the plan belongs to.</param>
27+
/// <param name="reportingMonth">
28+
/// The month the result was narrowed to, echoed from the request, or <c>null</c> when none
29+
/// was supplied. Always null on an annual plan, where a month has no meaning.
30+
/// </param>
31+
/// <param name="reportingYear">
32+
/// The year the result was narrowed to, or <c>null</c> when none was supplied -- in which
33+
/// case the entries may span several years.
34+
/// </param>
35+
/// <param name="entries">The entries the facility is enrolled in.</param>
36+
/// <param name="retrievedOn">When the response was produced.</param>
37+
/// <remarks>
38+
/// Only the supplied entries appear in <c>measures</c>. A module the facility is not
39+
/// enrolled in is simply absent -- there is no negative representation -- so an empty
40+
/// collection produces an empty measures array rather than an error or a null.
41+
/// <para>
42+
/// The month and year are echoed rather than derived from the entries. They describe what
43+
/// the caller asked for, which is the only honest answer when no period was supplied and
44+
/// the result spans several.
45+
/// </para>
46+
/// </remarks>
47+
public static ReportingPlanResponse ToReportingPlan(
48+
string nhsnOrgId,
49+
int? reportingMonth,
50+
int? reportingYear,
51+
IReadOnlyList<ReportingPlanEntryEntity> entries,
52+
DateTimeOffset retrievedOn)
53+
{
54+
ArgumentNullException.ThrowIfNull(entries);
55+
56+
return new ReportingPlanResponse
57+
{
58+
PsDMRptPlanID = PlanIdentifier(nhsnOrgId, reportingMonth, reportingYear),
59+
60+
// Numeric at the root, a string inside plans. Both come from the same
61+
// facility identifier; only the type differs. See the note below.
62+
Orgid = int.TryParse(nhsnOrgId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var orgId)
63+
? orgId
64+
: null,
65+
Year = reportingYear,
66+
Month = reportingMonth,
67+
68+
CreateDate = FormatTimestamp(entries.Count == 0
69+
? retrievedOn.UtcDateTime
70+
: entries.Min(e => e.CreateDate)),
71+
ModifyDate = FormatTimestamp(entries.Count == 0
72+
? retrievedOn.UtcDateTime
73+
: entries.Max(e => e.ModifyDate ?? e.CreateDate)),
74+
75+
Plans = entries
76+
.Select(e => new ReportingPlanItem
77+
{
78+
Name = e.Measure,
79+
Nhsnorgid = nhsnOrgId,
80+
Month = e.ReportingMonth?.ToString(CultureInfo.InvariantCulture),
81+
Year = e.ReportingYear.ToString(CultureInfo.InvariantCulture),
82+
Reporting = e.IsReporting,
83+
RptSeq = 0
84+
})
85+
.ToList()
86+
};
87+
}
88+
89+
/// <summary>
90+
/// The timestamp format the real API uses: a space separator, two fractional
91+
/// digits and no timezone.
92+
/// </summary>
93+
/// <remarks>
94+
/// Formatted by hand, and typed as a string in the contract, so it is emitted exactly as
95+
/// received rather than normalised to ISO 8601. Binding it as a date would produce
96+
/// <c>2023-09-09T11:12:12.59+00:00</c> — well-formed, and not what a consumer will have to
97+
/// parse in production.
98+
/// </remarks>
99+
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.ff";
100+
101+
private static string FormatTimestamp(DateTime value) =>
102+
value.ToString(TimestampFormat, CultureInfo.InvariantCulture);
103+
104+
/// <summary>
105+
/// A stable identifier for the plan a query describes.
106+
/// </summary>
107+
/// <remarks>
108+
/// The real API returns a stored record's key. Nothing here stores plans — they are
109+
/// assembled per request — so this derives one from what identifies the plan, which keeps
110+
/// it stable across repeated identical queries. A counter or a random value would change
111+
/// under a caller that reasonably expects it not to.
112+
/// </remarks>
113+
private static int PlanIdentifier(string nhsnOrgId, int? month, int? year)
114+
{
115+
var key = $"{nhsnOrgId}|{year?.ToString(CultureInfo.InvariantCulture) ?? "-"}"
116+
+ $"|{month?.ToString(CultureInfo.InvariantCulture) ?? "-"}";
117+
118+
// FNV-1a rather than string.GetHashCode(), which is randomised per process in .NET
119+
// Core -- the same query would return a different identifier after every restart,
120+
// which is the one thing this is supposed not to do.
121+
unchecked
122+
{
123+
const uint offsetBasis = 2166136261;
124+
const uint prime = 16777619;
125+
126+
var hash = offsetBasis;
127+
foreach (var c in key)
128+
{
129+
hash = (hash ^ c) * prime;
130+
}
131+
132+
// Masked to 31 bits so it is always non-negative and reads like a record id.
133+
return (int)(hash & 0x7FFFFFFF);
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)