Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,7 @@ All notable changes to this service will be documented in this file.
## [1.5.0] - Public Beta
### Notes
- Added support for Custom Application Status

## [1.5.1] - Public Beta
### Notes
- Added a new request model for Custom Statuses and fixed model bugs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Asp.Versioning;
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
using DfE.ExternalApplications.Application.Common.Exceptions;
using DfE.ExternalApplications.Application.Templates.Commands;
using DfE.ExternalApplications.Application.Templates.Queries;
using MediatR;
Expand Down Expand Up @@ -63,10 +64,14 @@ public async Task<IActionResult> GetCustomApplicationStatusesAsync([FromRoute] G
/// </summary>
[HttpPost("{templateId}/custom-statuses")]
[SwaggerResponse(201, "Custom status created/updated.", typeof(CustomApplicationStatusDto))]
[SwaggerResponse(400, "Invalid request data.", typeof(ExceptionResponse))]
[Authorize(Policy = "CanWriteTemplate")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> CreateCustomApplicationStatusAsync([FromRoute] Guid templateId, [FromBody] CustomApplicationStatusDto request, CancellationToken cancellationToken)
public async Task<IActionResult> CreateCustomApplicationStatusAsync([FromRoute] Guid templateId, [FromBody] CustomApplicationStatusRequest request, CancellationToken cancellationToken)
{
if (request is null)
throw new BadRequestException("Invalid request data.");

var command = new UpdateCustomApplicationStatusCommand(templateId, request.ApplicationStatus, request.Label);
var result = await sender.Send(command, cancellationToken);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<NoWarn>$(NoWarn);1591</NoWarn>
<TargetFramework>net10.0</TargetFramework>
<UserSecretsId>f714ca7a-fc08-46ff-b0cc-373d6f04cf4c</UserSecretsId>
<Version>1.5.0</Version>
<InformationalVersion>1.5.0</InformationalVersion>
<Version>1.5.1</Version>
<InformationalVersion>1.5.1</InformationalVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using GovUK.Dfe.CoreLibs.Http.Interfaces;
using GovUK.Dfe.CoreLibs.Http.Models;
using Microsoft.AspNetCore.Http;

namespace DfE.ExternalApplications.Api.ExceptionHandlers;

/// <summary>
/// Handles invalid JSON request bodies, including unrecognised enum values.
/// </summary>
[ExcludeFromCodeCoverage]
public class JsonExceptionHandler : ICustomExceptionHandler
{
public bool CanHandle(Type exceptionType) =>
exceptionType == typeof(JsonException) || exceptionType == typeof(BadHttpRequestException);

public int Priority => 15;

public ExceptionResponse Handle(Exception exception, Dictionary<string, object>? context = null)
{
var details = exception switch
{
JsonException jsonException => jsonException.Message,
BadHttpRequestException badHttpRequestException => badHttpRequestException.Message,
_ => exception.Message
};

return new ExceptionResponse
{
StatusCode = StatusCodes.Status400BadRequest,
Message = "Invalid request data.",
Details = details,
ExceptionType = exception.GetType().Name
};
}
}
14 changes: 14 additions & 0 deletions src/DfE.ExternalApplications.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
var allTenants = tenantConfigurationProvider.GetAllTenants();

// Startup validation: at least one tenant must be configured
if (!allTenants.Any())

Check warning on line 104 in src/DfE.ExternalApplications.Api/Program.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer comparing 'Count' to 0 rather than using 'Any()', both for clarity and for performance

See more on https://sonarcloud.io/project/issues?id=DFE-Digital_external-applications-api&issues=AZ88uncyZNXfVQULORwC&open=AZ88uncyZNXfVQULORwC&pullRequest=121
{
throw new InvalidOperationException(
"At least one tenant must be configured in the 'Tenants' section of appsettings.");
Expand All @@ -112,6 +112,7 @@
builder.Services.AddScoped<ICorrelationContext, CorrelationContext>();

builder.Services.AddCustomExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddCustomExceptionHandler<JsonExceptionHandler>();
builder.Services.AddCustomExceptionHandler<ApplicationExceptionHandler>();

// Collect all frontend origins from all tenants for the default CORS policy
Expand All @@ -122,7 +123,7 @@
.ToArray();

builder.Services.AddCors(o => o.AddPolicy("Frontend", p =>
p.WithOrigins(allFrontendOrigins.Length > 0 ? allFrontendOrigins : new[] { "https://localhost:7020" })

Check warning on line 126 in src/DfE.ExternalApplications.Api/Program.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array

See more on https://sonarcloud.io/project/issues?id=DFE-Digital_external-applications-api&issues=AZ88uncyZNXfVQULORwD&open=AZ88uncyZNXfVQULORwD&pullRequest=121
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()));
Expand Down Expand Up @@ -203,7 +204,7 @@
ForwardedHeaders = ForwardedHeaders.All,
RequireHeaderSymmetry = false
};
forwardOptions.KnownNetworks.Clear();

Check warning on line 207 in src/DfE.ExternalApplications.Api/Program.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'ForwardedHeadersOptions.KnownNetworks' is obsolete: 'Please use KnownIPNetworks instead. For more information, visit https://aka.ms/aspnet/deprecate/005.'

See more on https://sonarcloud.io/project/issues?id=DFE-Digital_external-applications-api&issues=AZ88uncyZNXfVQULORwA&open=AZ88uncyZNXfVQULORwA&pullRequest=121
forwardOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardOptions);

Expand Down Expand Up @@ -269,6 +270,19 @@
options.IncludeDetails = builder.Environment.IsDevelopment();
options.LogExceptions = true;
options.DefaultErrorMessage = "Something went wrong";
options.SharedPostProcessingAction = (exception, response) =>
{
if (exception is DfE.ExternalApplications.Application.Common.Exceptions.ValidationException validationException)
{
response.Details = string.Join("; ",
validationException.Errors
.SelectMany(kvp => kvp.Value.Select(error => $"{kvp.Key}: {error}")));
}
else if (exception is JsonException jsonException)
{
response.Details = jsonException.Message;
}
};
});


