Skip to content

Commit e6276f9

Browse files
authored
LEGLINK-807: Implement Measure Mappings Functionality (#1807)
* Implement MeasureMapping api * Update DMRP package lock * Fix tests * Coderabbit fixes * Fix failing tests
1 parent 7b06836 commit e6276f9

19 files changed

Lines changed: 1517 additions & 50 deletions

DotNet/DMRP/Business/Managers/MeasureMappingManager.cs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
using LantanaGroup.Link.Shared.Application.Models;
33
using LantanaGroup.Link.Shared.Application.Services.Security;
44
using LantanaGroup.Link.Shared.Domain.Repositories.Interfaces;
5+
using Microsoft.Data.SqlClient;
6+
using Microsoft.Data.Sqlite;
7+
using Microsoft.EntityFrameworkCore;
58
using OpenTelemetry.Trace;
69
using System.Diagnostics;
710

@@ -12,6 +15,15 @@ public interface IMeasureMappingManager
1215
Task<MeasureMapping> CreateAsync(MeasureMapping newMeasureMapping, CancellationToken cancellationToken = default);
1316
Task UpdateAsync(string id, MeasureMapping measureMapping, CancellationToken cancellationToken = default);
1417
Task DeleteAsync(string id, CancellationToken cancellationToken = default);
18+
Task DeleteAllAsync(CancellationToken cancellationToken = default);
19+
}
20+
21+
internal sealed class DuplicateMeasureMappingException : ApplicationException
22+
{
23+
public DuplicateMeasureMappingException(Exception innerException)
24+
: base("A measure mapping with this measure and DQM already exists.", innerException)
25+
{
26+
}
1527
}
1628

1729
public class MeasureMappingManager : IMeasureMappingManager
@@ -38,16 +50,26 @@ public async Task<MeasureMapping> CreateAsync(MeasureMapping newMeasureMapping,
3850
{
3951
throw;
4052
}
53+
catch (DbUpdateException ex) when (IsUniqueIndexViolation(ex))
54+
{
55+
Activity.Current?.SetStatus(ActivityStatusCode.Error);
56+
Activity.Current?.AddException(ex);
57+
throw new DuplicateMeasureMappingException(ex);
58+
}
4159
catch (Exception ex)
4260
{
4361
Activity.Current?.SetStatus(ActivityStatusCode.Error);
4462
Activity.Current?.AddException(ex);
45-
throw new ApplicationException("Measure mapping failed to create. " + ex.Message);
63+
throw new ApplicationException("Measure mapping failed to create. " + ex.Message, ex);
4664
}
4765

4866
return newMeasureMapping;
4967
}
5068

69+
private static bool IsUniqueIndexViolation(DbUpdateException exception) =>
70+
exception.InnerException is SqliteException { SqliteExtendedErrorCode: 2067 }
71+
|| exception.InnerException is SqlException { Number: 2601 or 2627 };
72+
5173
public async Task UpdateAsync(string id, MeasureMapping measureMapping, CancellationToken cancellationToken = default)
5274
{
5375
using Activity? activity = ServiceActivitySource.Instance.StartActivity("Update Measure Mapping");
@@ -59,7 +81,9 @@ public async Task UpdateAsync(string id, MeasureMapping measureMapping, Cancella
5981
throw new ApplicationException($"Measure mapping with Id: {id} not found");
6082
}
6183

62-
// TODO: Map `measureMapping` to `existing`
84+
existing.Measure = measureMapping.Measure;
85+
existing.DQM = measureMapping.DQM;
86+
existing.Frequency = measureMapping.Frequency;
6387

6488
try
6589
{
@@ -70,6 +94,12 @@ public async Task UpdateAsync(string id, MeasureMapping measureMapping, Cancella
7094
{
7195
throw;
7296
}
97+
catch (DbUpdateException ex) when (IsUniqueIndexViolation(ex))
98+
{
99+
Activity.Current?.SetStatus(ActivityStatusCode.Error);
100+
Activity.Current?.AddException(ex);
101+
throw new DuplicateMeasureMappingException(ex);
102+
}
73103
catch (Exception ex)
74104
{
75105
Activity.Current?.SetStatus(ActivityStatusCode.Error);
@@ -105,5 +135,30 @@ public async Task DeleteAsync(string id, CancellationToken cancellationToken = d
105135
throw new ApplicationException($"Measure mapping {id} failed to delete. " + ex.Message);
106136
}
107137
}
138+
139+
public async Task DeleteAllAsync(CancellationToken cancellationToken = default)
140+
{
141+
using Activity? activity = ServiceActivitySource.Instance.StartActivity("Delete All Measure Mappings");
142+
143+
try
144+
{
145+
var measureMappings = await _repository.GetAllAsync(cancellationToken);
146+
foreach (var measureMapping in measureMappings)
147+
{
148+
_repository.Remove(measureMapping);
149+
}
150+
await _repository.SaveChangesAsync(cancellationToken);
151+
}
152+
catch (OperationCanceledException)
153+
{
154+
throw;
155+
}
156+
catch (Exception ex)
157+
{
158+
Activity.Current?.SetStatus(ActivityStatusCode.Error);
159+
Activity.Current?.AddException(ex);
160+
throw new ApplicationException("Failed to delete all measure mappings. " + ex.Message);
161+
}
162+
}
108163
}
109164
}

DotNet/DMRP/Business/Queries/MeasureMappingQueries.cs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ public interface IMeasureMappingQueries
1010
{
1111
Task<MeasureMappingModel?> GetAsync(string id, CancellationToken cancellationToken = default);
1212

13-
Task<PagedMeasureMappingDto> PagedSearchAsync(string sortBy = "Id", SortOrder sortOrder = SortOrder.Descending,
14-
int pageSize = 10, int pageNumber = 1, CancellationToken cancellationToken = default);
13+
Task<PagedMeasureMappingDto> PagedSearchAsync(SearchMeasureMappingDto searchDto, CancellationToken cancellationToken = default);
1514
}
1615

1716
public class MeasureMappingQueries : IMeasureMappingQueries
@@ -30,12 +29,18 @@ public MeasureMappingQueries(IEntityRepository<MeasureMapping> repository)
3029
return entity == null ? null : ToModel(entity);
3130
}
3231