Expand All @@ -281,14 +295,14 @@
app.UseAuthentication();
app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();

endpoints.MapHub<Hubs.NotificationHub>("/hubs/notifications")
.RequireAuthorization("Cookies.CanReadNotifications")
.RequireCors("Frontend");
});

Check warning on line 305 in src/DfE.ExternalApplications.Api/Program.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Suggest using top level route registrations instead of UseEndpoints

See more on https://sonarcloud.io/project/issues?id=DFE-Digital_external-applications-api&issues=AZ88uncyZNXfVQULORwE&open=AZ88uncyZNXfVQULORwE&pullRequest=121

ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Logger is working...");
Expand All @@ -314,7 +328,7 @@
.Where(x => !string.IsNullOrEmpty(x.ConnectionString))
.ToList();

if (signalREndpoints.Any())

Check warning on line 331 in src/DfE.ExternalApplications.Api/Program.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer comparing 'Count' to 0 rather than using 'Any()', both for clarity and for performance

See more on https://sonarcloud.io/project/issues?id=DFE-Digital_external-applications-api&issues=AZ88uncyZNXfVQULORwB&open=AZ88uncyZNXfVQULORwB&pullRequest=121
{
// Use Azure SignalR Service with multiple endpoints (one per tenant)
services.AddSignalR()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using DfE.ExternalApplications.Application.Templates.QueryObjects;
using DfE.ExternalApplications.Domain.Entities;
using DfE.ExternalApplications.Domain.Interfaces;
using DfE.ExternalApplications.Domain.Interfaces.Repositories;
Expand All @@ -18,7 +19,7 @@ namespace DfE.ExternalApplications.Application.Templates.Commands
/// </summary>
public sealed record UpdateCustomApplicationStatusCommand(
Guid TemplateId,
ApplicationStatus ApplicationStatus,
ApplicationStatus? ApplicationStatus,
string? Label) : IRequest<Result<CustomApplicationStatusDto>>;

public sealed class UpdateCustomApplicationStatusCommandHandler(
Expand Down Expand Up @@ -62,55 +63,48 @@ public async Task<Result<CustomApplicationStatusDto>> Handle(UpdateCustomApplica
var createdByUserId = dbUser.Id;

// Check if custom status exists for this template and application status
var existing = await customApplicationStatusRepo.Query()
.FirstOrDefaultAsync(x => x.TemplateId == new TemplateId(request.TemplateId) && x.ApplicationStatus == request.ApplicationStatus, cancellationToken);
var existing = await new GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject(
request.TemplateId,
request.ApplicationStatus!.Value)
.Apply(customApplicationStatusRepo.Query())
.FirstOrDefaultAsync(cancellationToken);

if (existing is not null)
{
// Update existing
existing.UpdateLabel(request.Label);
await unitOfWork.CommitAsync(cancellationToken);

var dto = new CustomApplicationStatusDto
{
CustomApplicationStatusId = existing.Id!.Value,
TemplateId = existing.TemplateId.Value,
ApplicationStatus = existing.ApplicationStatus,
Label = existing.Label,
CreatedOn = DateTime.UtcNow,
CreatedBy = createdByUserId.Value
};

return Result<CustomApplicationStatusDto>.Success(dto);
return Result<CustomApplicationStatusDto>.Success(MapToDto(existing));
}

var entity = new CustomApplicationStatus(
new CustomApplicationStatusId(Guid.NewGuid()),
new TemplateId(request.TemplateId),
request.ApplicationStatus,
request.ApplicationStatus!.Value,
request.Label,
DateTime.UtcNow,
createdByUserId);

await customApplicationStatusRepo.AddAsync(entity, cancellationToken);
await unitOfWork.CommitAsync(cancellationToken);

var createdDto = new CustomApplicationStatusDto
{
CustomApplicationStatusId = entity.Id!.Value,
TemplateId = entity.TemplateId.Value,
ApplicationStatus = entity.ApplicationStatus,
Label = entity.Label,
CreatedOn = entity.CreatedOn,
CreatedBy = entity.CreatedBy.Value
};

return Result<CustomApplicationStatusDto>.Success(createdDto);
return Result<CustomApplicationStatusDto>.Success(MapToDto(entity));
}
catch (Exception e)
{
return Result<CustomApplicationStatusDto>.Failure(e.ToString());
}
}

private static CustomApplicationStatusDto MapToDto(CustomApplicationStatus entity) =>
new()
{
CustomApplicationStatusId = entity.Id!.Value,
TemplateId = entity.TemplateId.Value,
ApplicationStatus = entity.ApplicationStatus,
Label = entity.Label,
CreatedOn = entity.CreatedOn,
CreatedBy = entity.CreatedBy.Value
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using FluentValidation;
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;

namespace DfE.ExternalApplications.Application.Templates.Commands;

/// <summary>
/// Validates create or update custom application status commands.
/// </summary>
public class UpdateCustomApplicationStatusCommandValidator : AbstractValidator<UpdateCustomApplicationStatusCommand>
{
public UpdateCustomApplicationStatusCommandValidator()
{
RuleFor(x => x.Label)
.NotEmpty()
.WithMessage("Label is required");

RuleFor(x => x.ApplicationStatus)
.NotNull()
.WithMessage("ApplicationStatus is required")
.IsInEnum()
.WithMessage($"ApplicationStatus must be a valid {nameof(ApplicationStatus)} value");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using DfE.ExternalApplications.Application.Templates.QueryObjects;
using DfE.ExternalApplications.Domain.Entities;
using DfE.ExternalApplications.Domain.Interfaces.Repositories;
using DfE.ExternalApplications.Domain.ValueObjects;
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
using MediatR;
Expand All @@ -19,8 +19,11 @@ public async Task<Result<CustomApplicationStatusDto>> Handle(GetCustomApplicatio
{
try
{
var entity = await customApplicationStatusRepo.Query()
.FirstOrDefaultAsync(x => x.TemplateId == new TemplateId(request.TemplateId) && x.ApplicationStatus == request.ApplicationStatus, cancellationToken);
var entity = await new GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject(
request.TemplateId,
request.ApplicationStatus)
.Apply(customApplicationStatusRepo.Query())
.FirstOrDefaultAsync(cancellationToken);

if (entity is null)
return Result<CustomApplicationStatusDto>.NotFound("Custom application status not found");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using DfE.ExternalApplications.Application.Common.QueriesObjects;
using DfE.ExternalApplications.Domain.Entities;
using DfE.ExternalApplications.Domain.ValueObjects;
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;

namespace DfE.ExternalApplications.Application.Templates.QueryObjects;

/// <summary>
/// Filters custom application statuses by template and application status.
/// </summary>
public sealed class GetCustomApplicationStatusByTemplateIdAndApplicationStatusQueryObject(
Guid templateId,
ApplicationStatus applicationStatus) : IQueryObject<CustomApplicationStatus>
{
private readonly TemplateId _templateId = new(templateId);

public IQueryable<CustomApplicationStatus> Apply(IQueryable<CustomApplicationStatus> query) =>
query.Where(x => x.TemplateId == _templateId && x.ApplicationStatus == applicationStatus);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="GovUK.Dfe.CoreLibs.Caching" Version="1.0.13" />
<PackageReference Include="FluentValidation" Version="12.0.0" />
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.69" />
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.72" />
<PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4436,7 +4436,7 @@ public string BaseUrl
/// </summary>
/// <returns>Custom status created/updated.</returns>
/// <exception cref="ExternalApplicationsException">A server side error occurred.</exception>
public virtual async System.Threading.Tasks.Task<CustomApplicationStatusDto> CreateCustomApplicationStatusAsync(System.Guid templateId, CustomApplicationStatusDto request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken))
public virtual async System.Threading.Tasks.Task<CustomApplicationStatusDto> CreateCustomApplicationStatusAsync(System.Guid templateId, CustomApplicationStatusRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken))
{
if (templateId == null)
throw new System.ArgumentNullException("templateId");
Expand Down Expand Up @@ -4497,6 +4497,16 @@ public string BaseUrl
return objectResponse_.Object;
}
else
if (status_ == 400)
{
var objectResponse_ = await ReadObjectResponseAsync<ExceptionResponse>(response_, headers_, cancellationToken).ConfigureAwait(false);
if (objectResponse_.Object == null)
{
throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
}
throw new ExternalApplicationsException<ExceptionResponse>("Invalid request data.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
}
else
{
var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false);
throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ public partial interface ITemplatesClient
/// </summary>
/// <returns>Custom status created/updated.</returns>
/// <exception cref="ExternalApplicationsException">A server side error occurred.</exception>
System.Threading.Tasks.Task<CustomApplicationStatusDto> CreateCustomApplicationStatusAsync(System.Guid templateId, CustomApplicationStatusDto request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));
System.Threading.Tasks.Task<CustomApplicationStatusDto> CreateCustomApplicationStatusAsync(System.Guid templateId, CustomApplicationStatusRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));

/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2657,7 +2657,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomApplicationStatusDto"
"$ref": "#/components/schemas/CustomApplicationStatusRequest"
}
}
},
Expand All @@ -2674,6 +2674,16 @@
}
}
}
},
"400": {
"description": "Invalid request data.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExceptionResponse"
}
}
}
}
}
}
Expand Down Expand Up @@ -3839,12 +3849,29 @@
"type": "string",
"format": "date-time"
},
"CreatedBy": {
"createdBy": {
"type": "string",
"format": "guid"
}
}
},
"CustomApplicationStatusRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"applicationStatus",
"label"
],
"properties": {
"applicationStatus": {
"$ref": "#/components/schemas/ApplicationStatus"
},
"label": {
"type": "string",
"minLength": 1
}
}
},
"CreateTemplateVersionRequest": {
"type": "object",
"additionalProperties": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.69" />
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.72" />
<PackageReference Include="GovUK.Dfe.CoreLibs.Http" Version="1.0.10" />
<PackageReference Include="GovUK.Dfe.CoreLibs.Security" Version="1.1.25-prerelease-11" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.72.1" />
Expand Down
Loading
Loading