33-
public async Task<PagedMeasureMappingDto> PagedSearchAsync(string sortBy = "Id",
34-
SortOrder sortOrder = SortOrder.Descending, int pageSize = 10, int pageNumber = 1,
35-
CancellationToken cancellationToken = default)
32+
public async Task<PagedMeasureMappingDto> PagedSearchAsync(SearchMeasureMappingDto searchDto, CancellationToken cancellationToken = default)
3633
{
37-
var (records, metadata) = await _repository.SearchAsync(m => true, sortBy, sortOrder,
38-
pageSize, pageNumber, cancellationToken);
34+
var measure = string.IsNullOrWhiteSpace(searchDto.Measure) ? null : searchDto.Measure;
35+
var dqm = string.IsNullOrWhiteSpace(searchDto.DQM) ? null : searchDto.DQM;
36+
var frequency = searchDto.Frequency;
37+
38+
var (records, metadata) = await _repository.SearchAsync(
39+
m => (measure == null || m.Measure == measure)
40+
&& (dqm == null || m.DQM == dqm)
41+
&& (!frequency.HasValue || m.Frequency == frequency.Value),
42+
searchDto.SortBy, searchDto.SortOrder,
43+
searchDto.PageSize, searchDto.PageNumber, cancellationToken);
3944

4045
return new PagedMeasureMappingDto
4146
{
@@ -44,6 +49,12 @@ public async Task<PagedMeasureMappingDto> PagedSearchAsync(string sortBy = "Id",
4449
};
4550
}
4651

47-
private static MeasureMappingModel ToModel(MeasureMapping entity) => new() { Id = entity.Id };
52+
private static MeasureMappingModel ToModel(MeasureMapping entity) => new()
53+
{
54+
Id = entity.Id,
55+
Measure = entity.Measure,
56+
DQM = entity.DQM,
57+
Frequency = entity.Frequency
58+
};
4859
}
4960
}

DotNet/DMRP/Controllers/MeasureMappingsController.cs

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
using LantanaGroup.Link.DMRP.Data.Entities;
44
using LantanaGroup.Link.DMRP.Models;
55
using LantanaGroup.Link.Shared.Application.Enums;
6+
using LantanaGroup.Link.Shared.Application.Filters;
67
using LantanaGroup.Link.Shared.Application.Models;
78
using LantanaGroup.Link.Shared.Application.Models.Integration.DMRP;
89
using LantanaGroup.Link.Shared.Application.Services.Security;
10+
using LantanaGroup.Link.Sdk.Clients;
911
using Link.Authorization.Policies;
1012
using Microsoft.AspNetCore.Authorization;
1113
using Microsoft.AspNetCore.Mvc;
1214
using OpenTelemetry.Trace;
1315
using System.Diagnostics;
16+
using System.Net;
1417

1518
namespace LantanaGroup.Link.DMRP.Controllers
1619
{
@@ -22,12 +25,18 @@ public class MeasureMappingsController : ControllerBase
2225
private readonly ILogger<MeasureMappingsController> _logger;
2326
private readonly IMeasureMappingManager _manager;
2427
private readonly IMeasureMappingQueries _queries;
28+
private readonly IMeasureEvalServiceClient _measureEvalClient;
2529

26-
public MeasureMappingsController(ILogger<MeasureMappingsController> logger, IMeasureMappingManager manager, IMeasureMappingQueries queries)
30+
public MeasureMappingsController(
31+
ILogger<MeasureMappingsController> logger,
32+
IMeasureMappingManager manager,
33+
IMeasureMappingQueries queries,
34+
IMeasureEvalServiceClient measureEvalClient)
2735
{
2836
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
2937
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
3038
_queries = queries ?? throw new ArgumentNullException(nameof(queries));
39+
_measureEvalClient = measureEvalClient ?? throw new ArgumentNullException(nameof(measureEvalClient));
3140
}
3241

3342
/// <summary>
@@ -36,26 +45,14 @@ public MeasureMappingsController(ILogger<MeasureMappingsController> logger, IMea
3645
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PagedMeasureMappingDto))]
3746
[ProducesResponseType(StatusCodes.Status204NoContent)]
3847
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
39-
[HttpGet(Name = "GetMeasureMappings")]
40-
public async Task<IActionResult> GetMeasureMappings(string? sortBy, SortOrder? sortOrder,
41-
int pageSize = 10, int pageNumber = 1, CancellationToken cancellationToken = default)
48+
[HttpGet("search")]
49+
public async Task<IActionResult> GetMeasureMappings([FromQuery] SearchMeasureMappingDto searchDto, CancellationToken cancellationToken = default)
4250
{
43-
sortBy = sortBy?.Sanitize();
44-
45-
if (pageSize < 1 || pageSize > 100)
46-
{
47-
pageSize = 10;
48-
}
49-
50-
if (pageNumber < 1)
51-
{
52-
pageNumber = 1;
53-
}
51+
searchDto.Sanitize();
5452

5553
using Activity? activity = ServiceActivitySource.Instance.StartActivity("Get Measure Mappings");
5654

57-
var result = await _queries.PagedSearchAsync(sortBy ?? "Id", sortOrder ?? SortOrder.Descending,
58-
pageSize, pageNumber, cancellationToken);
55+
var result = await _queries.PagedSearchAsync(searchDto, cancellationToken);
5956

6057
if (result.Records.Count == 0)
6158
{
@@ -91,30 +88,47 @@ public async Task<IActionResult> GetMeasureMapping(string id, CancellationToken
9188
/// </summary>
9289
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MeasureMappingModel))]
9390
[ProducesResponseType(StatusCodes.Status400BadRequest)]
91+
[ProducesResponseType(StatusCodes.Status502BadGateway, Type = typeof(ProblemDetails))]
9492
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
93+
[ValidateAntiForgeryOrBearerToken]
9594
[HttpPost]
9695
public async Task<IActionResult> CreateMeasureMapping(MeasureMappingModel request, CancellationToken cancellationToken)
9796
{
9897
MeasureMapping created;
9998

10099
try
101100
{
102-
var entity = new MeasureMapping();
103-
// TODO: Map `request` to `entity`
101+
var entity = ToEntity(request);
102+
if (!await DqmExistsAsync(entity.DQM, cancellationToken))
103+
{
104+
return BadRequest($"DQM '{entity.DQM}' was not found in MeasureEval.");
105+
}
106+
104107
created = await _manager.CreateAsync(entity, cancellationToken);
105108
}
106-
catch (ApplicationException ex)
109+
catch (DuplicateMeasureMappingException)
110+
{
111+
return ValidationProblem(new ValidationProblemDetails(new Dictionary<string, string[]>
112+
{
113+
["measure"] = ["A measure mapping for this measure and dQM already exists."]
114+
}));
115+
}
116+
catch (ApplicationException)
117+
{
118+
return BadRequest();
119+
}
120+
catch (HttpRequestException ex)
107121
{
108-
return BadRequest(ex.Message);
122+
_logger.LogError(ex, "MeasureEval failed while verifying the DQM for a measure mapping.");
123+
return Problem("Unable to verify the DQM in MeasureEval.", statusCode: StatusCodes.Status502BadGateway);
109124
}
110125
catch (Exception ex)
111126
{
112127
_logger.LogError(ex, "Exception encountered in MeasureMappingsController.CreateMeasureMapping");
113128
return Problem("An error occurred while creating the measure mapping", null, 500);
114129
}
115130

116-
var model = new MeasureMappingModel { Id = created.Id };
117-
// TODO: Map `created` to `model`
131+
var model = ToModel(created);
118132

119133
return Created($"/api/dmrp/measure-mappings/{model.Id}", model);
120134
}
@@ -125,7 +139,9 @@ public async Task<IActionResult> CreateMeasureMapping(MeasureMappingModel reques
125139
[ProducesResponseType(StatusCodes.Status202Accepted, Type = typeof(MeasureMappingModel))]
126140
[ProducesResponseType(StatusCodes.Status400BadRequest)]
127141
[ProducesResponseType(StatusCodes.Status404NotFound)]
142+
[ProducesResponseType(StatusCodes.Status502BadGateway, Type = typeof(ProblemDetails))]
128143
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
144+
[ValidateAntiForgeryOrBearerToken]
129145
[HttpPut("{id}")]
130146
public async Task<IActionResult> UpdateMeasureMapping(string id, MeasureMappingModel request, CancellationToken cancellationToken)
131147
{
@@ -138,12 +154,32 @@ public async Task<IActionResult> UpdateMeasureMapping(string id, MeasureMappingM
138154

139155
try
140156
{
141-
await _manager.UpdateAsync(id, new MeasureMapping { Id = id }, cancellationToken);
157+
var entity = ToEntity(request);
158+
entity.Id = id;
159+
160+
if (!await DqmExistsAsync(entity.DQM, cancellationToken))
161+
{
162+
return BadRequest($"DQM '{entity.DQM}' was not found in MeasureEval.");
163+
}
164+
165+
await _manager.UpdateAsync(id, entity, cancellationToken);
166+
}
167+
catch (DuplicateMeasureMappingException)
168+
{
169+
return ValidationProblem(new ValidationProblemDetails(new Dictionary<string, string[]>
170+
{
171+
["measure"] = ["A measure mapping for this measure and dQM already exists."]
172+
}));
142173
}
143174
catch (ApplicationException ex)
144175
{
145176
return NotFound(ex.Message);
146177
}
178+
catch (HttpRequestException ex)
179+
{
180+
_logger.LogError(ex, "MeasureEval failed while verifying the DQM for a measure mapping.");
181+
return Problem("Unable to verify the DQM in MeasureEval.", statusCode: StatusCodes.Status502BadGateway);
182+
}
147183
catch (Exception ex)
148184
{
149185
_logger.LogError(ex, "Exception encountered in MeasureMappingsController.UpdateMeasureMapping");
@@ -182,5 +218,60 @@ public async Task<IActionResult> DeleteMeasureMapping(string id, CancellationTok
182218

183219
return NoContent();
184220
}
221+
222+
/// <summary>
223+
/// Deletes all measure mappings.
224+
/// </summary>
225+
[ProducesResponseType(StatusCodes.Status204NoContent)]
226+
[ProducesResponseType(StatusCodes.Status404NotFound)]
227+
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
228+
[ValidateAntiForgeryOrBearerToken]
229+
[HttpDelete]
230+
public async Task<IActionResult> DeleteAllMeasureMappings(CancellationToken cancellationToken)
231+
{
232+
try
233+
{
234+
await _manager.DeleteAllAsync(cancellationToken);
235+
}
236+
catch (Exception ex)
237+
{
238+
_logger.LogError(ex, "Exception encountered in MeasureMappingsController.DeleteAllMeasureMappings");
239+
return Problem("An error occurred while deleting the measure mappings", null, 500);
240+
}
241+
242+
return NoContent();
243+
}
244+
245+
private static MeasureMapping ToEntity(MeasureMappingModel model) => new()
246+
{
247+
Measure = model.Measure?.Sanitize() ?? "",
248+
DQM = model.DQM?.Sanitize() ?? "",
249+
Frequency = model.Frequency ?? Frequency.Adhoc
250+
};
251+
252+
private static MeasureMappingModel ToModel(MeasureMapping entity) => new()
253+
{
254+
Id = entity.Id,
255+
Measure = entity.Measure,
256+
DQM = entity.DQM,
257+
Frequency = entity.Frequency
258+
};
259+
260+
private async Task<bool> DqmExistsAsync(string dqm, CancellationToken cancellationToken)
261+
{
262+
var response = await _measureEvalClient.GetMeasureDefinitionAsync(dqm, cancellationToken);
263+
264+
if (response.StatusCode == StatusCodes.Status404NotFound)
265+
{
266+
return false;
267+
}
268+
269+
if (!response.IsSuccessStatusCode)
270+
{
271+
throw new HttpRequestException($"MeasureEval returned status code {response.StatusCode} while verifying a DQM.", null, (HttpStatusCode)response.StatusCode);
272+
}
273+
274+
return true;
275+
}
185276
}
186277
}

DotNet/DMRP/DMRP.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
</ItemGroup>
3232

3333
<ItemGroup>
34+
<ProjectReference Include="..\LinkSdk\LinkSdk.csproj" />
3435
<ProjectReference Include="..\Shared\Shared.csproj" />
3536
</ItemGroup>
3637

0 commit comments

Comments
 (0)